Merge branch 'dev' into prod

This commit is contained in:
2026-07-13 06:20:38 +08:00
3 changed files with 62 additions and 131 deletions
+6 -2
View File
@@ -39,7 +39,7 @@ export type PluginEntryRow = {
export type PluginGameResponse = { export type PluginGameResponse = {
game: { id: string; steamAppId: number | null; title: string; slug: string | null } | null game: { id: string; steamAppId: number | null; title: string; slug: string | null } | null
estFps: { avg: number; low: number | null; onePct: number | null; high: number | null; count: number } | null estFps: { avg: number; low: number | null; onePct: number | null; high: number | null; count: number; tdpAvg: number | null } | null
topEntries: ReturnType<typeof trimEntry>[] topEntries: ReturnType<typeof trimEntry>[]
recentEntries: ReturnType<typeof trimEntry>[] recentEntries: ReturnType<typeof trimEntry>[]
error?: string error?: string
@@ -85,6 +85,9 @@ export async function buildPluginGameResponse(args: {
onePct: entries.reduce<number | null>((m, e) => (m == null ? e.fpsOnePercentLow : Math.min(m, e.fpsOnePercentLow ?? m)), null), onePct: entries.reduce<number | null>((m, e) => (m == null ? e.fpsOnePercentLow : Math.min(m, e.fpsOnePercentLow ?? m)), null),
high: entries.reduce<number | null>((m, e) => (m == null ? e.fpsHigh : Math.max(m, e.fpsHigh ?? m)), null), high: entries.reduce<number | null>((m, e) => (m == null ? e.fpsHigh : Math.max(m, e.fpsHigh ?? m)), null),
count: entries.length, count: entries.length,
tdpAvg: entries.length > 0
? Math.round((entries.filter((e) => e.tdpWatts != null).reduce((a, b) => a + (b.tdpWatts ?? 0), 0) / Math.max(1, entries.filter((e) => e.tdpWatts != null).length)) * 10) / 10
: null,
} : null) } : null)
if (!estFps) { if (!estFps) {
return { game: { ...args.game }, estFps: null, topEntries: [], recentEntries: [] } return { game: { ...args.game }, estFps: null, topEntries: [], recentEntries: [] }
@@ -215,6 +218,7 @@ export const pluginPublicRoutes = new Elysia({
onePct: sql<number | null>`min(${performanceEntries.fpsOnePercentLow})`, onePct: sql<number | null>`min(${performanceEntries.fpsOnePercentLow})`,
high: sql<number | null>`max(${performanceEntries.fpsHigh})`, high: sql<number | null>`max(${performanceEntries.fpsHigh})`,
count: sql<number>`count(*)::int`, count: sql<number>`count(*)::int`,
tdpAvg: sql<number | null>`round(avg(${performanceEntries.tdpWatts})::numeric, 1)`,
}) })
.from(performanceEntries) .from(performanceEntries)
.where(baseWhere) .where(baseWhere)
@@ -225,7 +229,7 @@ export const pluginPublicRoutes = new Elysia({
game, game,
entries: topRows as unknown as PluginEntryRow[], entries: topRows as unknown as PluginEntryRow[],
recent: recentRows as unknown as PluginEntryRow[], recent: recentRows as unknown as PluginEntryRow[],
estFps: agg.avg ? { avg: agg.avg, low: agg.low, onePct: agg.onePct, high: agg.high, count: agg.count } : null, estFps: agg.avg ? { avg: agg.avg, low: agg.low, onePct: agg.onePct, high: agg.high, count: agg.count, tdpAvg: agg.tdpAvg ?? null } : null,
}) })
}, },
{ {
@@ -1,13 +1,12 @@
import { useEffect, useState, useRef } from "react" import { useEffect, useState, useRef } from "react"
import { PanelSection, PanelSectionRow, DropdownItem, staticClasses } from "@decky/ui" import { PanelSectionRow, DropdownItem, ButtonItem, staticClasses } from "@decky/ui"
import { FaCheck, FaTimes, FaChartLine } from "react-icons/fa" import { Router } from "@decky/ui"
import { import {
fetchPluginGame, fetchPluginGame,
fetchPluginDevices, fetchPluginDevices,
setPluginApiBaseUrl, setPluginApiBaseUrl,
type PluginGameResponse, type PluginGameResponse,
type PluginDeviceRow, type PluginDeviceRow,
type PluginEntry,
} from "../lib/plugin-api" } from "../lib/plugin-api"
interface Props { interface Props {
@@ -17,40 +16,6 @@ interface Props {
baseUrl: string baseUrl: string
} }
function EntryCard({ e }: { e: PluginEntry }) {
const [expanded, setExpanded] = useState(false)
const settingsCount = Array.isArray(e.settingsJson)
? (e.settingsJson as Array<{ settings: unknown[] }>).reduce((s, c) => s + (c.settings?.length ?? 0), 0)
: 0
const label = e.isPinned ? "Pinned" : e.upvotes > 0 ? `${e.upvotes}👍` : "Recent"
return (
<PanelSectionRow>
<div
onClick={() => setExpanded((v) => !v)}
style={{ padding: "8px 10px", borderRadius: "8px", background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.10)", cursor: "pointer" }}
>
<div className={staticClasses.Text} style={{ display: "flex", justifyContent: "space-between", fontSize: "13px" }}>
<strong>{e.fpsAvg} FPS avg</strong>
<span style={{ opacity: 0.7 }}>{label}</span>
</div>
<div className={staticClasses.Text} style={{ fontSize: "11px", opacity: 0.6, marginTop: 2 }}>
{e.fpsLow ?? "—"} low · {e.fpsOnePercentLow ?? "—"} 1% · {e.fpsHigh ?? "—"} high
{e.tdpWatts ? ` · ${e.tdpWatts}W` : ""}
{e.upscalerType && e.upscalerType !== "none" ? ` · ${e.upscalerType}` : ""}
{e.protonVersion ? ` · Proton ${e.protonVersion}` : ""}
</div>
{expanded && (
<div className={staticClasses.Text} style={{ fontSize: "11px", opacity: 0.7, marginTop: 6, borderTop: "1px solid rgba(255,255,255,0.08)", paddingTop: 6 }}>
<div>By {e.userName ?? "unknown"} · {new Date(e.createdAt).toLocaleDateString()}</div>
<div>{settingsCount} settings</div>
{e.osVersion && <div>OS: {e.osVersion}</div>}
</div>
)}
</div>
</PanelSectionRow>
)
}
export default function LibraryAppPanel({ appId, title, hardwareSlug, baseUrl }: Props) { export default function LibraryAppPanel({ appId, title, hardwareSlug, baseUrl }: Props) {
const [data, setData] = useState<PluginGameResponse | null>(null) const [data, setData] = useState<PluginGameResponse | null>(null)
const [devices, setDevices] = useState<PluginDeviceRow[]>([]) const [devices, setDevices] = useState<PluginDeviceRow[]>([])
@@ -68,16 +33,12 @@ export default function LibraryAppPanel({ appId, title, hardwareSlug, baseUrl }:
try { try {
const d = await fetchPluginGame(appId, device || null, 3) const d = await fetchPluginGame(appId, device || null, 3)
if (cancelled || id !== reqIdRef.current) return if (cancelled || id !== reqIdRef.current) return
if (d.error && !d.game) { setFetchError(d.error && !d.game ? d.error : "")
setFetchError(d.error)
} else {
setFetchError("")
}
setData(d) setData(d)
setLoading(false) setLoading(false)
const devs = await fetchPluginDevices(appId) const devs = await fetchPluginDevices(appId)
if (!cancelled && id === reqIdRef.current) setDevices(devs) if (!cancelled && id === reqIdRef.current) setDevices(devs)
} catch (e) { } catch {
if (!cancelled && id === reqIdRef.current) { if (!cancelled && id === reqIdRef.current) {
setData(null) setData(null)
setFetchError("Request failed") setFetchError("Request failed")
@@ -89,16 +50,6 @@ export default function LibraryAppPanel({ appId, title, hardwareSlug, baseUrl }:
return () => { cancelled = true } return () => { cancelled = true }
}, [appId, device, baseUrl]) }, [appId, device, baseUrl])
if (loading) {
return (
<PanelSection title="DeckyVault">
<PanelSectionRow>
<div className={staticClasses.Text} style={{ padding: "8px 0", fontSize: "12px", opacity: 0.6 }}>Loading DeckyVault</div>
</PanelSectionRow>
</PanelSection>
)
}
const deviceOptions = [ const deviceOptions = [
{ label: "All devices", data: "" }, { label: "All devices", data: "" },
...(hardwareSlug ? [{ label: `Your device (${hardwareSlug})`, data: hardwareSlug }] : []), ...(hardwareSlug ? [{ label: `Your device (${hardwareSlug})`, data: hardwareSlug }] : []),
@@ -107,103 +58,79 @@ export default function LibraryAppPanel({ appId, title, hardwareSlug, baseUrl }:
.map((d) => ({ label: `${d.name} (${d.count})`, data: d.slug })), .map((d) => ({ label: `${d.name} (${d.count})`, data: d.slug })),
] ]
const gameUrl = data?.game?.slug
? `${baseUrl}/games/${data.game.slug}`
: `${baseUrl}/games`
if (loading) {
return (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ padding: "8px 0", fontSize: "12px", opacity: 0.5 }}>
DeckyVault loading
</div>
</PanelSectionRow>
)
}
if (fetchError) { if (fetchError) {
return ( return (
<PanelSection title="DeckyVault"> <PanelSectionRow>
<PanelSectionRow> <div className={staticClasses.Text} style={{ fontSize: "12px", padding: "8px 0", opacity: 0.5, color: "#e74c3c" }}>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "6px 0", opacity: 0.7, color: "#e74c3c" }}> DeckyVault: {fetchError}
<FaTimes /> Could not load DeckyVault data: {fetchError} </div>
</div> </PanelSectionRow>
</PanelSectionRow>
</PanelSection>
) )
} }
if (!data || !data.game) { if (!data || !data.game) {
return ( return (
<PanelSection title="DeckyVault"> <PanelSectionRow>
<PanelSectionRow> <div className={staticClasses.Text} style={{ fontSize: "12px", padding: "8px 0", opacity: 0.5 }}>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "6px 0", opacity: 0.7 }}> Not on DeckyVault <a href={`${baseUrl}/games`}>add it</a>
<FaTimes /> Not in DeckyVault yet. Open <strong>{title}</strong> on{" "} </div>
<a href={`${baseUrl}/games`}>deckyvault.xyz</a> to add it. </PanelSectionRow>
</div>
</PanelSectionRow>
</PanelSection>
) )
} }
return ( return (
<PanelSection title="DeckyVault"> <>
{/* Quick stats row */}
<PanelSectionRow> <PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "4px 0", display: "flex", alignItems: "center", gap: 6 }}> <div className={staticClasses.Text} style={{ padding: "4px 0", fontSize: "13px" }}>
<FaCheck style={{ color: "#2ecc71" }} /> In DeckyVault {device && data.estFps ? (
<span style={{ display: "flex", flexWrap: "wrap", gap: "4px 12px" }}>
<span><strong>{data.estFps.avg}</strong> <span style={{ opacity: 0.4 }}>avg FPS</span></span>
{data.estFps.onePct != null && <span><strong>{data.estFps.onePct}</strong> <span style={{ opacity: 0.4 }}>1% low</span></span>}
{data.estFps.low != null && <span><strong>{data.estFps.low}</strong> <span style={{ opacity: 0.4 }}>min</span></span>}
{data.estFps.high != null && <span><strong>{data.estFps.high}</strong> <span style={{ opacity: 0.4 }}>max</span></span>}
{data.estFps.tdpAvg != null && <span><strong>{data.estFps.tdpAvg}W</strong> <span style={{ opacity: 0.4 }}>TDP</span></span>}
<span><strong>{data.estFps.count}</strong> <span style={{ opacity: 0.4 }}>entries</span></span>
</span>
) : (
<span style={{ opacity: 0.5, fontSize: "12px" }}>
{device
? "No entries for this device yet"
: "Select a device to see estimated FPS"}
</span>
)}
</div> </div>
</PanelSectionRow> </PanelSectionRow>
{/* Est FPS — hidden when "All devices" selected to avoid mixing hardware */} {/* Device dropdown */}
{device ? (
<>
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "13px", padding: "4px 0", display: "flex", alignItems: "center", gap: 6 }}>
<FaChartLine /> Est FPS
</div>
</PanelSectionRow>
{data.estFps ? (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "13px", padding: "0 0 6px 0" }}>
<strong>{data.estFps.avg}</strong> avg · {data.estFps.low ?? "—"} low · {data.estFps.onePct ?? "—"} 1% · {data.estFps.high ?? "—"} high
<span style={{ opacity: 0.5, fontSize: "11px" }}> · {data.estFps.count} entries</span>
</div>
</PanelSectionRow>
) : (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", opacity: 0.6, padding: "0 0 6px 0" }}>
No entries for this device yet be the first: open the DeckyVault plugin and record.
</div>
</PanelSectionRow>
)}
</>
) : (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", opacity: 0.6, padding: "4px 0" }}>
Select a device to see estimated FPS.
</div>
</PanelSectionRow>
)}
{/* Device switcher */}
<PanelSectionRow> <PanelSectionRow>
<DropdownItem <DropdownItem
label="Device"
rgOptions={deviceOptions} rgOptions={deviceOptions}
selectedOption={device} selectedOption={device}
onChange={(opt) => setDevice(opt.data as string)} onChange={(opt) => setDevice(opt.data as string)}
/> />
</PanelSectionRow> </PanelSectionRow>
{/* Top entries */} {/* Open on DeckyVault button */}
{data.topEntries.length > 0 && ( <PanelSectionRow>
<> <ButtonItem layout="below" onClick={() => Router.NavigateToExternalWeb(gameUrl)}>
<PanelSectionRow> View on DeckyVault
<div className={staticClasses.Text} style={{ fontSize: "11px", opacity: 0.5, padding: "8px 0 2px 0", textTransform: "uppercase", letterSpacing: "0.05em" }}> </ButtonItem>
Top entries </PanelSectionRow>
</div> </>
</PanelSectionRow>
{data.topEntries.map((e) => <EntryCard key={e.id} e={e} />)}
</>
)}
{/* Recent entries */}
{data.recentEntries.length > 0 && (
<>
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "11px", opacity: 0.5, padding: "8px 0 2px 0", textTransform: "uppercase", letterSpacing: "0.05em" }}>
Recent entries
</div>
</PanelSectionRow>
{data.recentEntries.map((e) => <EntryCard key={e.id} e={e} />)}
</>
)}
</PanelSection>
) )
} }
+1 -1
View File
@@ -22,7 +22,7 @@ export interface PluginEntry {
export interface PluginGameResponse { export interface PluginGameResponse {
game: { id: string; steamAppId: number | null; title: string; slug: string | null } | null game: { id: string; steamAppId: number | null; title: string; slug: string | null } | null
estFps: { avg: number; low: number | null; onePct: number | null; high: number | null; count: number } | null estFps: { avg: number; low: number | null; onePct: number | null; high: number | null; count: number; tdpAvg: number | null } | null
topEntries: PluginEntry[] topEntries: PluginEntry[]
recentEntries: PluginEntry[] recentEntries: PluginEntry[]
error?: string error?: string