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:
@@ -2,20 +2,36 @@ import { Elysia, t } from "elysia"
|
|||||||
import { db } from "@/lib/db/index"
|
import { db } from "@/lib/db/index"
|
||||||
import { performanceEntries, entryScreenshots, storageObjects } from "@/lib/db/schema"
|
import { performanceEntries, entryScreenshots, storageObjects } from "@/lib/db/schema"
|
||||||
import { eq, sql } from "drizzle-orm"
|
import { eq, sql } from "drizzle-orm"
|
||||||
import { requireRole } from "@/lib/auth/guard"
|
import { requireAuthWithApiKeyFallback } from "@/lib/auth/api-key-guard"
|
||||||
import { uploadObject, deleteObject, getR2PublicUrl, isR2Configured } from "@/lib/storage"
|
import { uploadObject, deleteObject, getR2PublicUrl, isR2Configured } from "@/lib/storage"
|
||||||
import { processScreenshot, isAllowedMimeType } from "@/lib/image-processing"
|
import { processScreenshot, isAllowedMimeType } from "@/lib/image-processing"
|
||||||
|
|
||||||
const MAX_SCREENSHOTS_PER_ENTRY = 2
|
const MAX_SCREENSHOTS_PER_ENTRY = 2
|
||||||
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 // 10 MB raw
|
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 // 10 MB raw
|
||||||
|
|
||||||
|
/** Auth that accepts either a session cookie or an x-api-key header,
|
||||||
|
* then enforces a role. Used so the Decky plugin (API key) and the web
|
||||||
|
* app (session) can both upload screenshots. */
|
||||||
|
async function requireRoleWithApiKeyFallback(
|
||||||
|
headers: Headers,
|
||||||
|
roles: string[],
|
||||||
|
) {
|
||||||
|
const guard = await requireAuthWithApiKeyFallback(headers)
|
||||||
|
if (!guard.ok) return guard
|
||||||
|
const userRole = guard.user.role ?? "user"
|
||||||
|
if (!roles.includes(userRole)) {
|
||||||
|
return { ok: false as const, error: "Forbidden", status: 403 }
|
||||||
|
}
|
||||||
|
return guard
|
||||||
|
}
|
||||||
|
|
||||||
export const screenshotRoutes = new Elysia({ prefix: "/performance", detail: { tags: ["Performance"] } })
|
export const screenshotRoutes = new Elysia({ prefix: "/performance", detail: { tags: ["Performance"] } })
|
||||||
|
|
||||||
// ── Upload screenshots ─────────────────────────────────────────
|
// ── Upload screenshots ─────────────────────────────────────────
|
||||||
.post(
|
.post(
|
||||||
"/:id/screenshots",
|
"/:id/screenshots",
|
||||||
async ({ params, request, set }) => {
|
async ({ params, request, set }) => {
|
||||||
const guard = await requireRole(request.headers, ["user", "contributor", "admin"])
|
const guard = await requireRoleWithApiKeyFallback(request.headers, ["user", "contributor", "admin"])
|
||||||
if (!guard.ok) {
|
if (!guard.ok) {
|
||||||
set.status = guard.status
|
set.status = guard.status
|
||||||
return { error: guard.error }
|
return { error: guard.error }
|
||||||
@@ -202,7 +218,7 @@ export const screenshotRoutes = new Elysia({ prefix: "/performance", detail: { t
|
|||||||
.delete(
|
.delete(
|
||||||
"/:id/screenshots/:sid",
|
"/:id/screenshots/:sid",
|
||||||
async ({ params, request, set }) => {
|
async ({ params, request, set }) => {
|
||||||
const guard = await requireRole(request.headers, ["user", "contributor", "admin"])
|
const guard = await requireRoleWithApiKeyFallback(request.headers, ["user", "contributor", "admin"])
|
||||||
if (!guard.ok) {
|
if (!guard.ok) {
|
||||||
set.status = guard.status
|
set.status = guard.status
|
||||||
return { error: guard.error }
|
return { error: guard.error }
|
||||||
|
|||||||
@@ -605,6 +605,115 @@ exec mangohud "$@"
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"success": False, "error": str(e), "status": 0}
|
return {"success": False, "error": str(e), "status": 0}
|
||||||
|
|
||||||
|
async def list_screenshots(self, limit: int = 12) -> dict:
|
||||||
|
"""RPC: List recent Steam Deck screenshots from ~/Pictures/Screenshots/.
|
||||||
|
Returns {screenshots: [{path, name, mtime, size}], error?}.
|
||||||
|
Steam saves timestamped JPGs in a 'Steam Client' subfolder and keeps
|
||||||
|
a 'most_recent.jpg' symlink-like copy at the top level."""
|
||||||
|
import glob
|
||||||
|
import time
|
||||||
|
try:
|
||||||
|
home = os.path.expanduser("~")
|
||||||
|
base = os.path.join(home, "Pictures", "Screenshots")
|
||||||
|
patterns = [
|
||||||
|
os.path.join(base, "*.jpg"),
|
||||||
|
os.path.join(base, "*.png"),
|
||||||
|
os.path.join(base, "Steam Client", "*.jpg"),
|
||||||
|
os.path.join(base, "Steam Client", "*.png"),
|
||||||
|
]
|
||||||
|
seen = set()
|
||||||
|
files = []
|
||||||
|
for pat in patterns:
|
||||||
|
for f in glob.glob(pat):
|
||||||
|
if not os.path.isfile(f) or f in seen:
|
||||||
|
continue
|
||||||
|
# Skip the most_recent.jpg duplicate if a real timestamped
|
||||||
|
# copy exists — it's just a pointer to the latest one.
|
||||||
|
if os.path.basename(f) == "most_recent.jpg":
|
||||||
|
continue
|
||||||
|
seen.add(f)
|
||||||
|
try:
|
||||||
|
files.append({
|
||||||
|
"path": f,
|
||||||
|
"name": os.path.basename(f),
|
||||||
|
"mtime": os.path.getmtime(f),
|
||||||
|
"size": os.path.getsize(f),
|
||||||
|
})
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
files.sort(key=lambda x: x["mtime"], reverse=True)
|
||||||
|
return {"screenshots": files[:limit]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"screenshots": [], "error": str(e)}
|
||||||
|
|
||||||
|
async def upload_screenshots(self, entry_id: str, screenshot_paths: list, api_key: str, base_url: str = "https://deckyvault.xyz") -> dict:
|
||||||
|
"""RPC: Upload up to 2 screenshots to a performance entry as multipart/form-data.
|
||||||
|
Returns {success: bool, uploaded: int, error?: str, status?: int}.
|
||||||
|
The server enforces the 2-screenshot limit per entry."""
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
# Hard cap at 2 — matches the website limit
|
||||||
|
paths = [p for p in screenshot_paths if p][:2]
|
||||||
|
if not paths:
|
||||||
|
return {"success": False, "error": "No screenshots selected"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f"{base_url}/api/performance/{entry_id}/screenshots"
|
||||||
|
|
||||||
|
# Build a multipart/form-data body manually (urllib has no helper)
|
||||||
|
boundary = "----DeckyVaultBoundary" + uuid.uuid4().hex
|
||||||
|
encoded = b""
|
||||||
|
valid_paths = []
|
||||||
|
for p in paths:
|
||||||
|
if not os.path.exists(p):
|
||||||
|
continue
|
||||||
|
valid_paths.append(p)
|
||||||
|
with open(p, "rb") as fh:
|
||||||
|
file_bytes = fh.read()
|
||||||
|
fname = os.path.basename(p)
|
||||||
|
ext = os.path.splitext(fname)[1].lower()
|
||||||
|
mime = "image/png" if ext == ".png" else ("image/webp" if ext == ".webp" else "image/jpeg")
|
||||||
|
encoded += (
|
||||||
|
f"--{boundary}\r\n"
|
||||||
|
f'Content-Disposition: form-data; name="screenshots"; filename="{fname}"\r\n'
|
||||||
|
f"Content-Type: {mime}\r\n\r\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
encoded += file_bytes
|
||||||
|
encoded += b"\r\n"
|
||||||
|
encoded += f"--{boundary}--\r\n".encode("utf-8")
|
||||||
|
|
||||||
|
if not valid_paths:
|
||||||
|
return {"success": False, "error": "No readable screenshot files"}
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=encoded,
|
||||||
|
headers={
|
||||||
|
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||||
|
"x-api-key": api_key,
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; rv:136.0) Gecko/20100101 Firefox/136.0",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
context = _get_ssl_context()
|
||||||
|
with urllib.request.urlopen(req, timeout=30, context=context) as response:
|
||||||
|
result = json.loads(response.read().decode("utf-8"))
|
||||||
|
uploaded = len(result.get("data", [])) if isinstance(result.get("data"), list) else 0
|
||||||
|
return {"success": True, "uploaded": uploaded}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
err = json.loads(e.read().decode("utf-8"))
|
||||||
|
return {"success": False, "error": err.get("error", f"Server returned status {e.code}"), "status": e.code}
|
||||||
|
except Exception:
|
||||||
|
return {"success": False, "error": f"Server returned status {e.code}", "status": e.code}
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return {"success": False, "error": f"Network error: {str(e.reason)}", "status": 0}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": str(e), "status": 0}
|
||||||
|
|
||||||
async def test_api_key(self, api_key: str, base_url: str = "https://deckyvault.xyz") -> dict:
|
async def test_api_key(self, api_key: str, base_url: str = "https://deckyvault.xyz") -> dict:
|
||||||
"""RPC: Test if an API key is valid by calling the dedicated verify endpoint.
|
"""RPC: Test if an API key is valid by calling the dedicated verify endpoint.
|
||||||
Returns {valid: bool, error: str?, userName?: str, userImage?: str}."""
|
Returns {valid: bool, error: str?, userName?: str, userImage?: str}."""
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
PanelSectionRow,
|
PanelSectionRow,
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
TextField,
|
TextField,
|
||||||
Field,
|
|
||||||
staticClasses,
|
staticClasses,
|
||||||
} from "@decky/ui"
|
} from "@decky/ui"
|
||||||
import {
|
import {
|
||||||
@@ -13,11 +12,20 @@ import {
|
|||||||
FaCloudUploadAlt,
|
FaCloudUploadAlt,
|
||||||
FaCheck,
|
FaCheck,
|
||||||
FaTimes,
|
FaTimes,
|
||||||
|
FaImages,
|
||||||
|
FaPlus,
|
||||||
|
FaTrash,
|
||||||
|
FaSpinner,
|
||||||
} from "react-icons/fa"
|
} from "react-icons/fa"
|
||||||
import type { SessionData } from "../lib/store"
|
import type { SessionData } from "../lib/store"
|
||||||
import { buildImportPayload } from "../lib/store"
|
import { buildImportPayload } from "../lib/store"
|
||||||
import type { PluginSettings } 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 {
|
interface SessionFormProps {
|
||||||
session: SessionData
|
session: SessionData
|
||||||
@@ -46,6 +54,29 @@ const FRAME_GEN_OPTIONS = [
|
|||||||
{ label: "Other", data: "other" },
|
{ 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({
|
export default function SessionForm({
|
||||||
session,
|
session,
|
||||||
error,
|
error,
|
||||||
@@ -59,6 +90,41 @@ export default function SessionForm({
|
|||||||
const [uploadStatus, setUploadStatus] = useState<"idle" | "loading" | "success" | "error">("idle")
|
const [uploadStatus, setUploadStatus] = useState<"idle" | "loading" | "success" | "error">("idle")
|
||||||
const [statusMessage, setStatusMessage] = useState("")
|
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() {
|
async function handleExport() {
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
setError("")
|
setError("")
|
||||||
@@ -88,7 +154,7 @@ export default function SessionForm({
|
|||||||
}
|
}
|
||||||
setError("")
|
setError("")
|
||||||
setUploadStatus("loading")
|
setUploadStatus("loading")
|
||||||
setStatusMessage("")
|
setStatusMessage("Uploading entry…")
|
||||||
|
|
||||||
const payload = buildImportPayload(session)
|
const payload = buildImportPayload(session)
|
||||||
const result = await uploadToDeckyvault(
|
const result = await uploadToDeckyvault(
|
||||||
@@ -97,17 +163,41 @@ export default function SessionForm({
|
|||||||
settings.baseUrl,
|
settings.baseUrl,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (result.success) {
|
if (!result.success) {
|
||||||
setUploadStatus("success")
|
|
||||||
setStatusMessage(`Uploaded! Entry ID: ${result.data?.id}`)
|
|
||||||
onAddToRecent(session)
|
|
||||||
} else {
|
|
||||||
setUploadStatus("error")
|
setUploadStatus("error")
|
||||||
setStatusMessage(result.error || "Upload failed")
|
setStatusMessage(result.error || "Upload failed")
|
||||||
if (result.status === 404) {
|
if (result.status === 404) {
|
||||||
setStatusMessage("This game isn't in DeckyVault yet. Submit it on the website first, or export to file.")
|
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 (
|
return (
|
||||||
@@ -195,6 +285,129 @@ export default function SessionForm({
|
|||||||
</PanelSectionRow>
|
</PanelSectionRow>
|
||||||
</PanelSection>
|
</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 display ──────────────────────────────────────── */}
|
||||||
{error && (
|
{error && (
|
||||||
<PanelSectionRow>
|
<PanelSectionRow>
|
||||||
|
|||||||
@@ -81,6 +81,23 @@ export const uploadToDeckyvault = callable<[
|
|||||||
status?: number
|
status?: number
|
||||||
}>("upload_to_deckyvault")
|
}>("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], {
|
export const testApiKey = callable<[apiKey: string, baseUrl?: string], {
|
||||||
valid: boolean
|
valid: boolean
|
||||||
error?: string
|
error?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user