Merge branch 'dev' into prod
This commit is contained in:
@@ -159,12 +159,12 @@ describe("Sitemap Generator (app/sitemap.ts)", () => {
|
||||
// ── sitemap({ id: 'static' }) ──────────────────────────────────────
|
||||
|
||||
describe("sitemap({ id: 'static' })", () => {
|
||||
it("returns 7 static page entries", async () => {
|
||||
it("returns 8 static page entries", async () => {
|
||||
const mod = await import("@/app/sitemap")
|
||||
const result = await mod.default({ id: Promise.resolve("static") })
|
||||
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
expect(result).toHaveLength(7)
|
||||
expect(result).toHaveLength(8)
|
||||
})
|
||||
|
||||
it("first entry is homepage with priority 1.0", async () => {
|
||||
@@ -182,6 +182,7 @@ describe("Sitemap Generator (app/sitemap.ts)", () => {
|
||||
const urls = result.map((e: { url: string }) => e.url)
|
||||
expect(urls).toContain("https://deckyvault.xyz/games")
|
||||
expect(urls).toContain("https://deckyvault.xyz/compare")
|
||||
expect(urls).toContain("https://deckyvault.xyz/plugin")
|
||||
})
|
||||
|
||||
it("includes lastModified on all entries", async () => {
|
||||
@@ -360,7 +361,7 @@ describe("Sitemap Generator (app/sitemap.ts)", () => {
|
||||
const mod = await import("@/app/sitemap")
|
||||
const result = await mod.default({ id: Promise.resolve("static") })
|
||||
|
||||
expect(result).toHaveLength(7)
|
||||
expect(result).toHaveLength(8)
|
||||
})
|
||||
|
||||
it("unknown id returns empty array", async () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
GaugeIcon,
|
||||
ChevronDownIcon,
|
||||
PencilIcon,
|
||||
X as XIcon,
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useSession } from "@/lib/auth-client"
|
||||
@@ -641,6 +642,23 @@ export function GamePageClient({
|
||||
)
|
||||
const presetsRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// DeckyVault plugin CTA dismissal (persisted across games in localStorage)
|
||||
const [showPluginCta, setShowPluginCta] = useState(false)
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
if (localStorage.getItem("dv-plugin-cta-dismissed") !== "1") {
|
||||
setShowPluginCta(true)
|
||||
}
|
||||
}, [])
|
||||
const dismissPluginCta = () => {
|
||||
setShowPluginCta(false)
|
||||
try {
|
||||
localStorage.setItem("dv-plugin-cta-dismissed", "1")
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// On mount, auto-open preset from URL
|
||||
useEffect(() => {
|
||||
const presetId = searchParams.get("preset")
|
||||
@@ -1425,6 +1443,39 @@ export function GamePageClient({
|
||||
className='px-4 md:px-[10svw]'
|
||||
>
|
||||
<div className='max-w-7xl mx-auto flex flex-col gap-4'>
|
||||
{showPluginCta && (
|
||||
<div
|
||||
data-gamepad-focusable
|
||||
className='flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 p-3 sm:pr-2 rounded-xl border border-primary/20 bg-primary/5'
|
||||
>
|
||||
<div className='flex items-center gap-3 flex-1 min-w-0'>
|
||||
<div className='shrink-0 w-9 h-9 rounded-lg bg-primary/10 border border-primary/20 flex items-center justify-center'>
|
||||
<Gamepad2Icon className='h-5 w-5 text-primary' />
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<p className='text-sm text-text leading-snug'>
|
||||
Have a Steam Deck? Record benchmarks automatically with the DeckyVault Decky plugin — one tap, no manual screenshots.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 shrink-0'>
|
||||
<Link
|
||||
href='/plugin'
|
||||
className='inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-white text-xs font-semibold hover:bg-primary/90 transition-colors'
|
||||
>
|
||||
Get the plugin
|
||||
<ExternalLinkIcon className='h-3 w-3' />
|
||||
</Link>
|
||||
<button
|
||||
onClick={dismissPluginCta}
|
||||
className='p-1.5 rounded-md text-text/40 hover:text-text hover:bg-text/5 transition-colors'
|
||||
aria-label='Dismiss'
|
||||
>
|
||||
<XIcon className='h-4 w-4' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='text-lg font-semibold'>
|
||||
Performance Entries
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html
|
||||
lang='en'
|
||||
className={`${font.variable} bg-background text-text antialiased overscroll-none`}
|
||||
className={`${font.variable} bg-background text-text antialiased overscroll-none scroll-smooth`}
|
||||
>
|
||||
<head>
|
||||
{/* DNS prefetch + preconnect for external image CDNs */}
|
||||
|
||||
@@ -61,7 +61,7 @@ const SCREENSHOTS: Shot[] = [
|
||||
title: "Record while you play",
|
||||
caption:
|
||||
"Once in-game, open the panel and hit Start Recording. A live timer tracks your session. Stop when you're done benchmarking.",
|
||||
file: "recording.png",
|
||||
file: "recording.jpg",
|
||||
photo: true,
|
||||
},
|
||||
{
|
||||
@@ -69,7 +69,7 @@ const SCREENSHOTS: Shot[] = [
|
||||
title: "Review & submit",
|
||||
caption:
|
||||
"After stopping, review the captured FPS, 1% lows, and power draw. Add notes, then upload straight to DeckyVault — or export to a file.",
|
||||
file: "session-form.png",
|
||||
file: "session-form.jpg",
|
||||
photo: true,
|
||||
},
|
||||
{
|
||||
@@ -87,8 +87,8 @@ const HAS_SCREENSHOT: Record<string, boolean> = {
|
||||
"panel-overview": true,
|
||||
"pair-qr": true,
|
||||
"launch-option": true,
|
||||
recording: false,
|
||||
"session-form": false,
|
||||
recording: true,
|
||||
"session-form": true,
|
||||
"entry-live": true,
|
||||
}
|
||||
|
||||
@@ -184,6 +184,40 @@ const STEPS = [
|
||||
},
|
||||
]
|
||||
|
||||
// ── Screenshot acts ────────────────────────────────────
|
||||
// The "See it in action" gallery is structured as a 3-act narrative so the
|
||||
// story flows: Set up → Capture → Share. Each act has a heading + a list of
|
||||
// shots. The last act's shot is rendered larger as a visual payoff.
|
||||
const ACTS: {
|
||||
number: string
|
||||
title: string
|
||||
subtitle: string
|
||||
shotIds: string[]
|
||||
layout: "grid-3" | "grid-2" | "feature"
|
||||
}[] = [
|
||||
{
|
||||
number: "1",
|
||||
title: "Get set up",
|
||||
subtitle: "Three quick steps before your first benchmark.",
|
||||
shotIds: ["panel-overview", "pair-qr", "launch-option"],
|
||||
layout: "grid-3",
|
||||
},
|
||||
{
|
||||
number: "2",
|
||||
title: "Capture performance",
|
||||
subtitle: "Hit record, play, then review your stats.",
|
||||
shotIds: ["recording", "session-form"],
|
||||
layout: "grid-2",
|
||||
},
|
||||
{
|
||||
number: "3",
|
||||
title: "Share & compare",
|
||||
subtitle: "Your entry appears on the game's page for the community.",
|
||||
shotIds: ["entry-live"],
|
||||
layout: "feature",
|
||||
},
|
||||
]
|
||||
|
||||
export function PluginPageClient() {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
@@ -365,61 +399,122 @@ export function PluginPageClient() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Screenshots gallery ──────────────────────────────── */}
|
||||
<section className="px-4 md:px-[10svw] py-12 md:py-16 border-t border-border">
|
||||
{/* ── See it in action: 3-act narrative ──────────────────────── */}
|
||||
<section className="px-4 md:px-[10svw] py-12 md:py-20 border-t border-border">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-center mb-2">
|
||||
See it in action
|
||||
</h2>
|
||||
<p className="text-text/50 text-center mb-12 max-w-xl mx-auto">
|
||||
A walkthrough of the plugin, from pairing to publishing.
|
||||
</p>
|
||||
<div className="text-center mb-12 md:mb-16">
|
||||
<h2 className="text-2xl md:text-3xl font-bold mb-2">
|
||||
See it in action
|
||||
</h2>
|
||||
<p className="text-text/50 max-w-xl mx-auto">
|
||||
A walkthrough of the plugin, from pairing to publishing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{SCREENSHOTS.map((shot, i) => (
|
||||
<motion.figure
|
||||
key={shot.id}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="rounded-xl border border-border bg-text/2 overflow-hidden flex flex-col"
|
||||
>
|
||||
<div className="relative aspect-[16/10] bg-gradient-to-br from-text/5 to-text/10 flex items-center justify-center p-3">
|
||||
{HAS_SCREENSHOT[shot.id] ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/plugin/${shot.file}`}
|
||||
alt={shot.title}
|
||||
className={shot.photo
|
||||
? "max-w-full max-h-full object-contain rounded-lg shadow-2xl shadow-black/40 ring-1 ring-black/20"
|
||||
: "w-full h-full object-contain"}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 text-text/30 p-6 text-center">
|
||||
<Gamepad2Icon className="h-10 w-10" />
|
||||
<span className="text-xs font-mono">
|
||||
/plugin/{shot.file}
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
{shot.photo ? "device photo" : "screenshot"} coming soon
|
||||
</span>
|
||||
<div className="flex flex-col gap-10 md:gap-14">
|
||||
{ACTS.map((act, actIdx) => {
|
||||
const shots = act.shotIds
|
||||
.map((id) => SCREENSHOTS.find((s) => s.id === id))
|
||||
.filter((s): s is (typeof SCREENSHOTS)[number] => Boolean(s))
|
||||
|
||||
return (
|
||||
<div key={act.number} className="flex flex-col">
|
||||
{/* Act heading */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -12 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: actIdx * 0.05 }}
|
||||
className="flex items-center gap-3 mb-5"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-primary text-white flex items-center justify-center font-bold text-sm shrink-0">
|
||||
{act.number}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-lg md:text-xl font-semibold leading-tight">
|
||||
{act.title}
|
||||
</h3>
|
||||
<p className="text-sm text-text/50">
|
||||
{act.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Shot grid for this act */}
|
||||
<div
|
||||
className={
|
||||
act.layout === "grid-3"
|
||||
? "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||
: act.layout === "grid-2"
|
||||
? "grid grid-cols-1 sm:grid-cols-2 gap-4"
|
||||
: "grid grid-cols-1 gap-4"
|
||||
}
|
||||
>
|
||||
{shots.map((shot) => {
|
||||
const isFeature = act.layout === "feature"
|
||||
return (
|
||||
<motion.figure
|
||||
key={shot.id}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="rounded-xl border border-border bg-text/2 overflow-hidden flex flex-col hover:border-primary/30 transition-colors"
|
||||
>
|
||||
<div
|
||||
className={
|
||||
isFeature
|
||||
? "relative aspect-[16/9] bg-gradient-to-br from-text/5 to-text/10 flex items-center justify-center p-4"
|
||||
: "relative aspect-[16/10] bg-gradient-to-br from-text/5 to-text/10 flex items-center justify-center p-3"
|
||||
}
|
||||
>
|
||||
{HAS_SCREENSHOT[shot.id] ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/plugin/${shot.file}`}
|
||||
alt={shot.title}
|
||||
className={shot.photo
|
||||
? "max-w-full max-h-full object-contain rounded-lg shadow-2xl shadow-black/40 ring-1 ring-black/20"
|
||||
: isFeature
|
||||
? "w-full h-full object-contain rounded-md"
|
||||
: "w-full h-full object-contain"}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 text-text/30 p-6 text-center">
|
||||
<Gamepad2Icon className="h-8 w-8 opacity-50" />
|
||||
<span className="text-xs">
|
||||
{shot.photo ? "device photo" : "screenshot"} not yet captured
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<figcaption className="p-4 flex flex-col gap-1">
|
||||
<div className="text-sm font-semibold">
|
||||
{shot.title}
|
||||
</div>
|
||||
<p className="text-xs text-text/55 leading-relaxed">
|
||||
{shot.caption}
|
||||
</p>
|
||||
</figcaption>
|
||||
</motion.figure>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Flow arrow between acts */}
|
||||
{actIdx < ACTS.length - 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: actIdx * 0.05 + 0.1 }}
|
||||
className="flex justify-center mt-8 md:mt-10"
|
||||
>
|
||||
<div className="w-px h-8 md:h-10 bg-gradient-to-b from-primary/40 to-primary/10" />
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
<figcaption className="p-4 flex flex-col gap-1">
|
||||
<div className="text-sm font-semibold flex items-center gap-2">
|
||||
<span className="w-5 h-5 rounded-full bg-primary/15 text-primary text-[10px] font-bold flex items-center justify-center">
|
||||
{i + 1}
|
||||
</span>
|
||||
{shot.title}
|
||||
</div>
|
||||
<p className="text-xs text-text/55 leading-relaxed pl-7">
|
||||
{shot.caption}
|
||||
</p>
|
||||
</figcaption>
|
||||
</motion.figure>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+113
-12
@@ -4,50 +4,147 @@ import { PluginPageClient } from "./page-client"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const CANONICAL = "https://deckyvault.xyz/plugin"
|
||||
const TITLE = "DeckyVault Plugin — Record & Upload Steam Deck Benchmarks"
|
||||
const DESCRIPTION =
|
||||
"Install the DeckyVault Decky Loader plugin to capture FPS, frame times, and power draw with MangoHud, then upload performance entries straight to DeckyVault from your Steam Deck. Free, open source, controller-friendly."
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "DeckyVault Plugin — Record & Upload Steam Deck Benchmarks",
|
||||
description:
|
||||
"Install the DeckyVault Decky Loader plugin to capture FPS, frame times, and power draw with MangoHud, then upload performance entries straight to DeckyVault from your Steam Deck.",
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
keywords: [
|
||||
"decky loader plugin",
|
||||
"deckyvault plugin",
|
||||
"steam deck plugin",
|
||||
"steam deck benchmark tool",
|
||||
"mangohud",
|
||||
"mangohud steam deck",
|
||||
"steam deck performance logging",
|
||||
"steam deck fps recorder",
|
||||
"steam deck benchmark",
|
||||
"performance logging",
|
||||
"deckyvault",
|
||||
"steam deck tdp",
|
||||
"steam deck frame times",
|
||||
"decky plugin install",
|
||||
],
|
||||
alternates: { canonical: "https://deckyvault.xyz/plugin" },
|
||||
alternates: { canonical: CANONICAL },
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
"max-video-preview": -1,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: "DeckyVault Plugin — Record & Upload Steam Deck Benchmarks",
|
||||
title: TITLE,
|
||||
description:
|
||||
"Capture FPS and power draw with MangoHud, then upload to DeckyVault straight from your Steam Deck.",
|
||||
url: "https://deckyvault.xyz/plugin",
|
||||
"Capture FPS and power draw with MangoHud, then upload to DeckyVault straight from your Steam Deck. QR-code pairing, one-tap upload, auto game detection.",
|
||||
url: CANONICAL,
|
||||
siteName: "DeckyVault",
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
images: [
|
||||
{
|
||||
url: "/opengraph-image",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "DeckyVault Plugin for Steam Deck",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "DeckyVault Plugin",
|
||||
title: "DeckyVault Plugin — Steam Deck Benchmark Recorder",
|
||||
description:
|
||||
"Record Steam Deck performance and upload to DeckyVault with the Decky Loader plugin.",
|
||||
"Record Steam Deck performance with MangoHud and upload to DeckyVault with the Decky Loader plugin. QR pairing, one-tap upload.",
|
||||
images: ["/opengraph-image"],
|
||||
},
|
||||
category: "technology",
|
||||
authors: [{ name: "DeckyVault", url: "https://deckyvault.xyz" }],
|
||||
creator: "DeckyVault",
|
||||
publisher: "DeckyVault",
|
||||
}
|
||||
|
||||
// ── Structured data ────────────────────────────────────────────────
|
||||
// SoftwareApplication describes the plugin itself; FAQPage captures the
|
||||
// install/setup steps so search engines can surface rich results.
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
name: "DeckyVault Plugin",
|
||||
applicationCategory: "UtilitiesApplication",
|
||||
operatingSystem: "SteamOS",
|
||||
softwareVersion: "1.0.0",
|
||||
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
|
||||
description:
|
||||
"A Decky Loader plugin that records Steam Deck performance metrics with MangoHud and uploads them to DeckyVault.",
|
||||
url: "https://deckyvault.xyz/plugin",
|
||||
"A Decky Loader plugin that records Steam Deck performance metrics (FPS, frame times, power draw) with MangoHud and uploads them to DeckyVault.",
|
||||
url: CANONICAL,
|
||||
downloadUrl:
|
||||
"https://github.com/AdrianBonpin/deckyvault/releases",
|
||||
author: {
|
||||
"@type": "Organization",
|
||||
name: "DeckyVault",
|
||||
url: "https://deckyvault.xyz",
|
||||
},
|
||||
featureList: [
|
||||
"Capture FPS, frame times, and 1% lows with MangoHud",
|
||||
"Measure CPU + GPU power draw with peripheral overhead",
|
||||
"QR-code pairing with your DeckyVault account",
|
||||
"One-tap upload of performance entries",
|
||||
"Automatic game and Proton version detection",
|
||||
"Controller-friendly Quick Access Menu UI",
|
||||
],
|
||||
screenshot: "https://deckyvault.xyz/plugin/panel-overview.jpg",
|
||||
}
|
||||
|
||||
const faqLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
mainEntity: [
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "How do I install the DeckyVault plugin?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Install Decky Loader on your Steam Deck, then either use 'Install Plugin from ZIP File' with the downloaded plugin ZIP, or 'Install Plugin from URL' with the direct download link from the GitHub release.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "Do I need to manually copy an API key?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "No. Open the plugin in the Quick Access Menu, tap 'Pair with Phone', and scan the QR code with your phone. Confirm on deckyvault.xyz and your account links automatically — the API key is created and saved for you.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "What does the plugin record?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "It captures FPS averages, frame times, 1% lows, and total power draw (CPU + GPU plus peripheral overhead) using MangoHud, along with the detected game, Steam App ID, and Proton version.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "Is the DeckyVault plugin free?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Yes, the plugin is free and open source.",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const breadcrumbs = buildBreadcrumbList([
|
||||
{ name: "Home", url: "https://deckyvault.xyz/" },
|
||||
{ name: "Plugin", url: "https://deckyvault.xyz/plugin" },
|
||||
{ name: "Plugin", url: CANONICAL },
|
||||
])
|
||||
|
||||
export default function PluginPage() {
|
||||
@@ -57,6 +154,10 @@ export default function PluginPage() {
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbs) }}
|
||||
|
||||
@@ -2,20 +2,36 @@ import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { performanceEntries, entryScreenshots, storageObjects } from "@/lib/db/schema"
|
||||
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 { processScreenshot, isAllowedMimeType } from "@/lib/image-processing"
|
||||
|
||||
const MAX_SCREENSHOTS_PER_ENTRY = 2
|
||||
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"] } })
|
||||
|
||||
// ── Upload screenshots ─────────────────────────────────────────
|
||||
.post(
|
||||
"/:id/screenshots",
|
||||
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) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
@@ -202,7 +218,7 @@ export const screenshotRoutes = new Elysia({ prefix: "/performance", detail: { t
|
||||
.delete(
|
||||
"/:id/screenshots/:sid",
|
||||
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) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
|
||||
@@ -79,6 +79,8 @@ export const STATIC_PAGES: StaticPageDef[] = [
|
||||
// Core browse pages
|
||||
{ urlPath: "/games", changeFrequency: "daily", priority: 0.9 },
|
||||
{ urlPath: "/devices", changeFrequency: "weekly", priority: 0.7 },
|
||||
// Plugin landing page
|
||||
{ urlPath: "/plugin", changeFrequency: "monthly", priority: 0.7 },
|
||||
{ urlPath: "/updates", changeFrequency: "weekly", priority: 0.6 },
|
||||
// Utility pages
|
||||
{ urlPath: "/compare", changeFrequency: "weekly", priority: 0.5 },
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -605,6 +605,115 @@ exec mangohud "$@"
|
||||
except Exception as e:
|
||||
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:
|
||||
"""RPC: Test if an API key is valid by calling the dedicated verify endpoint.
|
||||
Returns {valid: bool, error: str?, userName?: str, userImage?: str}."""
|
||||
|
||||
@@ -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