mirror of
https://github.com/shadps4-emu/shadPS4-launcher.git
synced 2026-01-31 00:55:20 +01:00
feat: game details page
This commit is contained in:
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@@ -5,6 +5,7 @@
|
|||||||
"src-tauri/**": true,
|
"src-tauri/**": true,
|
||||||
"dist/**": true
|
"dist/**": true
|
||||||
},
|
},
|
||||||
|
"typescript.preferences.autoImportSpecifierExcludeRegexes": ["@radix-ui"],
|
||||||
"[typescript]": {
|
"[typescript]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Suspense } from "react";
|
|||||||
import { LoadingScreen } from "./components/loading-overlay";
|
import { LoadingScreen } from "./components/loading-overlay";
|
||||||
import { MainPage } from "./components/main-page";
|
import { MainPage } from "./components/main-page";
|
||||||
import { FolderConfigModal } from "./components/modals/folder-config-modal";
|
import { FolderConfigModal } from "./components/modals/folder-config-modal";
|
||||||
|
import { GameDetailsModal } from "./components/modals/game-details-modal";
|
||||||
import { RunningGameModal } from "./components/modals/running-game-modal";
|
import { RunningGameModal } from "./components/modals/running-game-modal";
|
||||||
import { VersionManagerModal } from "./components/modals/version-manager-modal";
|
import { VersionManagerModal } from "./components/modals/version-manager-modal";
|
||||||
import { UpdateIcon } from "./components/update-icon";
|
import { UpdateIcon } from "./components/update-icon";
|
||||||
@@ -19,6 +20,7 @@ export function App() {
|
|||||||
<VersionManagerModal />
|
<VersionManagerModal />
|
||||||
<UpdateIcon />
|
<UpdateIcon />
|
||||||
<RunningGameModal />
|
<RunningGameModal />
|
||||||
|
<GameDetailsModal />
|
||||||
<MainPage />
|
<MainPage />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
179
src/components/game-box.tsx
Normal file
179
src/components/game-box.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
|
||||||
|
import { CircleHelp, FrownIcon, Globe, ImageOff, Play } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import CN from "@/assets/flags/cn.svg";
|
||||||
|
import EU from "@/assets/flags/eu.svg";
|
||||||
|
import JP from "@/assets/flags/jp.svg";
|
||||||
|
import US from "@/assets/flags/us.svg";
|
||||||
|
import { startGame } from "@/handlers/run-emu";
|
||||||
|
import type { PSF } from "@/lib/native/psf";
|
||||||
|
import { atomShowingGameDetails } from "@/store/common";
|
||||||
|
import type { GameEntry } from "@/store/game-library";
|
||||||
|
import { gamepadActiveAtom } from "@/store/gamepad";
|
||||||
|
import { atomShowingRunningGame } from "@/store/running-games";
|
||||||
|
import { atomSelectedVersion } from "@/store/version-manager";
|
||||||
|
import { stringifyError } from "@/utils/error";
|
||||||
|
import GamepadIcon from "./gamepad-icon";
|
||||||
|
import { Skeleton } from "./ui/skeleton";
|
||||||
|
import { Spinner } from "./ui/spinner";
|
||||||
|
|
||||||
|
function Flag({ sfo, className }: { sfo: PSF | null; className?: string }) {
|
||||||
|
const region = useMemo(() => {
|
||||||
|
const { CONTENT_ID } = sfo?.entries ?? {};
|
||||||
|
return CONTENT_ID?.Text?.[0] ?? undefined;
|
||||||
|
}, [sfo]);
|
||||||
|
|
||||||
|
switch (region) {
|
||||||
|
case "U":
|
||||||
|
return <img alt="US" className={className} src={US} />;
|
||||||
|
case "E":
|
||||||
|
return <img alt="EU" className={className} src={EU} />;
|
||||||
|
case "J":
|
||||||
|
return <img alt="JP" className={className} src={JP} />;
|
||||||
|
case "H":
|
||||||
|
return <img alt="CN" className={className} src={CN} />;
|
||||||
|
case "I":
|
||||||
|
return <Globe className={className} />;
|
||||||
|
default:
|
||||||
|
return <CircleHelp className={className} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GameBoxSkeleton() {
|
||||||
|
return (
|
||||||
|
<Skeleton className="aspect-square h-auto w-full min-w-[150px] max-w-[200px] flex-1 rounded-sm bg-zinc-800" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyGameBox() {
|
||||||
|
return (
|
||||||
|
<div className="aspect-square h-auto w-full min-w-[150px] max-w-[200px] flex-1 opacity-0" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GameBox({
|
||||||
|
game,
|
||||||
|
isFirst,
|
||||||
|
}: {
|
||||||
|
game: GameEntry;
|
||||||
|
isFirst?: boolean;
|
||||||
|
}) {
|
||||||
|
const [isPending, startTransaction] = useTransition();
|
||||||
|
|
||||||
|
const valueData = useAtomValue(game.dataLoadable);
|
||||||
|
const isGamepad = useAtom(gamepadActiveAtom);
|
||||||
|
const store = useStore();
|
||||||
|
const setShowingDetails = useSetAtom(atomShowingGameDetails);
|
||||||
|
|
||||||
|
const [clickCount, setClickCount] = useState<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (clickCount >= 3) {
|
||||||
|
setClickCount(0);
|
||||||
|
toast.info("Do a double click to start the game");
|
||||||
|
}
|
||||||
|
}, [clickCount]);
|
||||||
|
|
||||||
|
const openGame = () =>
|
||||||
|
startTransaction(async () => {
|
||||||
|
try {
|
||||||
|
setClickCount(0);
|
||||||
|
const selectEmu = store.get(atomSelectedVersion);
|
||||||
|
if (!selectEmu) {
|
||||||
|
toast.warning("No emulator selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = await startGame(selectEmu, game);
|
||||||
|
store.set(atomShowingRunningGame, r);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast.error("Unknown error: " + stringifyError(e));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
setClickCount((prev) => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBlur = () => {
|
||||||
|
setClickCount(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (valueData.state === "loading") {
|
||||||
|
return <GameBoxSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valueData.state === "hasError") {
|
||||||
|
return (
|
||||||
|
<div className="relative aspect-square h-auto w-full min-w-[150px] max-w-[200px] flex-1 cursor-pointer overflow-hidden rounded-sm bg-zinc-800 transition-transform focus-within:scale-110 hover:scale-110">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2">
|
||||||
|
<FrownIcon className="h-8" />
|
||||||
|
<span className="text-sm">
|
||||||
|
Error: {stringifyError(valueData.error)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = valueData.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group relative aspect-square h-auto w-full min-w-[150px] max-w-[200px] flex-1 cursor-pointer overflow-hidden rounded-sm bg-zinc-800 transition-transform focus-within:scale-110 hover:scale-110"
|
||||||
|
data-gamepad-selectable
|
||||||
|
onBlur={onBlur}
|
||||||
|
onClick={onClick}
|
||||||
|
onDoubleClick={openGame}
|
||||||
|
>
|
||||||
|
{isPending && (
|
||||||
|
<div className="center absolute inset-0 bg-black/60">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.cover ? (
|
||||||
|
<img
|
||||||
|
alt={data.title}
|
||||||
|
className="col-span-full row-span-full object-cover"
|
||||||
|
src={data.cover}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="center col-span-full row-span-full">
|
||||||
|
<ImageOff className="h-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute inset-0 grid grid-cols-3 grid-rows-3 bg-black/50 opacity-0 backdrop-blur-[2px] transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
|
||||||
|
<span className="col-span-full row-start-1 row-end-2 truncate px-3 py-2 text-center font-semibold text-lg">
|
||||||
|
{/* TODO: scroll text on overflow */}
|
||||||
|
{data.title}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="col-start-3 col-end-4 row-start-3 row-end-4 m-2 size-6 place-self-end">
|
||||||
|
<Flag className="rounded-full" sfo={data.sfo} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="col-span-full row-span-full grid size-16 place-items-center place-self-center rounded-full bg-black/75"
|
||||||
|
data-initial-focus={isFirst ? "" : undefined}
|
||||||
|
data-play-game={""}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Play className="size-10" fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="col-span-full row-start-3 row-end-4 flex flex-row items-center justify-center gap-x-2 self-end py-2 transition-colors hover:bg-secondary/75 focus:bg-secondary/75"
|
||||||
|
onClick={() => setShowingDetails(data)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isGamepad && (
|
||||||
|
<GamepadIcon className="size-6" icon="options" />
|
||||||
|
)}
|
||||||
|
View More
|
||||||
|
{isGamepad && <div className="size-6" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,186 +1,14 @@
|
|||||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { exists, mkdir } from "@tauri-apps/plugin-fs";
|
import { exists, mkdir } from "@tauri-apps/plugin-fs";
|
||||||
import { useAtom, useAtomValue, useStore } from "jotai";
|
import { useAtom, useStore } from "jotai";
|
||||||
import { CircleHelp, FrownIcon, Globe, ImageOff, Play } from "lucide-react";
|
import { Suspense, useEffect, useRef, useState } from "react";
|
||||||
import {
|
|
||||||
Suspense,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
useTransition,
|
|
||||||
} from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import CN from "@/assets/flags/cn.svg";
|
|
||||||
import EU from "@/assets/flags/eu.svg";
|
|
||||||
import JP from "@/assets/flags/jp.svg";
|
|
||||||
import US from "@/assets/flags/us.svg";
|
|
||||||
import { startGame } from "@/handlers/run-emu";
|
|
||||||
import { openPath } from "@/lib/native/common";
|
import { openPath } from "@/lib/native/common";
|
||||||
import type { PSF } from "@/lib/native/psf";
|
import { atomGameLibrary } from "@/store/game-library";
|
||||||
import { atomGameLibrary, type GameEntry } from "@/store/game-library";
|
|
||||||
import { gamepadActiveAtom } from "@/store/gamepad";
|
|
||||||
import { atomGamesPath } from "@/store/paths";
|
import { atomGamesPath } from "@/store/paths";
|
||||||
import { atomShowingRunningGame } from "@/store/running-games";
|
import { EmptyGameBox, GameBox, GameBoxSkeleton } from "./game-box";
|
||||||
import { atomSelectedVersion } from "@/store/version-manager";
|
|
||||||
import { stringifyError } from "@/utils/error";
|
|
||||||
import GamepadIcon from "./gamepad-icon";
|
|
||||||
import { ScrollBar } from "./ui/scroll-area";
|
import { ScrollBar } from "./ui/scroll-area";
|
||||||
import { Skeleton } from "./ui/skeleton";
|
|
||||||
import { Spinner } from "./ui/spinner";
|
|
||||||
|
|
||||||
function Flag({ sfo, className }: { sfo: PSF | null; className?: string }) {
|
|
||||||
const region = useMemo(() => {
|
|
||||||
const { CONTENT_ID } = sfo?.entries ?? {};
|
|
||||||
return CONTENT_ID?.Text?.[0] ?? undefined;
|
|
||||||
}, [sfo]);
|
|
||||||
|
|
||||||
switch (region) {
|
|
||||||
case "U":
|
|
||||||
return <img alt="US" className={className} src={US} />;
|
|
||||||
case "E":
|
|
||||||
return <img alt="EU" className={className} src={EU} />;
|
|
||||||
case "J":
|
|
||||||
return <img alt="JP" className={className} src={JP} />;
|
|
||||||
case "H":
|
|
||||||
return <img alt="CN" className={className} src={CN} />;
|
|
||||||
case "I":
|
|
||||||
return <Globe className={className} />;
|
|
||||||
default:
|
|
||||||
return <CircleHelp className={className} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyGameBox() {
|
|
||||||
return (
|
|
||||||
<div className="aspect-square h-auto w-full min-w-[150px] max-w-[200px] flex-1 opacity-0" />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GameBoxSkeleton() {
|
|
||||||
return (
|
|
||||||
<Skeleton className="aspect-square h-auto w-full min-w-[150px] max-w-[200px] flex-1 rounded-sm bg-zinc-800" />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GameBox({ game, isFirst }: { game: GameEntry; isFirst?: boolean }) {
|
|
||||||
const [isPending, startTransaction] = useTransition();
|
|
||||||
|
|
||||||
const valueData = useAtomValue(game.dataLoadable);
|
|
||||||
const isGamepad = useAtom(gamepadActiveAtom);
|
|
||||||
const store = useStore();
|
|
||||||
|
|
||||||
const [clickCount, setClickCount] = useState<number>(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (clickCount >= 3) {
|
|
||||||
setClickCount(0);
|
|
||||||
toast.info("Do a double click to start the game");
|
|
||||||
}
|
|
||||||
}, [clickCount]);
|
|
||||||
|
|
||||||
const openGame = () =>
|
|
||||||
startTransaction(async () => {
|
|
||||||
try {
|
|
||||||
setClickCount(0);
|
|
||||||
const selectEmu = store.get(atomSelectedVersion);
|
|
||||||
if (!selectEmu) {
|
|
||||||
toast.warning("No emulator selected");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const r = await startGame(selectEmu, game);
|
|
||||||
store.set(atomShowingRunningGame, r);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
toast.error("Unknown error: " + stringifyError(e));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const onClick = () => {
|
|
||||||
setClickCount((prev) => prev + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onBlur = () => {
|
|
||||||
setClickCount(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (valueData.state === "loading") {
|
|
||||||
return <GameBoxSkeleton />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (valueData.state === "hasError") {
|
|
||||||
return (
|
|
||||||
<div className="relative aspect-square h-auto w-full min-w-[150px] max-w-[200px] flex-1 cursor-pointer overflow-hidden rounded-sm bg-zinc-800 transition-transform focus-within:scale-110 hover:scale-110">
|
|
||||||
<div className="flex flex-col items-center justify-center gap-2">
|
|
||||||
<FrownIcon className="h-8" />
|
|
||||||
<span className="text-sm">
|
|
||||||
Error: {stringifyError(valueData.error)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = valueData.data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="group relative aspect-square h-auto w-full min-w-[150px] max-w-[200px] flex-1 cursor-pointer overflow-hidden rounded-sm bg-zinc-800 transition-transform focus-within:scale-110 hover:scale-110"
|
|
||||||
data-gamepad-selectable
|
|
||||||
onBlur={onBlur}
|
|
||||||
onClick={onClick}
|
|
||||||
onDoubleClick={openGame}
|
|
||||||
>
|
|
||||||
{isPending && (
|
|
||||||
<div className="center absolute inset-0 bg-black/60">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{data.cover ? (
|
|
||||||
<img
|
|
||||||
alt={data.title}
|
|
||||||
className="col-span-full row-span-full object-cover"
|
|
||||||
src={data.cover}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="center col-span-full row-span-full">
|
|
||||||
<ImageOff className="h-8" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="absolute inset-0 grid grid-cols-3 grid-rows-3 bg-black/50 opacity-0 backdrop-blur-[2px] transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
|
|
||||||
<span className="col-span-full row-start-1 row-end-2 truncate px-3 py-2 text-center font-semibold text-lg">
|
|
||||||
{/* TODO: scroll text on overflow */}
|
|
||||||
{data.title}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="col-start-3 col-end-4 row-start-3 row-end-4 m-2 size-6 place-self-end">
|
|
||||||
<Flag className="rounded-full" sfo={data.sfo} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="col-span-full row-span-full grid size-16 place-items-center place-self-center rounded-full bg-black/75"
|
|
||||||
data-initial-focus={isFirst ? "" : undefined}
|
|
||||||
data-play-game={""}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Play className="size-10" fill="currentColor" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="col-span-full row-start-3 row-end-4 flex flex-row items-center justify-center gap-x-2 self-end py-2 transition-colors hover:bg-secondary/75 focus:bg-secondary/75"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{isGamepad && (
|
|
||||||
<GamepadIcon className="size-6" icon="options" />
|
|
||||||
)}
|
|
||||||
View More
|
|
||||||
{isGamepad && <div className="size-6" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NoGameFound() {
|
function NoGameFound() {
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
|
|||||||
198
src/components/modals/game-details-modal.tsx
Normal file
198
src/components/modals/game-details-modal.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { ImageOffIcon } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import type { PSFEntry } from "@/lib/native/psf";
|
||||||
|
import { atomShowingGameDetails } from "@/store/common";
|
||||||
|
import type { GameEntryData } from "@/store/game-library";
|
||||||
|
|
||||||
|
function Entry({ value }: { value: PSFEntry }) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return <span className="text-muted-foreground italic">N/A</span>;
|
||||||
|
}
|
||||||
|
if (value.Text !== undefined) {
|
||||||
|
return <>{value.Text}</>;
|
||||||
|
}
|
||||||
|
if (value.Integer !== undefined) {
|
||||||
|
return <>{value.Integer.toString()}</>;
|
||||||
|
}
|
||||||
|
if (value.Binary !== undefined) {
|
||||||
|
return (
|
||||||
|
<span className="text-muted-foreground italic">
|
||||||
|
[Binary Data ({value.Binary.length} bytes)]
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="text-red-500 italic">[Unknown Format]</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
gameData: GameEntryData;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function GameDetailsDialog({ gameData, onClose }: Props) {
|
||||||
|
return (
|
||||||
|
<Dialog onOpenChange={() => onClose()} open>
|
||||||
|
<DialogContent
|
||||||
|
aria-describedby="game-details-description"
|
||||||
|
className="flex max-h-[90vh] max-w-xl flex-col md:max-w-4xl"
|
||||||
|
>
|
||||||
|
<DialogHeader className="flex-shrink-0">
|
||||||
|
<DialogTitle className="text-2xl">
|
||||||
|
{gameData.title}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription id="game-details-description">
|
||||||
|
{gameData.cusa}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-grow overflow-y-auto">
|
||||||
|
<div className="grid grid-cols-1 gap-6 py-4 sm:grid-cols-3">
|
||||||
|
<div className="flex flex-col items-center pt-2 md:col-span-1">
|
||||||
|
{gameData.cover ? (
|
||||||
|
<img
|
||||||
|
alt={`${gameData.title} Cover`}
|
||||||
|
className="aspect-[1/1] w-full max-w-[200px] rounded-lg object-contain shadow-md"
|
||||||
|
src={gameData.cover}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex aspect-[1/1] w-full max-w-[200px] items-center justify-center rounded-lg bg-muted text-muted-foreground shadow-md">
|
||||||
|
<ImageOffIcon className="h-12 w-12" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 md:col-span-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-medium text-muted-foreground text-sm">
|
||||||
|
Version
|
||||||
|
</h3>
|
||||||
|
<p className="font-semibold text-lg">
|
||||||
|
{gameData.version}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-medium text-muted-foreground text-sm">
|
||||||
|
Firmware
|
||||||
|
</h3>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{gameData.fw_version}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gameData.sfo?.entries && (
|
||||||
|
<>
|
||||||
|
{gameData.sfo.entries.CATEGORY && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-medium text-muted-foreground text-sm">
|
||||||
|
Category
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
<Entry
|
||||||
|
value={
|
||||||
|
gameData.sfo.entries
|
||||||
|
.CATEGORY
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{gameData.sfo.entries.CONTENT_ID && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-medium text-muted-foreground text-sm">
|
||||||
|
Content ID
|
||||||
|
</h3>
|
||||||
|
<p className="break-all">
|
||||||
|
<Entry
|
||||||
|
value={
|
||||||
|
gameData.sfo.entries
|
||||||
|
.CONTENT_ID
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gameData.sfo?.entries && (
|
||||||
|
<div className="mt-4 border-t pt-4">
|
||||||
|
<h3 className="mb-2 font-semibold text-lg">
|
||||||
|
SFO Details
|
||||||
|
</h3>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[280px]">
|
||||||
|
Key
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>Value</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{Object.entries(gameData.sfo.entries)
|
||||||
|
.filter(
|
||||||
|
([key]) =>
|
||||||
|
![
|
||||||
|
"TITLE",
|
||||||
|
"TITLE_ID",
|
||||||
|
"APP_VER",
|
||||||
|
"SYSTEM_VER",
|
||||||
|
"CATEGORY",
|
||||||
|
"CONTENT_ID",
|
||||||
|
].includes(key),
|
||||||
|
)
|
||||||
|
.sort(([keyA], [keyB]) =>
|
||||||
|
keyA.localeCompare(keyB),
|
||||||
|
)
|
||||||
|
.map(([key, value]) => (
|
||||||
|
<TableRow key={key}>
|
||||||
|
<TableCell className="break-all py-1.5 font-medium">
|
||||||
|
{key}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="break-all py-1.5">
|
||||||
|
<Entry value={value} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GameDetailsModal() {
|
||||||
|
const [showingGame, setShowingGame] = useAtom(atomShowingGameDetails);
|
||||||
|
|
||||||
|
if (!showingGame) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GameDetailsDialog
|
||||||
|
gameData={showingGame}
|
||||||
|
onClose={() => setShowingGame(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,7 +30,11 @@ import {
|
|||||||
} from "../ui/dialog";
|
} from "../ui/dialog";
|
||||||
import { Skeleton } from "../ui/skeleton";
|
import { Skeleton } from "../ui/skeleton";
|
||||||
|
|
||||||
function RunningGameDialog({ runningGame }: { runningGame: RunningGame }) {
|
export function RunningGameDialog({
|
||||||
|
runningGame,
|
||||||
|
}: {
|
||||||
|
runningGame: RunningGame;
|
||||||
|
}) {
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const setShowingGame = useSetAtom(atomShowingRunningGame);
|
const setShowingGame = useSetAtom(atomShowingRunningGame);
|
||||||
|
|
||||||
|
|||||||
@@ -198,7 +198,10 @@ export function VersionManagerModal() {
|
|||||||
{isNew ? (
|
{isNew ? (
|
||||||
<AddNewVersion />
|
<AddNewVersion />
|
||||||
) : (
|
) : (
|
||||||
<DialogContent className="min-w-[525px]">
|
<DialogContent
|
||||||
|
aria-describedby={undefined}
|
||||||
|
className="min-w-[525px]"
|
||||||
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Update } from "@tauri-apps/plugin-updater";
|
import type { Update } from "@tauri-apps/plugin-updater";
|
||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
|
import type { GameEntryData } from "./game-library";
|
||||||
|
|
||||||
export const oficialRepo = "shadps4-emu/shadPS4";
|
export const oficialRepo = "shadps4-emu/shadPS4";
|
||||||
|
|
||||||
@@ -17,3 +18,5 @@ export const atomDownloadingOverlay = atom<
|
|||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
export const atomUpdateAvailable = atom<Update | null>(null);
|
export const atomUpdateAvailable = atom<Update | null>(null);
|
||||||
|
|
||||||
|
export const atomShowingGameDetails = atom<GameEntryData | null>(null);
|
||||||
|
|||||||
Reference in New Issue
Block a user