Merge branch 'dev' into prod

This commit is contained in:
2026-07-13 07:08:44 +08:00
2 changed files with 53 additions and 56 deletions
+6 -6
View File
@@ -151,7 +151,7 @@ export default function Navbar() {
initial={{ opacity: 0, y: "-100%" }} initial={{ opacity: 0, y: "-100%" }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: "-100%" }} exit={{ opacity: 0, y: "-100%" }}
className='w-full flex flex-row px-4 md:px-[10svw] py-2 items-center gap-4 md:gap-8 justify-between border-b border-border sticky top-0 z-50 backdrop-blur-sm bg-background/80' className='w-full flex flex-row px-4 lg:px-[10svw] py-2 items-center gap-4 lg:gap-8 justify-between border-b border-border sticky top-0 z-50 backdrop-blur-sm bg-background/80'
> >
<Link <Link
href='/' href='/'
@@ -164,7 +164,7 @@ export default function Navbar() {
loading='eager' loading='eager'
/> />
{!isLanding && ( {!isLanding && (
<motion.span className='hidden md:inline-block'> <motion.span className='hidden lg:inline-block'>
DeckyVault DeckyVault
</motion.span> </motion.span>
)} )}
@@ -233,7 +233,7 @@ export default function Navbar() {
</AnimatePresence> </AnimatePresence>
{/* Desktop Navigation Links */} {/* Desktop Navigation Links */}
<ul className='hidden md:flex flex-row items-center gap-6 shrink-0'> <ul className='hidden lg:flex flex-row items-center gap-6 shrink-0'>
{navbarRoutes.map((route) => ( {navbarRoutes.map((route) => (
<Link <Link
key={route.href} key={route.href}
@@ -352,7 +352,7 @@ export default function Navbar() {
{/* Mobile Hamburger Button */} {/* Mobile Hamburger Button */}
<button <button
onClick={() => setMobileMenuOpen(true)} onClick={() => setMobileMenuOpen(true)}
className='flex md:hidden flex-row items-center justify-center p-2 -mr-2 rounded-md hover:bg-text/5 transition-colors cursor-pointer' className='flex lg:hidden flex-row items-center justify-center p-2 -mr-2 rounded-md hover:bg-text/5 transition-colors cursor-pointer'
aria-label='Open menu' aria-label='Open menu'
> >
<MenuIcon className='h-5 w-5' /> <MenuIcon className='h-5 w-5' />
@@ -369,7 +369,7 @@ export default function Navbar() {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
className='fixed inset-0 bg-black/50 z-60 md:hidden' className='fixed inset-0 bg-black/50 z-60 lg:hidden'
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
/> />
<motion.aside <motion.aside
@@ -381,7 +381,7 @@ export default function Navbar() {
damping: 25, damping: 25,
stiffness: 250, stiffness: 250,
}} }}
className='fixed top-0 right-0 bottom-0 w-64 bg-background border-l border-border z-70 md:hidden flex flex-col' className='fixed top-0 right-0 bottom-0 w-64 bg-background border-l border-border z-70 lg:hidden flex flex-col'
> >
<div className='flex flex-row items-center justify-between px-4 py-2 border-b border-border'> <div className='flex flex-row items-center justify-between px-4 py-2 border-b border-border'>
<span className='font-bold text-lg'>Menu</span> <span className='font-bold text-lg'>Menu</span>
@@ -1,18 +1,17 @@
import { useEffect, useState, useRef } from "react" import { useEffect, useState, useRef } from "react"
import { PanelSectionRow, DropdownItem, ButtonItem, staticClasses } from "@decky/ui" import { PanelSectionRow, DialogButtonPrimary, staticClasses } from "@decky/ui"
import { Router } from "@decky/ui" import { Router } from "@decky/ui"
import { import {
fetchPluginGame, fetchPluginGame,
fetchPluginDevices,
setPluginApiBaseUrl, setPluginApiBaseUrl,
type PluginGameResponse, type PluginGameResponse,
type PluginDeviceRow,
} from "../lib/plugin-api" } from "../lib/plugin-api"
import { getHardwareInfo } from "../lib/api"
interface Props { interface Props {
appId: number appId: number
title: string title: string
hardwareSlug: string | null // detected device hardwareSlug: string | null // detected device (from settings, may be null)
baseUrl: string baseUrl: string
} }
@@ -34,10 +33,9 @@ function openExternalUrl(url: string) {
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 [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [device, setDevice] = useState<string>(hardwareSlug ?? "") // "" = all devices
const [fetchError, setFetchError] = useState<string>("") const [fetchError, setFetchError] = useState<string>("")
const [detectedSlug, setDetectedSlug] = useState<string | null>(hardwareSlug ?? null)
const reqIdRef = useRef(0) const reqIdRef = useRef(0)
useEffect(() => { useEffect(() => {
@@ -47,13 +45,23 @@ export default function LibraryAppPanel({ appId, title, hardwareSlug, baseUrl }:
async function load() { async function load() {
setLoading(true) setLoading(true)
try { try {
const d = await fetchPluginGame(appId, device || null, 3) // Detect hardware if not set in settings
let slug = hardwareSlug ?? null
if (!slug) {
try {
const hw = await getHardwareInfo()
if (hw.slug && hw.slug !== "unknown") slug = hw.slug
} catch {
// Fall back to global
}
if (!cancelled) setDetectedSlug(slug)
}
const d = await fetchPluginGame(appId, slug, 3)
if (cancelled || id !== reqIdRef.current) return if (cancelled || id !== reqIdRef.current) return
setFetchError(d.error && !d.game ? d.error : "") setFetchError(d.error && !d.game ? d.error : "")
setData(d) setData(d)
setLoading(false) setLoading(false)
const devs = await fetchPluginDevices(appId)
if (!cancelled && id === reqIdRef.current) setDevices(devs)
} catch { } catch {
if (!cancelled && id === reqIdRef.current) { if (!cancelled && id === reqIdRef.current) {
setData(null) setData(null)
@@ -64,15 +72,7 @@ export default function LibraryAppPanel({ appId, title, hardwareSlug, baseUrl }:
} }
load() load()
return () => { cancelled = true } return () => { cancelled = true }
}, [appId, device, baseUrl]) }, [appId, baseUrl]) // re-fetch only when app changes
const deviceOptions = [
{ label: "All devices", data: "" },
...(hardwareSlug ? [{ label: `Your device (${hardwareSlug})`, data: hardwareSlug }] : []),
...devices
.filter((d) => d.slug !== hardwareSlug)
.map((d) => ({ label: `${d.name} (${d.count})`, data: d.slug })),
]
const gameUrl = data?.game?.steamAppId const gameUrl = data?.game?.steamAppId
? `${baseUrl}/game/${data.game.steamAppId}` ? `${baseUrl}/game/${data.game.steamAppId}`
@@ -81,7 +81,7 @@ export default function LibraryAppPanel({ appId, title, hardwareSlug, baseUrl }:
if (loading) { if (loading) {
return ( return (
<PanelSectionRow> <PanelSectionRow>
<div className={staticClasses.Text} style={{ padding: "8px 0", fontSize: "12px", opacity: 0.5, textAlign: "center" }}> <div className={staticClasses.Text} style={{ padding: "8px 16px", fontSize: "12px", opacity: 0.5, textAlign: "center" }}>
DeckyVault loading DeckyVault loading
</div> </div>
</PanelSectionRow> </PanelSectionRow>
@@ -91,7 +91,7 @@ export default function LibraryAppPanel({ appId, title, hardwareSlug, baseUrl }:
if (fetchError) { if (fetchError) {
return ( return (
<PanelSectionRow> <PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "8px 0", opacity: 0.5, color: "#e74c3c", textAlign: "center" }}> <div className={staticClasses.Text} style={{ fontSize: "12px", padding: "8px 16px", opacity: 0.5, color: "#e74c3c", textAlign: "center" }}>
DeckyVault: {fetchError} DeckyVault: {fetchError}
</div> </div>
</PanelSectionRow> </PanelSectionRow>
@@ -101,7 +101,7 @@ export default function LibraryAppPanel({ appId, title, hardwareSlug, baseUrl }:
if (!data || !data.game) { if (!data || !data.game) {
return ( return (
<PanelSectionRow> <PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "8px 0", opacity: 0.5, textAlign: "center" }}> <div className={staticClasses.Text} style={{ fontSize: "12px", padding: "8px 16px", opacity: 0.5, textAlign: "center" }}>
Not on DeckyVault <a href={`${baseUrl}/games`}>add it</a> Not on DeckyVault <a href={`${baseUrl}/games`}>add it</a>
</div> </div>
</PanelSectionRow> </PanelSectionRow>
@@ -110,37 +110,34 @@ export default function LibraryAppPanel({ appId, title, hardwareSlug, baseUrl }:
return ( return (
<> <>
{/* Stats + device dropdown inline */} <style>{`
<PanelSectionRow> .deckyvault-lib-btn { width: auto !important; min-width: 0 !important; display: inline-block !important; }
<div className={staticClasses.Text} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px", padding: "2px 0", fontSize: "13px" }}> `}</style>
<span style={{ flex: 1, textAlign: "center" }}> <div className={staticClasses.Text} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px", padding: "4px 16px", fontSize: "13px", overflow: "hidden" }}>
{data.estFps ? ( {/* Stats */}
<span style={{ display: "inline-flex", flexWrap: "wrap", gap: "4px 8px", justifyContent: "center" }}> <span style={{ flex: "1 1 0", minWidth: 0, overflow: "hidden" }}>
<span><strong>{data.estFps.avg}</strong> <span style={{ opacity: 0.4 }}>avg</span></span> {data.estFps ? (
{data.estFps.onePct != null && <span><strong>{data.estFps.onePct}</strong> <span style={{ opacity: 0.4 }}>1% low</span></span>} <span style={{ display: "inline-flex", flexWrap: "wrap", gap: "4px 8px" }}>
{data.estFps.low != null && <span><strong>{data.estFps.low}</strong> <span style={{ opacity: 0.4 }}>min</span></span>} <span><strong>{data.estFps.avg}</strong> <span style={{ opacity: 0.4 }}>avg</span></span>
{data.estFps.high != null && <span><strong>{data.estFps.high}</strong> <span style={{ opacity: 0.4 }}>max</span></span>} {data.estFps.onePct != null && <span><strong>{data.estFps.onePct}</strong> <span style={{ opacity: 0.4 }}>1% low</span></span>}
{data.estFps.tdpAvg != null && <span><strong>{data.estFps.tdpAvg}W</strong> <span style={{ opacity: 0.4 }}>TDP</span></span>} {data.estFps.low != null && <span><strong>{data.estFps.low}</strong> <span style={{ opacity: 0.4 }}>min</span></span>}
<span style={{ opacity: 0.4 }}>({data.estFps.count})</span> {data.estFps.high != null && <span><strong>{data.estFps.high}</strong> <span style={{ opacity: 0.4 }}>max</span></span>}
</span> {data.estFps.tdpAvg != null && <span><strong>{data.estFps.tdpAvg}W</strong> <span style={{ opacity: 0.4 }}>TDP</span></span>}
) : ( <span style={{ opacity: 0.4 }}>({data.estFps.count})</span>
<span style={{ opacity: 0.4, fontSize: "12px" }}>No data yet</span> </span>
)} ) : (
<span style={{ opacity: 0.4, fontSize: "12px" }}>No data yet</span>
)}
{/* Device scope badge */}
<span style={{ fontSize: "10px", opacity: 0.4, marginLeft: "4px" }}>
{detectedSlug ? detectedSlug : <span style={{ color: "#e0a030" }}> global</span>}
</span> </span>
<DropdownItem </span>
rgOptions={deviceOptions} {/* View Details — gamepad-navigable, CSS-constrained width */}
selectedOption={device} <DialogButtonPrimary className="deckyvault-lib-btn" onClick={() => openExternalUrl(gameUrl)}>
onChange={(opt) => setDevice(opt.data as string)}
/>
</div>
</PanelSectionRow>
{/* View Details button */}
<PanelSectionRow>
<ButtonItem layout="below" onClick={() => openExternalUrl(gameUrl)}>
View Details View Details
</ButtonItem> </DialogButtonPrimary>
</PanelSectionRow> </div>
</> </>
) )
} }