feat: initial PSG Launcher scaffold
This commit is contained in:
153
src/components/AppCard.tsx
Normal file
153
src/components/AppCard.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
downloadAndInstallApp,
|
||||
launchApp,
|
||||
uninstallApp,
|
||||
} from "../lib/commands";
|
||||
import type { AppEntry, InstalledApp } from "../types/manifest";
|
||||
import "./AppCard.css";
|
||||
|
||||
interface Props {
|
||||
app: AppEntry;
|
||||
installed?: InstalledApp;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
type ActionState = "idle" | "downloading" | "launching" | "error";
|
||||
|
||||
export function AppCard({ app, installed, onRefresh }: Props) {
|
||||
const [state, setState] = useState<ActionState>("idle");
|
||||
const [errMsg, setErrMsg] = useState<string | null>(null);
|
||||
|
||||
const isInstalled = !!installed;
|
||||
const hasUpdate = isInstalled && installed.version !== app.current_version;
|
||||
const initial = app.name.charAt(0).toUpperCase();
|
||||
|
||||
async function handleInstallOrUpdate() {
|
||||
setState("downloading");
|
||||
setErrMsg(null);
|
||||
try {
|
||||
await downloadAndInstallApp(app);
|
||||
onRefresh();
|
||||
} catch (e: unknown) {
|
||||
const msg =
|
||||
e && typeof e === "object" && "message" in e
|
||||
? String((e as { message: unknown }).message)
|
||||
: String(e);
|
||||
setErrMsg(msg);
|
||||
setState("error");
|
||||
return;
|
||||
}
|
||||
setState("idle");
|
||||
}
|
||||
|
||||
async function handleLaunch() {
|
||||
setState("launching");
|
||||
setErrMsg(null);
|
||||
try {
|
||||
await launchApp(app.id);
|
||||
} catch (e: unknown) {
|
||||
const msg =
|
||||
e && typeof e === "object" && "message" in e
|
||||
? String((e as { message: unknown }).message)
|
||||
: String(e);
|
||||
setErrMsg(msg);
|
||||
setState("error");
|
||||
return;
|
||||
}
|
||||
setState("idle");
|
||||
}
|
||||
|
||||
async function handleUninstall() {
|
||||
if (!confirm(`Remove ${app.name} from the launcher? (Files are kept.)`)) return;
|
||||
try {
|
||||
await uninstallApp(app.id);
|
||||
onRefresh();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
const busy = state === "downloading" || state === "launching";
|
||||
|
||||
return (
|
||||
<article className={`app-card${hasUpdate ? " app-card--has-update" : ""}`}>
|
||||
{hasUpdate && (
|
||||
<span className="app-card__update-badge" title={`Update: v${app.current_version}`}>
|
||||
↑
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="app-card__icon">
|
||||
{app.icon_url ? (
|
||||
<img src={app.icon_url} alt={app.name} />
|
||||
) : (
|
||||
<span className="app-card__icon-fallback">{initial}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="app-card__info">
|
||||
<h3 className="app-card__name">{app.name}</h3>
|
||||
<p className="app-card__desc">{app.description}</p>
|
||||
<div className="app-card__meta">
|
||||
<span className="app-card__version">
|
||||
{isInstalled ? (
|
||||
<>
|
||||
<span className="app-card__installed-ver">{installed.version}</span>
|
||||
{hasUpdate && (
|
||||
<span className="app-card__available-ver"> → {app.current_version}</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
`v${app.current_version}`
|
||||
)}
|
||||
</span>
|
||||
<span className="app-card__category">{app.category}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state === "error" && errMsg && (
|
||||
<p className="app-card__error" title={errMsg}>
|
||||
{errMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="app-card__actions">
|
||||
{isInstalled && (
|
||||
<button
|
||||
className="app-card__btn app-card__btn--primary"
|
||||
onClick={handleLaunch}
|
||||
disabled={busy}
|
||||
>
|
||||
{state === "launching" ? "Launching…" : "Launch"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(!isInstalled || hasUpdate) && (
|
||||
<button
|
||||
className={`app-card__btn${isInstalled ? " app-card__btn--secondary" : " app-card__btn--primary"}`}
|
||||
onClick={handleInstallOrUpdate}
|
||||
disabled={busy}
|
||||
>
|
||||
{state === "downloading"
|
||||
? "Downloading…"
|
||||
: hasUpdate
|
||||
? `Update → v${app.current_version}`
|
||||
: "Install"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isInstalled && (
|
||||
<button
|
||||
className="app-card__btn app-card__btn--ghost"
|
||||
onClick={handleUninstall}
|
||||
disabled={busy}
|
||||
title="Remove from launcher"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user