feat(plugin): add session form component (manual inputs + export/upload)
This commit is contained in:
@@ -0,0 +1,281 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
ButtonItem,
|
||||||
|
PanelSection,
|
||||||
|
PanelSectionRow,
|
||||||
|
Field,
|
||||||
|
DropdownItem,
|
||||||
|
staticClasses,
|
||||||
|
} from "@decky/ui"
|
||||||
|
import {
|
||||||
|
FaFileExport,
|
||||||
|
FaCloudUploadAlt,
|
||||||
|
FaCheck,
|
||||||
|
FaTimes,
|
||||||
|
} from "react-icons/fa"
|
||||||
|
import type { SessionData } from "../lib/store"
|
||||||
|
import { buildImportPayload } from "../lib/store"
|
||||||
|
import type { PluginSettings } from "../lib/store"
|
||||||
|
import { exportToFile, uploadToDeckyvault } from "../lib/api"
|
||||||
|
|
||||||
|
interface SessionFormProps {
|
||||||
|
session: SessionData
|
||||||
|
error: string
|
||||||
|
settings?: PluginSettings
|
||||||
|
onUpdateSession: (updates: Partial<SessionData>) => void
|
||||||
|
onAddToRecent: (sess: SessionData) => void
|
||||||
|
onReset: () => void
|
||||||
|
setError: (msg: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const UPSCALER_OPTIONS = [
|
||||||
|
{ label: "None", value: "none" },
|
||||||
|
{ label: "FSR", value: "fsr" },
|
||||||
|
{ label: "DLSS", value: "dlss" },
|
||||||
|
{ label: "XeSS", value: "xess" },
|
||||||
|
{ label: "LSFG", value: "lsfg" },
|
||||||
|
{ label: "Other", value: "other" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const FRAME_GEN_OPTIONS = [
|
||||||
|
{ label: "None", value: "none" },
|
||||||
|
{ label: "FSR FG", value: "fsr_fg" },
|
||||||
|
{ label: "DLSS FG", value: "dlss_fg" },
|
||||||
|
{ label: "LSFG", value: "lsfg" },
|
||||||
|
{ label: "Other", value: "other" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function SessionForm({
|
||||||
|
session,
|
||||||
|
error,
|
||||||
|
settings,
|
||||||
|
onUpdateSession,
|
||||||
|
onAddToRecent,
|
||||||
|
onReset,
|
||||||
|
setError,
|
||||||
|
}: SessionFormProps) {
|
||||||
|
const [exportStatus, setExportStatus] = useState<"idle" | "success" | "error">("idle")
|
||||||
|
const [uploadStatus, setUploadStatus] = useState<"idle" | "loading" | "success" | "error">("idle")
|
||||||
|
const [statusMessage, setStatusMessage] = useState("")
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
if (!settings) return
|
||||||
|
setError("")
|
||||||
|
setExportStatus("idle")
|
||||||
|
const payload = buildImportPayload(session)
|
||||||
|
const gameSlug = session.gameName.toLowerCase().replace(/[^a-z0-9]/g, "-") || "unknown"
|
||||||
|
const date = new Date().toISOString().slice(0, 10)
|
||||||
|
const filename = `${gameSlug}-${date}.deckyvault.json`
|
||||||
|
const fullPath = `${settings.exportPath}/${filename}`
|
||||||
|
|
||||||
|
const result = await exportToFile(payload as unknown as Record<string, unknown>, fullPath)
|
||||||
|
if (result.success) {
|
||||||
|
setExportStatus("success")
|
||||||
|
setStatusMessage(`Saved to ${result.path}`)
|
||||||
|
onAddToRecent(session)
|
||||||
|
} else {
|
||||||
|
setExportStatus("error")
|
||||||
|
setStatusMessage(result.error || "Export failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload() {
|
||||||
|
if (!settings) return
|
||||||
|
if (!settings.apiKey) {
|
||||||
|
setError("No API key configured. Set one in the Settings tab.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError("")
|
||||||
|
setUploadStatus("loading")
|
||||||
|
setStatusMessage("")
|
||||||
|
|
||||||
|
const payload = buildImportPayload(session)
|
||||||
|
const result = await uploadToDeckyvault(
|
||||||
|
payload as unknown as Record<string, unknown>,
|
||||||
|
settings.apiKey,
|
||||||
|
settings.baseUrl,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setUploadStatus("success")
|
||||||
|
setStatusMessage(`Uploaded! Entry ID: ${result.data?.id}`)
|
||||||
|
onAddToRecent(session)
|
||||||
|
} else {
|
||||||
|
setUploadStatus("error")
|
||||||
|
setStatusMessage(result.error || "Upload failed")
|
||||||
|
if (result.status === 404) {
|
||||||
|
setStatusMessage("This game isn't in DeckyVault yet. Submit it on the website first, or export to file.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelSection title="Session Results">
|
||||||
|
{/* ── Auto-captured summary ──────────────────────────────── */}
|
||||||
|
<PanelSection title="Captured Metrics">
|
||||||
|
<PanelSectionRow>
|
||||||
|
<div className={staticClasses.Text} style={{ fontSize: "13px", padding: "4px 0" }}>
|
||||||
|
<strong>Game:</strong> {session.gameName || "Unknown"}<br />
|
||||||
|
{session.appId && <><strong>App ID:</strong> {session.appId}<br /></>}
|
||||||
|
<strong>FPS:</strong> {session.fpsAvg ?? "—"} avg / {session.fpsLow ?? "—"} min / {session.fpsOnePercentLow ?? "—"} 1% low / {session.fpsHigh ?? "—"} max<br />
|
||||||
|
<strong>TDP:</strong> {session.tdpWatts ? `${session.tdpWatts}W` : "—"}<br />
|
||||||
|
<strong>Hardware:</strong> {session.hardwareName || session.hardwareSlug || "—"}<br />
|
||||||
|
<strong>OS:</strong> {session.osVersion || "—"}<br />
|
||||||
|
<strong>Proton:</strong> {session.protonVersion || "—"}
|
||||||
|
</div>
|
||||||
|
</PanelSectionRow>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
{/* ── Manual inputs ──────────────────────────────────────── */}
|
||||||
|
<PanelSection title="Additional Details">
|
||||||
|
<PanelSectionRow>
|
||||||
|
<DropdownItem
|
||||||
|
label="Upscaler"
|
||||||
|
rgOptions={UPSCALER_OPTIONS}
|
||||||
|
selectedOption={session.upscalerType}
|
||||||
|
onChange={(opt) => onUpdateSession({ upscalerType: opt.data as string })}
|
||||||
|
/>
|
||||||
|
</PanelSectionRow>
|
||||||
|
|
||||||
|
<PanelSectionRow>
|
||||||
|
<Field label="Upscaler Version" bottomSeparator="none">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={session.upscalerVersion}
|
||||||
|
onChange={(e) => onUpdateSession({ upscalerVersion: e.target.value })}
|
||||||
|
placeholder="e.g. 2.4"
|
||||||
|
style={{ width: "100%", padding: "4px 8px" }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</PanelSectionRow>
|
||||||
|
|
||||||
|
<PanelSectionRow>
|
||||||
|
<DropdownItem
|
||||||
|
label="Frame Generation"
|
||||||
|
rgOptions={FRAME_GEN_OPTIONS}
|
||||||
|
selectedOption={session.frameGenMethod}
|
||||||
|
onChange={(opt) => onUpdateSession({ frameGenMethod: opt.data as string })}
|
||||||
|
/>
|
||||||
|
</PanelSectionRow>
|
||||||
|
|
||||||
|
<PanelSectionRow>
|
||||||
|
<Field label="In-game Settings" bottomSeparator="none">
|
||||||
|
<textarea
|
||||||
|
value={session.settingsJson}
|
||||||
|
onChange={(e) => onUpdateSession({ settingsJson: e.target.value })}
|
||||||
|
placeholder="e.g. High preset, 1280x800, TAA"
|
||||||
|
rows={3}
|
||||||
|
style={{ width: "100%", padding: "4px 8px", resize: "vertical" }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</PanelSectionRow>
|
||||||
|
|
||||||
|
<PanelSectionRow>
|
||||||
|
<Field label="Load Time - SSD (seconds)" bottomSeparator="none">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={session.loadTimeSsd}
|
||||||
|
onChange={(e) => onUpdateSession({ loadTimeSsd: e.target.value })}
|
||||||
|
placeholder="e.g. 12.5"
|
||||||
|
style={{ width: "100%", padding: "4px 8px" }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</PanelSectionRow>
|
||||||
|
|
||||||
|
<PanelSectionRow>
|
||||||
|
<Field label="Load Time - SD Card (seconds)" bottomSeparator="none">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={session.loadTimeSd}
|
||||||
|
onChange={(e) => onUpdateSession({ loadTimeSd: e.target.value })}
|
||||||
|
placeholder="e.g. 25.0"
|
||||||
|
style={{ width: "100%", padding: "4px 8px" }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</PanelSectionRow>
|
||||||
|
|
||||||
|
<PanelSectionRow>
|
||||||
|
<Field label="Launch Options" bottomSeparator="none">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={session.launchOptions}
|
||||||
|
onChange={(e) => onUpdateSession({ launchOptions: e.target.value })}
|
||||||
|
placeholder="e.g. mangohud %command%"
|
||||||
|
style={{ width: "100%", padding: "4px 8px" }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</PanelSectionRow>
|
||||||
|
|
||||||
|
<PanelSectionRow>
|
||||||
|
<Field label="Notes" bottomSeparator="none">
|
||||||
|
<textarea
|
||||||
|
value={session.userNotes}
|
||||||
|
onChange={(e) => onUpdateSession({ userNotes: e.target.value })}
|
||||||
|
placeholder="Any observations about performance..."
|
||||||
|
rows={3}
|
||||||
|
maxLength={5000}
|
||||||
|
style={{ width: "100%", padding: "4px 8px", resize: "vertical" }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</PanelSectionRow>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
{/* ── Error display ──────────────────────────────────────── */}
|
||||||
|
{error && (
|
||||||
|
<PanelSectionRow>
|
||||||
|
<div className={staticClasses.Text} style={{ color: "#e74c3c", padding: "8px" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</PanelSectionRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Status messages ────────────────────────────────────── */}
|
||||||
|
{statusMessage && (
|
||||||
|
<PanelSectionRow>
|
||||||
|
<div
|
||||||
|
className={staticClasses.Text}
|
||||||
|
style={{
|
||||||
|
padding: "8px",
|
||||||
|
color: exportStatus === "success" || uploadStatus === "success" ? "#2ecc71" : "#e74c3c",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{exportStatus === "success" && <FaCheck />}{" "}
|
||||||
|
{exportStatus === "error" && <FaTimes />}{" "}
|
||||||
|
{uploadStatus === "success" && <FaCheck />}{" "}
|
||||||
|
{uploadStatus === "error" && <FaTimes />}{" "}
|
||||||
|
{statusMessage}
|
||||||
|
</div>
|
||||||
|
</PanelSectionRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Action buttons ─────────────────────────────────────── */}
|
||||||
|
<PanelSectionRow>
|
||||||
|
<ButtonItem layout="below" onClick={handleExport} disabled={false}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
<FaFileExport />
|
||||||
|
Export to File
|
||||||
|
</div>
|
||||||
|
</ButtonItem>
|
||||||
|
</PanelSectionRow>
|
||||||
|
|
||||||
|
<PanelSectionRow>
|
||||||
|
<ButtonItem
|
||||||
|
layout="below"
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={uploadStatus === "loading"}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
<FaCloudUploadAlt />
|
||||||
|
{uploadStatus === "loading" ? "Uploading..." : "Upload to DeckyVault"}
|
||||||
|
</div>
|
||||||
|
</ButtonItem>
|
||||||
|
</PanelSectionRow>
|
||||||
|
|
||||||
|
<PanelSectionRow>
|
||||||
|
<ButtonItem layout="below" onClick={onReset} disabled={false}>
|
||||||
|
New Recording
|
||||||
|
</ButtonItem>
|
||||||
|
</PanelSectionRow>
|
||||||
|
</PanelSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user