feat(plugin): attach Steam Deck screenshots to performance submissions
The plugin can now attach up to 2 Steam Deck screenshots (Steam + R1) to a performance entry, uploaded together in one Submit tap. Backend: - Screenshot upload/delete endpoints now accept API key auth via requireRoleWithApiKeyFallback (was session-only), so the plugin's x-api-key header works. The 2-screenshot limit was already enforced server-side (MAX_SCREENSHOTS_PER_ENTRY = 2). Plugin Python (main.py): - list_screenshots(): scans ~/Pictures/Screenshots/ + Steam Client/ subfolder, skips the most_recent.jpg duplicate, returns recent shots - upload_screenshots(): hard-caps at 2, builds multipart/form-data with urllib, posts to /api/performance/:id/screenshots Plugin UI (session-form.tsx): - New Screenshots section (max 2) with a picker listing recent Steam screenshots (time + size), selected shots as removable rows - Submit flow: upload entry → get entry ID → upload selected screenshots → combined success message (partial-success if screenshots fail)
This commit is contained in:
@@ -5,7 +5,6 @@ import {
|
||||
PanelSectionRow,
|
||||
DropdownItem,
|
||||
TextField,
|
||||
Field,
|
||||
staticClasses,
|
||||
} from "@decky/ui"
|
||||
import {
|
||||
@@ -13,11 +12,20 @@ import {
|
||||
FaCloudUploadAlt,
|
||||
FaCheck,
|
||||
FaTimes,
|
||||
FaImages,
|
||||
FaPlus,
|
||||
FaTrash,
|
||||
FaSpinner,
|
||||
} 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"
|
||||
import {
|
||||
exportToFile,
|
||||
uploadToDeckyvault,
|
||||
listScreenshots,
|
||||
uploadScreenshots,
|
||||
} from "../lib/api"
|
||||
|
||||
interface SessionFormProps {
|
||||
session: SessionData
|
||||
@@ -46,6 +54,29 @@ const FRAME_GEN_OPTIONS = [
|
||||
{ label: "Other", data: "other" },
|
||||
]
|
||||
|
||||
const MAX_SCREENSHOTS = 2
|
||||
|
||||
interface ScreenshotFile {
|
||||
path: string
|
||||
name: string
|
||||
mtime: number
|
||||
size: number
|
||||
}
|
||||
|
||||
function formatShotTime(mtime: number): string {
|
||||
const d = new Date(mtime * 1000)
|
||||
const today = new Date()
|
||||
const isToday = d.toDateString() === today.toDateString()
|
||||
const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
return isToday ? `Today ${time}` : `${d.toLocaleDateString()} ${time}`
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export default function SessionForm({
|
||||
session,
|
||||
error,
|
||||
@@ -59,6 +90,41 @@ export default function SessionForm({
|
||||
const [uploadStatus, setUploadStatus] = useState<"idle" | "loading" | "success" | "error">("idle")
|
||||
const [statusMessage, setStatusMessage] = useState("")
|
||||
|
||||
// ── Screenshot picker state ──────────────────────────────
|
||||
const [selectedShots, setSelectedShots] = useState<ScreenshotFile[]>([])
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [availableShots, setAvailableShots] = useState<ScreenshotFile[]>([])
|
||||
const [shotsLoading, setShotsLoading] = useState(false)
|
||||
const [shotsError, setShotsError] = useState("")
|
||||
|
||||
async function openPicker() {
|
||||
if (selectedShots.length >= MAX_SCREENSHOTS) return
|
||||
setPickerOpen(true)
|
||||
setShotsLoading(true)
|
||||
setShotsError("")
|
||||
const result = await listScreenshots(12)
|
||||
setShotsLoading(false)
|
||||
if (result.error) {
|
||||
setShotsError(result.error)
|
||||
}
|
||||
// Filter out already-selected paths
|
||||
setAvailableShots(result.screenshots.filter((s) => !selectedShots.some((sel) => sel.path === s.path)))
|
||||
}
|
||||
|
||||
function addShot(shot: ScreenshotFile) {
|
||||
if (selectedShots.length >= MAX_SCREENSHOTS) return
|
||||
setSelectedShots((prev) => [...prev, shot])
|
||||
setAvailableShots((prev) => prev.filter((s) => s.path !== shot.path))
|
||||
if (selectedShots.length + 1 >= MAX_SCREENSHOTS) {
|
||||
setPickerOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
function removeShot(shot: ScreenshotFile) {
|
||||
setSelectedShots((prev) => prev.filter((s) => s.path !== shot.path))
|
||||
setAvailableShots((prev) => [...prev, shot].sort((a, b) => b.mtime - a.mtime))
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
if (!settings) return
|
||||
setError("")
|
||||
@@ -88,7 +154,7 @@ export default function SessionForm({
|
||||
}
|
||||
setError("")
|
||||
setUploadStatus("loading")
|
||||
setStatusMessage("")
|
||||
setStatusMessage("Uploading entry…")
|
||||
|
||||
const payload = buildImportPayload(session)
|
||||
const result = await uploadToDeckyvault(
|
||||
@@ -97,17 +163,41 @@ export default function SessionForm({
|
||||
settings.baseUrl,
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
setUploadStatus("success")
|
||||
setStatusMessage(`Uploaded! Entry ID: ${result.data?.id}`)
|
||||
onAddToRecent(session)
|
||||
} else {
|
||||
if (!result.success) {
|
||||
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
|
||||
}
|
||||
|
||||
const entryId = result.data?.id
|
||||
|
||||
// ── Upload screenshots if any are selected ───────────────
|
||||
if (selectedShots.length > 0 && entryId) {
|
||||
setStatusMessage(`Entry uploaded. Adding ${selectedShots.length} screenshot${selectedShots.length > 1 ? "s" : ""}…`)
|
||||
const shotResult = await uploadScreenshots(
|
||||
entryId,
|
||||
selectedShots.map((s) => s.path),
|
||||
settings.apiKey,
|
||||
settings.baseUrl,
|
||||
)
|
||||
if (shotResult.success) {
|
||||
setUploadStatus("success")
|
||||
const n = shotResult.uploaded ?? selectedShots.length
|
||||
setStatusMessage(`Uploaded! Entry + ${n} screenshot${n > 1 ? "s" : ""}`)
|
||||
} else {
|
||||
// Entry succeeded but screenshots failed — still a partial success
|
||||
setUploadStatus("success")
|
||||
setStatusMessage(`Entry uploaded (ID: ${entryId}). Screenshots failed: ${shotResult.error}`)
|
||||
}
|
||||
} else {
|
||||
setUploadStatus("success")
|
||||
setStatusMessage(`Uploaded! Entry ID: ${entryId}`)
|
||||
}
|
||||
|
||||
onAddToRecent(session)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -195,6 +285,129 @@ export default function SessionForm({
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
|
||||
{/* ── Screenshots (max 2) ────────────────────────────────── */}
|
||||
<PanelSection title={`Screenshots${selectedShots.length > 0 ? ` (${selectedShots.length}/${MAX_SCREENSHOTS})` : ""}`}>
|
||||
<PanelSectionRow>
|
||||
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "2px 0", opacity: 0.6, lineHeight: 1.4 }}>
|
||||
Attach up to {MAX_SCREENSHOTS} Steam Deck screenshots (Steam + R1). They upload with your entry.
|
||||
</div>
|
||||
</PanelSectionRow>
|
||||
|
||||
{/* Selected screenshots */}
|
||||
{selectedShots.map((shot) => (
|
||||
<PanelSectionRow key={shot.path}>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "6px 10px",
|
||||
borderRadius: "6px",
|
||||
background: "rgba(255,255,255,0.06)",
|
||||
border: "1px solid rgba(255,255,255,0.10)",
|
||||
}}>
|
||||
<FaImages style={{ opacity: 0.6, flexShrink: 0 }} />
|
||||
<div className={staticClasses.Text} style={{ flex: 1, minWidth: 0, fontSize: "12px" }}>
|
||||
<div style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{shot.name}
|
||||
</div>
|
||||
<div style={{ opacity: 0.5, fontSize: "11px" }}>
|
||||
{formatShotTime(shot.mtime)} · {formatSize(shot.size)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeShot(shot)}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "#e74c3c",
|
||||
padding: "4px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title="Remove"
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</div>
|
||||
</PanelSectionRow>
|
||||
))}
|
||||
|
||||
{/* Add button (hidden when at max) */}
|
||||
{selectedShots.length < MAX_SCREENSHOTS && !pickerOpen && (
|
||||
<PanelSectionRow>
|
||||
<ButtonItem layout="below" onClick={openPicker}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", justifyContent: "center" }}>
|
||||
<FaPlus />
|
||||
Add Screenshot
|
||||
</div>
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
|
||||
{/* Picker: list of recent Steam screenshots */}
|
||||
{pickerOpen && (
|
||||
<>
|
||||
<PanelSectionRow>
|
||||
<div className={staticClasses.Text} style={{ fontSize: "11px", opacity: 0.5, padding: "4px 0", textTransform: "uppercase", letterSpacing: "0.04em" }}>
|
||||
Recent screenshots
|
||||
</div>
|
||||
</PanelSectionRow>
|
||||
|
||||
{shotsLoading && (
|
||||
<PanelSectionRow>
|
||||
<div className={staticClasses.Text} style={{ padding: "8px 0", textAlign: "center", fontSize: "13px", opacity: 0.7, display: "flex", alignItems: "center", justifyContent: "center", gap: "8px" }}>
|
||||
<FaSpinner className="fa-spin" /> Loading…
|
||||
</div>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
|
||||
{shotsError && !shotsLoading && (
|
||||
<PanelSectionRow>
|
||||
<div className={staticClasses.Text} style={{ fontSize: "12px", color: "#e74c3c", padding: "4px 0" }}>
|
||||
{shotsError}
|
||||
</div>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
|
||||
{!shotsLoading && !shotsError && availableShots.length === 0 && (
|
||||
<PanelSectionRow>
|
||||
<div className={staticClasses.Text} style={{ fontSize: "12px", opacity: 0.5, padding: "8px 0", textAlign: "center" }}>
|
||||
No screenshots found. Take one with Steam + R1.
|
||||
</div>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
|
||||
{!shotsLoading && availableShots.map((shot) => (
|
||||
<PanelSectionRow key={shot.path}>
|
||||
<ButtonItem layout="below" onClick={() => addShot(shot)}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<FaImages style={{ opacity: 0.6, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0, textAlign: "left" }}>
|
||||
<div style={{ fontSize: "12px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{formatShotTime(shot.mtime)}
|
||||
</div>
|
||||
<div style={{ fontSize: "11px", opacity: 0.6 }}>
|
||||
{formatSize(shot.size)}
|
||||
</div>
|
||||
</div>
|
||||
<FaPlus style={{ opacity: 0.7, flexShrink: 0 }} />
|
||||
</div>
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
))}
|
||||
|
||||
<PanelSectionRow>
|
||||
<ButtonItem layout="below" onClick={() => setPickerOpen(false)}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", justifyContent: "center" }}>
|
||||
<FaTimes />
|
||||
Cancel
|
||||
</div>
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
{/* ── Error display ──────────────────────────────────────── */}
|
||||
{error && (
|
||||
<PanelSectionRow>
|
||||
|
||||
@@ -81,6 +81,23 @@ export const uploadToDeckyvault = callable<[
|
||||
status?: number
|
||||
}>("upload_to_deckyvault")
|
||||
|
||||
export const listScreenshots = callable<[limit?: number], {
|
||||
screenshots: Array<{ path: string; name: string; mtime: number; size: number }>
|
||||
error?: string
|
||||
}>("list_screenshots")
|
||||
|
||||
export const uploadScreenshots = callable<[
|
||||
entryId: string,
|
||||
screenshotPaths: string[],
|
||||
apiKey: string,
|
||||
baseUrl?: string
|
||||
], {
|
||||
success: boolean
|
||||
uploaded?: number
|
||||
error?: string
|
||||
status?: number
|
||||
}>("upload_screenshots")
|
||||
|
||||
export const testApiKey = callable<[apiKey: string, baseUrl?: string], {
|
||||
valid: boolean
|
||||
error?: string
|
||||
|
||||
Reference in New Issue
Block a user