Skip to content

Commit

Permalink
feat(ui): add game creation page
Browse files Browse the repository at this point in the history
- Add game creation form & handlers.
- Allow specifying empty account ID to create new vanilla account when launching.
- Extract game listing function as hook.
- Remove game list refresh button.
- Separate monitor list and each instances.
- Rename game process service.
skjsjhb committed Jan 31, 2025
1 parent 20c6f87 commit 14a5d93
Showing 18 changed files with 668 additions and 357 deletions.
20 changes: 19 additions & 1 deletion public/i18n/zh-CN/pages.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
create-game:
title: 创建新游戏
name-input-title: 游戏名称
version-select-title: 游戏版本
storage-title: 存储
container-select-title: 选择要共享存储空间的游戏
container-select-placeholder: 未选取
version-select-placeholder: 未选取
storage-policy:
new: 独立分配
share: 与已有游戏共享
account-title: 帐户
default-name: 新游戏({{date}})
loading-versions: 正在获取版本列表……

account-tip: 现在需要为每个游戏重新登录帐户。在未来的版本中,将能够选择使用已有的帐户。
create-btn: 创建
toast-created: 已创建游戏。

games:
loading: 正在读取游戏列表……
load-list-failed: 读取游戏列表失败。
title: 游戏库
new: 创建新游戏
reload: 重新加载
load-failed: 无法读取 {{name}} (标识符为 {{id}}),此游戏可能已损坏。
remove-failed: 删除游戏
auth-failed:
15 changes: 12 additions & 3 deletions src/main/api/auth.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import { VanillaAccount } from "@/main/auth/vanilla";
import { ipcMain } from "@/main/ipc/typed";
import { reg } from "@/main/registry/registry";

ipcMain.handle("gameAuth", (_, gameId) => {
ipcMain.handle("gameAuth", async (_, gameId) => {
const g = reg.games.get(gameId);
const a = reg.accounts.get(g.launchHint.accountId);
return a.refresh();

const a = g.launchHint.accountId ? reg.accounts.get(g.launchHint.accountId) : new VanillaAccount();

const success = await a.refresh();

if (success) {
g.launchHint.accountId = a.uuid;
}

return success;
});
49 changes: 48 additions & 1 deletion src/main/api/game.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { containerInspector } from "@/main/container/inspect";
import { containers } from "@/main/container/manage";
import type { ContainerSpec } from "@/main/container/spec";
import { paths } from "@/main/fs/paths";
import { gameInspector } from "@/main/game/inspect";
import type { GameProfile } from "@/main/game/spec";
import { vanillaInstaller } from "@/main/install/vanilla";
import { ipcMain } from "@/main/ipc/typed";
import { reg } from "@/main/registry/registry";
import { shell } from "electron";
import { nanoid } from "nanoid";

ipcMain.handle("listGames", () => reg.games.getAll());
ipcMain.handle("tellGame", (_, gameId) => gameInspector.tellGame(gameId));
@@ -20,4 +25,46 @@ ipcMain.on("revealGameContent", async (_, gameId, scope) => {
void shell.openPath(container.content(scope));
});

ipcMain.handle("addGame", (_, game) => reg.games.add(game.id, game));
export interface CreateGameInit {
name: string;
profileId: string;
containerId?: string;
}

ipcMain.handle("addGame", async (_, init) => {
const { name, profileId, containerId } = init;

let cid = containerId;

if (!cid) {
cid = nanoid();
const spec: ContainerSpec = {
id: cid,
root: paths.game.to(cid),
flags: {} // TODO add support for linking
};
reg.containers.add(cid, spec);
}

const g: GameProfile = {
id: nanoid(),
name,
installed: false,
launchHint: {
accountId: "", // TODO update ID when other account types are added
containerId: cid,
profileId,
pref: {} // TODO allow user to choose pref
},
time: Date.now()
};

reg.games.add(g.id, g);

const c = containers.get(cid);

// TODO it may be possible to delay the installation to launch stage
// This can be helpful when creating games with mod loaders
// Implement it and then remove this
await vanillaInstaller.installProfile(profileId, c);
});
3 changes: 2 additions & 1 deletion src/main/ipc/channels.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CreateGameInit } from "@/main/api/game";
import type { LaunchGameResult } from "@/main/api/launcher";
import type { UserConfig } from "@/main/conf/conf";
import type { GameProfile, GameProfileDetail } from "@/main/game/spec";
@@ -23,6 +24,6 @@ export type IpcCommands = {
tellGame: (gameId: string) => GameProfileDetail;
launch: (id: string) => LaunchGameResult;
gameAuth: (gameId: string) => boolean;
addGame: (game: GameProfile) => void;
addGame: (game: CreateGameInit) => void;
getVersionManifest: () => VersionManifest;
}
5 changes: 4 additions & 1 deletion src/main/launch/types.ts
Original file line number Diff line number Diff line change
@@ -7,9 +7,12 @@ import { VersionProfile } from "@/main/profile/version-profile";
* User is capable for customizing the detailed launch options without creating new containers or create additional files.
*/
export interface LaunchHint {
id: string;
containerId: string;
profileId: string;

/**
* Account identifier. An empty string indicates a new account should be created when launching.
*/
accountId: string;
pref: Partial<LaunchPref>;
}
7 changes: 4 additions & 3 deletions src/preload/preload.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CreateGameInit } from "@/main/api/game";
import type { LaunchGameResult } from "@/main/api/launcher";
import type { UserConfig } from "@/main/conf/conf";
import type { GameProfile, GameProfileDetail } from "@/main/game/spec";
@@ -72,7 +73,7 @@ const native = {
/**
* Adds the specified game to registry.
*/
add(game: GameProfile): Promise<void> {
add(game: CreateGameInit): Promise<void> {
return ipcRenderer.invoke("addGame", game);
}
},
@@ -82,9 +83,9 @@ const native = {
*/
auth: {
/**
* Refreshes the account of the given game.
* Authenticate the account of the specified game.
*/
refresh(id: string): Promise<boolean> {
forGame(id: string): Promise<boolean> {
return ipcRenderer.invoke("gameAuth", id);
}
},
5 changes: 4 additions & 1 deletion src/renderer/App.tsx
Original file line number Diff line number Diff line change
@@ -3,8 +3,10 @@ import { themeManager, useTheme } from "@/renderer/theme";
import { Navigator } from "@components/Navigator";
import { HeroUIProvider } from "@heroui/react";
import { AboutView } from "@pages/about/AboutView";
import { CreateGameView } from "@pages/create-game/CreateGameView";
import { GamesView } from "@pages/games/GamesView";
import { MonitorListView, MonitorView } from "@pages/monitor/MonitorView";
import { MonitorListView } from "@pages/monitor-list/MonitorListView";
import { MonitorView } from "@pages/monitor/MonitorView";
import { pages } from "@pages/pages";
import { SettingsView } from "@pages/settings/SettingsView";
import React from "react";
@@ -63,6 +65,7 @@ function Routes() {
return <Switch>
<Route path="/about" component={AboutView}/>
<Route path="/settings" component={SettingsView}/>
<Route path="/create-game" component={CreateGameView}/>
<Route path="/games" component={GamesView}/>
<Route path="/monitor/:procId" component={MonitorView}/>
<Route path="/monitor" component={MonitorListView}/>
38 changes: 38 additions & 0 deletions src/renderer/pages/create-game/ContainerSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useGameList } from "@/renderer/services/game";
import { Select, SelectItem, type SharedSelection } from "@heroui/react";
import { useTranslation } from "react-i18next";

interface ContainerSelectorProps {
enabled: boolean;
containerId?: string;
onChange: (containerId?: string) => void;
}

export function ContainerSelector({ enabled, containerId, onChange }: ContainerSelectorProps) {
const { t } = useTranslation("pages", { keyPrefix: "create-game" });

const games = useGameList();

const sid = games.find(g => g.launchHint.containerId === containerId)?.id;

function handleSelectionChange(s: SharedSelection) {
if (s instanceof Set) {
const gid = [...s][0];
if (!gid) {
onChange(undefined);
} else {
onChange(games.find(g => g.id === gid)?.launchHint.containerId);
}
}
}

return <Select
isDisabled={!enabled}
label={t("container-select-title")}
placeholder={t("version-select-placeholder")}
selectedKeys={sid ? [sid] : []}
onSelectionChange={handleSelectionChange}
>
{games.map(g => <SelectItem key={g.id}>{g.name}</SelectItem>)}
</Select>;
}
95 changes: 95 additions & 0 deletions src/renderer/pages/create-game/CreateGameView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Alert } from "@components/Alert";
import { Radio, RadioGroup } from "@heroui/radio";
import { Button, Input } from "@heroui/react";
import { ContainerSelector } from "@pages/create-game/ContainerSelector";
import { VersionSelector } from "@pages/create-game/VersionSelector";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "react-toastify";
import { useLocation } from "wouter";


/**
* Create new game without the help of the wizard.
*/
export function CreateGameView() {
const { t } = useTranslation("pages", { keyPrefix: "create-game" });

const [gameName, setGameName] = useState(t("default-name", { date: new Date().toLocaleString() }));
const [gameVersion, setGameVersion] = useState<string>();
const [containerId, setContainerId] = useState<string>();
const [shareContainer, setShareContainer] = useState(false);

const [creating, setCreating] = useState(false);
const [, nav] = useLocation();


const valid = gameVersion && (!shareContainer || (shareContainer && containerId));

async function handleCreate() {
if (valid) {
setCreating(true);
// TODO add error handler
await native.game.add({
name: gameName,
containerId,
profileId: gameVersion
});
setCreating(false);
toast(t("toast-created"), { type: "success" });
nav("/games");
}
}

return <div className="w-11/12 h-full mx-auto flex flex-col items-center gap-4">
<div className="font-bold text-2xl">{t("title")}</div>
<div className="w-full grow flex flex-col justify-between mt-4">
<div className="flex flex-col gap-4">
<div className="font-bold text-xl">{t("name-input-title")}</div>
<Input aria-label="Game Name" value={gameName} onValueChange={setGameName}/>
</div>

<div className="flex flex-col gap-4">
<div className="font-bold text-xl">{t("version-select-title")}</div>
<VersionSelector version={gameVersion} onChange={setGameVersion}/>
</div>


<div className="flex flex-col gap-4">
<div className="font-bold text-xl">{t("storage-title")}</div>

<RadioGroup
orientation="horizontal"
value={shareContainer ? "share" : "new"}
onValueChange={v => setShareContainer(v === "share")}
>
<Radio value="new">{t("storage-policy.new")}</Radio>
<Radio value="share">{t("storage-policy.share")}</Radio>
</RadioGroup>

<ContainerSelector enabled={shareContainer} containerId={containerId} onChange={setContainerId}/>
</div>


<div className="flex flex-col gap-4">
<div className="font-bold text-xl">{t("account-title")}</div>

<Alert
classNames={{ title: "font-bold" }}
title={t("account-tip")}
/>
</div>

<Button
fullWidth
color="primary"
size="lg"
isDisabled={!valid}
onPress={handleCreate}
isLoading={creating}
>
{t("create-btn")}
</Button>
</div>
</div>;
}
96 changes: 96 additions & 0 deletions src/renderer/pages/create-game/VersionSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { VersionEntry } from "@/main/install/vanilla";
import { useVersionManifest } from "@/renderer/services/sources";
import grassBlock from "@assets/img/grass-block.webp";
import snowyGrassBlock from "@assets/img/snowy-grass-block.webp";
import { Checkbox, Select, SelectItem, type SharedSelection, Spinner } from "@heroui/react";
import { DotIcon } from "lucide-react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";

interface VersionSelectorProps {
version?: string;
onChange: (version?: string) => void;
}

export function VersionSelector({ version, onChange }: VersionSelectorProps) {
const vm = useVersionManifest();
const { t } = useTranslation("pages", { keyPrefix: "create-game.version-select" });
const [showSnapshots, setShowSnapshots] = useState(false);

if (!vm) return <VersionLoading/>;

let versions = vm.versions;

if (!showSnapshots) {
versions = vm.versions.filter(v => v.type !== "snapshot");
}

function handleSelectionChange(sel: SharedSelection) {
if (sel instanceof Set) {
const s = [...sel];
onChange(s[0]?.toString()); // Might be null when no selection
}
}

return <div className="items-center flex gap-4">
<div className="grow">
<Select
isVirtualized
aria-label="Select Version"
selectedKeys={version ? [version] : []}
placeholder={t("placeholder")}
onSelectionChange={handleSelectionChange}
scrollShadowProps={{
style: {
// @ts-expect-error Non-standard properties
"--scroll-shadow-size": "0px"
}
}}
itemHeight={64}
>
{
versions.map(v => {
return <SelectItem key={v.id} textValue={v.id}>
<VersionContent version={v}/>
</SelectItem>;
})
}
</Select>
</div>

<div>
<Checkbox checked={showSnapshots} onValueChange={setShowSnapshots}>包含快照</Checkbox>
</div>

</div>;
}

function VersionContent({ version: { id, type, sha1, releaseTime } }: { version: VersionEntry }) {
const src = type === "release" ? grassBlock : snowyGrassBlock;

return <div className="flex h-[64px] items-center gap-4 py-2">
<div className="h-full rounded-full bg-content2 p-2">
<img src={src} alt="version" className="w-full h-full object-contain"/>
</div>

<div className="flex flex-col">
<div className="font-bold text-lg">{id}</div>
<div className="flex items-center text-foreground-400 text-sm">
{sha1}
<DotIcon/>
{new Date(releaseTime).toLocaleString()}
</div>
</div>
</div>;
}

function VersionLoading() {
const { t } = useTranslation("pages", { keyPrefix: "create-game" });

return <div className="flex gap-4 justify-center items-center">
<Spinner size="sm"/>
<span className="text-foreground-400">
{t("loading-versions")}
</span>
</div>;
}
6 changes: 3 additions & 3 deletions src/renderer/pages/games/GameCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { GameProfileDetail } from "@/main/game/spec";
import { remoteGame } from "@/renderer/lib/remote-game";
import { procService } from "@/renderer/services/proc";
import { GameTypeImage } from "@components/GameTypeImage";
import {
Alert,
@@ -170,15 +170,15 @@ function GameActions({ installed, detail }: GameActionsProps) {

async function launch() {
setLaunching(true);
const authed = await native.auth.refresh(detail.id);
const authed = await native.auth.forGame(detail.id);

if (!authed) {
toast(AuthFailedToast, { type: "error" });
return;
}

// TODO add error handler
const procId = await remoteGame.create(detail);
const procId = await procService.create(detail);
setLaunching(false);
nav(`/monitor/${procId}`);
}
51 changes: 14 additions & 37 deletions src/renderer/pages/games/GamesView.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,26 @@
import type { GameProfile } from "@/main/game/spec";
import { useGameList } from "@/renderer/services/game";
import { Alert } from "@components/Alert";
import { Button, ButtonGroup, Spinner, Tooltip } from "@heroui/react";
import { GameCardDisplay } from "@pages/games/GameCard";
import {
ArrowDownAZIcon,
ArrowUpAZIcon,
ClockArrowDownIcon,
ClockArrowUpIcon,
PlusIcon,
RefreshCcwIcon
} from "lucide-react";
import { useEffect, useState } from "react";
import { ArrowDownAZIcon, ArrowUpAZIcon, ClockArrowDownIcon, ClockArrowUpIcon, PlusIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocalStorage } from "react-use";
import { useLocation } from "wouter";

/**
* The index page of game launching, listing user-defined games for playing.
*/
export function GamesView() {
const [games, setGames] = useState<GameProfile[]>();
const games = useGameList();
const [error, setError] = useState();
const [sortMethod, setSortMethod] = useLocalStorage<SortMethod>("games.sort-method", "latest");
const [, nav] = useLocation();
const { t } = useTranslation("pages", { keyPrefix: "games" });

async function loadGames() {
native.game.list().then(setGames).catch(setError);
}

useEffect(() => {
void loadGames();
}, []);

if (error !== undefined) {
return <FailedAlert retry={loadGames}/>;
return <FailedAlert/>;
}

if (!games) {
@@ -43,18 +31,16 @@ export function GamesView() {

return <div className="flex flex-col w-full h-full">
<div className="flex gap-2">
<Button fullWidth color="primary" size="lg" startContent={<PlusIcon/>}>
<Button
onPress={() => nav("/create-game")}
fullWidth
color="primary"
startContent={<PlusIcon/>}
>
{t("new")}
</Button>

<SortMethodControl sortMethod={sortMethod!} onChange={setSortMethod}/>

<Tooltip content={t("reload")}>
<Button isIconOnly size="lg">
<RefreshCcwIcon onClick={loadGames}/>
</Button>
</Tooltip>

</div>

<div className="mt-4 w-full h-full overflow-y-auto">
@@ -105,7 +91,6 @@ function SortMethodControl({ sortMethod, onChange }: SortMethodControlProps) {
<Tooltip content={t(m)} key={m}>
<Button
isIconOnly
size="lg"
color={sortMethod === m ? "secondary" : "default"}
onPress={() => onChange(m)}
>
@@ -125,20 +110,12 @@ function LoadingSpinner() {
</div>;
}

function FailedAlert({ retry }: { retry: () => void }) {
function FailedAlert() {
const { t } = useTranslation("pages", { keyPrefix: "games" });
return <Alert
color="danger"
className="w-11/12 mx-auto"
classNames={{ title: "font-bold" }}
title={t("load-list-failed")}
endContent={
<Button onPress={retry}>
<div className="flex items-center gap-2">
<RefreshCcwIcon/>
{t("reload")}
</div>
</Button>
}
/>;
}
70 changes: 70 additions & 0 deletions src/renderer/pages/monitor-list/MonitorListView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { type RemoteGameProcess, type RemoteGameStatus, useGameProcList } from "@/renderer/services/proc";
import { GameTypeImage } from "@components/GameTypeImage";
import { Button, Card, CardBody, Chip } from "@heroui/react";
import { ArrowRightIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useLocation } from "wouter";

export function MonitorListView() {
const procs = useGameProcList();

return <div className="w-full h-full overflow-y-auto">
<div className="grid grid-cols-2 gap-4 w-full">
{
procs.map(p => <MonitorItem proc={p} key={p.id}/>)
}
</div>
</div>;
}

const statusColors = {
running: "success",
crashed: "danger",
exited: "default"
} as const;

function StatusChip({ status }: { status: RemoteGameStatus }) {
const { t } = useTranslation("pages", { keyPrefix: "monitor.status" });

return <Chip
variant="dot"
color={statusColors[status]}
>
{t(status)}
</Chip>;
}

function MonitorItem({ proc }: { proc: RemoteGameProcess }) {
const { detail: { modLoader, stable, name, versionId }, status } = proc;
const [, nav] = useLocation();

function revealProc() {
nav(`/monitor/${proc.id}`);
}

return <Card>
<CardBody>
<div className="flex gap-4 items-center h-16 px-3">
<div className="h-full p-3 bg-content2 rounded-full">
<GameTypeImage loader={modLoader} stable={stable}/>
</div>

<div className="flex flex-col gap-1">
<div className="font-bold text-xl">{name}</div>
<div className="text-foreground-400">{versionId}</div>
</div>

<div className="ml-auto flex gap-2 items-center">
<StatusChip status={status}/>
</div>


<div className="ml-4">
<Button isIconOnly color="primary" onPress={revealProc}>
<ArrowRightIcon/>
</Button>
</div>
</div>
</CardBody>
</Card>;
}
260 changes: 0 additions & 260 deletions src/renderer/pages/monitor/Monitor.tsx

This file was deleted.

279 changes: 234 additions & 45 deletions src/renderer/pages/monitor/MonitorView.tsx
Original file line number Diff line number Diff line change
@@ -1,76 +1,265 @@
import { type RemoteGameProcess, type RemoteGameStatus, useGameProcList } from "@/renderer/lib/remote-game";
import type { GameProcessLog } from "@/main/launch/log-parser";
import { type RemoteGameProcess, type RemoteGameStatus, useGameProc } from "@/renderer/services/proc";
import { GameTypeImage } from "@components/GameTypeImage";
import { Button, Card, CardBody, Chip } from "@heroui/react";
import { Monitor } from "@pages/monitor/Monitor";
import { ArrowRightIcon } from "lucide-react";
import { Button, Card, CardBody, Popover, PopoverContent, PopoverTrigger, Tab, Tabs } from "@heroui/react";
import { clsx } from "clsx";
import {
ArrowLeftIcon,
ArrowRightIcon,
CircleHelpIcon,
CpuIcon,
FileClockIcon,
FolderArchive,
OctagonXIcon
} from "lucide-react";
import React, { useContext, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { VList, type VListHandle } from "virtua";
import { useLocation, useParams } from "wouter";

export function MonitorView() {
const { procId } = useParams<{ procId: string }>();
return <Monitor procId={procId} key={procId}/>;
}

export function MonitorListView() {
const procs = useGameProcList();
const GameProcessContext = React.createContext<RemoteGameProcess | null>(null);

return <div className="w-full h-full overflow-y-auto">
<div className="grid grid-cols-2 gap-4 w-full">
{
procs.map(p => <MonitorItem proc={p} key={p.id}/>)
function Monitor({ procId }: { procId: string }) {
const proc = useGameProc(procId, 200);

return <GameProcessContext.Provider value={proc}>
<div className="w-full h-full flex gap-4 mx-auto">
<div className="basis-1/4">
<ControlPanel/>
</div>
<div className="grow flex flex-col">
<ContentArea/>
</div>
</div>
</GameProcessContext.Provider>;
}

function ContentArea() {
const { t } = useTranslation("pages", { keyPrefix: "monitor" });

return <Tabs radius="full">
<Tab
className="h-full"
key="logs"
title={
<div className="flex gap-1 items-center">
<FileClockIcon/>
{t("log-view")}
</div>
}
>
<LogsDisplay/>
</Tab>
<Tab
className="h-full"
key="stats"
title={
<div className="flex gap-1 items-center">
<CpuIcon/>
{t("perf-view")}
</div>
}
>
</Tab>
</Tabs>;
}

function LogsDisplay() {
const autoScroll = useRef(true);
const prevOffset = useRef(0);
const ref = useRef<VListHandle | null>(null);
const { id: procId, logs } = useContext(GameProcessContext)!;

function scrollToBottom() {
ref.current?.scrollToIndex(Number.MAX_SAFE_INTEGER, { smooth: true });
}

useEffect(() => {
if (autoScroll.current) {
scrollToBottom();
}
}, [logs]);

function updateAutoScroll(offset: number) {
if (ref.current) {
if (offset < prevOffset.current) {
// Cancel auto scroll when scrolling up
autoScroll.current = false;
return;
} else if (ref.current.scrollOffset - ref.current.scrollSize + ref.current.viewportSize >= -10) {
// Enable auto scroll when not scrolling up & reached the bottom
// But does not cancel it if not at the bottom (due to smooth scrolling)
autoScroll.current = true;
}

prevOffset.current = offset;
}
}

return <VList
ref={ref}
className="h-full p-2"
onScroll={updateAutoScroll}
onScrollEnd={() => {
// Prevent misjudged scroll up when layout shifts
prevOffset.current = 0;
}}
>
{
logs.map(log =>
// The key includes process ID to correctly handle props changes when switching between monitors
<LogLineMemo log={log} key={procId + ":" + log.index}/>
)
}
</VList>;
}

const LogLineMemo = React.memo(LogLine, (prevProps, nextProps) => {
// Log objects are not changed once parsed
// By comparing props via index we save lots of re-renders
return nextProps.log.index === prevProps.log.index;
});

function LogLine({ log }: { log: GameProcessLog }) {
const { time, level, message, throwable } = log;

return <div className="flex flex-col">
<div className="flex gap-3">
<div className="text-foreground-400">
{new Date(time).toLocaleTimeString()}
</div>
<div
className={
clsx("break-all", {
"font-bold text-danger": level === "FATAL" || level === "ERROR",
"font-bold text-warning": level === "WARN",
"text-foreground-400": level === "DEBUG"
})
}
>
{message}
<pre className="whitespace-pre-wrap">{throwable}</pre>
</div>
</div>
</div>;
}

const statusColors = {
running: "success",
crashed: "danger",
exited: "default"
} as const;
function ControlPanel() {
const proc = useContext(GameProcessContext)!;
const { detail: { name, modLoader, stable }, status } = proc;

function StatusChip({ status }: { status: RemoteGameStatus }) {
const { t } = useTranslation("pages", { keyPrefix: "monitor.status" });
return <div className="w-full h-full flex flex-col gap-4">
<div className="grow">
<GameInfoCardMemo
name={name}
modLoader={modLoader}
stable={stable}
status={status}
/>
</div>
<MonitorActionsMemo procId={proc.id} gameId={proc.detail.id} status={proc.status}/>
</div>;
}

return <Chip
variant="dot"
color={statusColors[status]}
>
{t(status)}
</Chip>;
interface GameInfoCardProps {
name: string;
modLoader: string;
stable: boolean;
status: RemoteGameStatus;
}

function MonitorItem({ proc }: { proc: RemoteGameProcess }) {
const { detail: { modLoader, stable, name, versionId }, status } = proc;
const [, nav] = useLocation();
const GameInfoCardMemo = React.memo(GameInfoCard);

function revealProc() {
nav(`/monitor/${proc.id}`);
}
function GameInfoCard({ name, modLoader, stable, status }: GameInfoCardProps) {
const { t } = useTranslation("pages", { keyPrefix: "monitor.status" });

return <Card>
return <Card className="h-full">
<CardBody>
<div className="flex gap-4 items-center h-16 px-3">
<div className="h-full p-3 bg-content2 rounded-full">
<div className="flex flex-col p-4 gap-2 items-center my-auto">
<div
className={
clsx("max-w-40 m-8 p-5 bg-content2 rounded-full outline outline-4 outline-offset-8", {
"outline-success": status === "running",
"outline-default": status === "exited",
"outline-danger": status === "crashed"
})
}
>
<GameTypeImage loader={modLoader} stable={stable}/>
</div>
<div className="font-bold text-2xl">{name}</div>
<div className="text-lg text-foreground-400">{t(status)}</div>
</div>
</CardBody>
</Card>;
}

<div className="flex flex-col gap-1">
<div className="font-bold text-xl">{name}</div>
<div className="text-foreground-400">{versionId}</div>
</div>
interface MonitorActionsProps {
procId: string;
gameId: string;
status: RemoteGameStatus;
}

<div className="ml-auto flex gap-2 items-center">
<StatusChip status={status}/>
</div>
const MonitorActionsMemo = React.memo(MonitorActions);

function MonitorActions({ procId, gameId, status }: MonitorActionsProps) {
const { t } = useTranslation("pages", { keyPrefix: "monitor.actions" });
const [stopConfirmOpen, setStopConfirmOpen] = useState(false);
const [, nav] = useLocation();

function handleStopAction() {
native.launcher.stop(procId);
setStopConfirmOpen(false);
}


<div className="ml-4">
<Button isIconOnly color="primary" onPress={revealProc}>
<ArrowRightIcon/>
const stopDisabled = status !== "running";

return <div className="flex flex-col gap-4">
<Button startContent={<ArrowLeftIcon/>} onPress={() => nav("/monitor")}>
{t("back-to-list")}
</Button>
<Button startContent={<FolderArchive/>} onPress={() => native.game.reveal(gameId, "resourcepacks")}>
{t("reveal-rsp")}
</Button>
<Popover
placement="top"
color="foreground"
isOpen={stopConfirmOpen}
onOpenChange={setStopConfirmOpen}
>
<PopoverTrigger>
<Button
isDisabled={stopDisabled}
color="danger"
startContent={<OctagonXIcon/>}
>
{t("stop")}
</Button>
</PopoverTrigger>
<PopoverContent>
<div className="flex flex-col gap-2 py-2 px-4 items-center">
<div className="flex items-center gap-2">
<CircleHelpIcon/>
<div className="text-lg font-bold">{t("stop-title")}</div>
</div>

<div className="text-sm text-foreground-400">{t("stop-sub")}</div>
<Button
size="sm"
color="danger"
startContent={<ArrowRightIcon/>}
onPress={handleStopAction}
>
{t("stop-confirm")}
</Button>
</div>
</div>
</CardBody>
</Card>;
</PopoverContent>
</Popover>
</div>;
}
12 changes: 12 additions & 0 deletions src/renderer/services/game.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { GameProfile } from "@/main/game/spec";
import { useEffect, useState } from "react";

export function useGameList(): GameProfile[] {
const [games, setGames] = useState<GameProfile[]>([]);

useEffect(() => {
native.game.list().then(setGames);
}, []);

return games;
}
Original file line number Diff line number Diff line change
@@ -196,6 +196,6 @@ export function useGameProcList(): RemoteGameProcess[] {
return procs;
}

export const remoteGame = {
export const procService = {
create
};
12 changes: 12 additions & 0 deletions src/renderer/services/sources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { VersionManifest } from "@/main/install/vanilla";
import { useEffect, useState } from "react";

export function useVersionManifest(): VersionManifest | null {
const [versionManifest, setVersionManifest] = useState<VersionManifest | null>(null);

useEffect(() => {
native.sources.getVersionManifest().then(setVersionManifest);
}, []);

return versionManifest;
}

0 comments on commit 14a5d93

Please sign in to comment.