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,
|
||||
"dist/**": true
|
||||
},
|
||||
"typescript.preferences.autoImportSpecifierExcludeRegexes": ["@radix-ui"],
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Suspense } from "react";
|
||||
import { LoadingScreen } from "./components/loading-overlay";
|
||||
import { MainPage } from "./components/main-page";
|
||||
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 { VersionManagerModal } from "./components/modals/version-manager-modal";
|
||||
import { UpdateIcon } from "./components/update-icon";
|
||||
@@ -19,6 +20,7 @@ export function App() {
|
||||
<VersionManagerModal />
|
||||
<UpdateIcon />
|
||||
<RunningGameModal />
|
||||
<GameDetailsModal />
|
||||
<MainPage />
|
||||
</Suspense>
|
||||
</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 { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { exists, mkdir } from "@tauri-apps/plugin-fs";
|
||||
import { useAtom, useAtomValue, useStore } from "jotai";
|
||||
import { CircleHelp, FrownIcon, Globe, ImageOff, Play } from "lucide-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 { useAtom, useStore } from "jotai";
|
||||
import { Suspense, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { openPath } from "@/lib/native/common";
|
||||
import type { PSF } from "@/lib/native/psf";
|
||||
import { atomGameLibrary, type GameEntry } from "@/store/game-library";
|
||||
import { gamepadActiveAtom } from "@/store/gamepad";
|
||||
import { atomGameLibrary } from "@/store/game-library";
|
||||
import { atomGamesPath } from "@/store/paths";
|
||||
import { atomShowingRunningGame } from "@/store/running-games";
|
||||
import { atomSelectedVersion } from "@/store/version-manager";
|
||||
import { stringifyError } from "@/utils/error";
|
||||
import GamepadIcon from "./gamepad-icon";
|
||||
import { EmptyGameBox, GameBox, GameBoxSkeleton } from "./game-box";
|
||||
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() {
|
||||
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";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
function RunningGameDialog({ runningGame }: { runningGame: RunningGame }) {
|
||||
export function RunningGameDialog({
|
||||
runningGame,
|
||||
}: {
|
||||
runningGame: RunningGame;
|
||||
}) {
|
||||
const store = useStore();
|
||||
const setShowingGame = useSetAtom(atomShowingRunningGame);
|
||||
|
||||
|
||||
@@ -198,7 +198,10 @@ export function VersionManagerModal() {
|
||||
{isNew ? (
|
||||
<AddNewVersion />
|
||||
) : (
|
||||
<DialogContent className="min-w-[525px]">
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
className="min-w-[525px]"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Update } from "@tauri-apps/plugin-updater";
|
||||
import { atom } from "jotai";
|
||||
import type { GameEntryData } from "./game-library";
|
||||
|
||||
export const oficialRepo = "shadps4-emu/shadPS4";
|
||||
|
||||
@@ -17,3 +18,5 @@ export const atomDownloadingOverlay = atom<
|
||||
>(null);
|
||||
|
||||
export const atomUpdateAvailable = atom<Update | null>(null);
|
||||
|
||||
export const atomShowingGameDetails = atom<GameEntryData | null>(null);
|
||||
|
||||
Reference in New Issue
Block a user