154 lines
4.2 KiB
TypeScript
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>
|
|
);
|
|
}
|