Merge branch 'dev' into prod
This commit is contained in:
@@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user