Files
PSG-Conduit/src/components/AppCard.tsx

154 lines
4.2 KiB
TypeScript

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>
);
}