chore: remove public community dashboard page
This commit is contained in:
@@ -74,6 +74,13 @@ RP_ID="localhost"
|
|||||||
# RP Name: Human-readable name shown in passkey prompts
|
# RP Name: Human-readable name shown in passkey prompts
|
||||||
RP_NAME="DeckyVault"
|
RP_NAME="DeckyVault"
|
||||||
|
|
||||||
|
# Android APK key hash for native passkey support on Android
|
||||||
|
# Generate with:
|
||||||
|
# keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android | grep 'SHA256:'
|
||||||
|
# Then convert to base64url format (remove colons, lowercase, base64url encode)
|
||||||
|
# Leave empty for web-only passkey support
|
||||||
|
ANDROID_APK_KEY_HASH=
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# EMAIL
|
# EMAIL
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
import type { Metadata } from "next"
|
|
||||||
import { db } from "@/lib/db/index"
|
|
||||||
import { sql } from "drizzle-orm"
|
|
||||||
import { DashboardClient } from "@/components/dashboard/dashboard-client"
|
|
||||||
|
|
||||||
export const revalidate = 3600
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Dashboard — DeckyVault",
|
|
||||||
description:
|
|
||||||
"Community dashboard with trending games, best new releases, and most tested games on DeckyVault.",
|
|
||||||
keywords: [
|
|
||||||
"Steam Deck dashboard",
|
|
||||||
"trending games",
|
|
||||||
"best new releases",
|
|
||||||
"game benchmarks",
|
|
||||||
"community insights",
|
|
||||||
],
|
|
||||||
alternates: { canonical: "https://deckyvault.xyz/dashboard" },
|
|
||||||
openGraph: {
|
|
||||||
title: "Dashboard — DeckyVault",
|
|
||||||
description:
|
|
||||||
"Community dashboard with trending games, best new releases, and most tested games.",
|
|
||||||
url: "https://deckyvault.xyz/dashboard",
|
|
||||||
siteName: "DeckyVault",
|
|
||||||
type: "website",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const SEVEN_DAYS_AGO = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
|
||||||
const trending = await db.execute(sql`
|
|
||||||
WITH recent_benchmarks AS (
|
|
||||||
SELECT gv.game_id, COUNT(*) AS cnt
|
|
||||||
FROM performance_entries pe
|
|
||||||
JOIN game_versions gv ON pe.version_id = gv.id
|
|
||||||
WHERE pe.is_removed = false
|
|
||||||
AND pe.created_at >= ${SEVEN_DAYS_AGO}
|
|
||||||
GROUP BY gv.game_id
|
|
||||||
),
|
|
||||||
recent_comments AS (
|
|
||||||
SELECT gc.game_id, COUNT(*) AS cnt
|
|
||||||
FROM game_comments gc
|
|
||||||
WHERE gc.is_removed = false
|
|
||||||
AND gc.created_at >= ${SEVEN_DAYS_AGO}
|
|
||||||
GROUP BY gc.game_id
|
|
||||||
),
|
|
||||||
recent_upvotes AS (
|
|
||||||
SELECT gv.game_id, SUM(pe.upvotes) AS total_upvotes
|
|
||||||
FROM performance_entries pe
|
|
||||||
JOIN game_versions gv ON pe.version_id = gv.id
|
|
||||||
WHERE pe.is_removed = false
|
|
||||||
AND pe.updated_at >= ${SEVEN_DAYS_AGO}
|
|
||||||
GROUP BY gv.game_id
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
g.id,
|
|
||||||
g.title,
|
|
||||||
g.capsule_image,
|
|
||||||
g.header_image,
|
|
||||||
g.playability_status,
|
|
||||||
COALESCE(rb.cnt, 0) AS benchmark_count,
|
|
||||||
COALESCE(rc.cnt, 0) AS comment_count,
|
|
||||||
COALESCE(ru.total_upvotes, 0) AS upvote_count,
|
|
||||||
(COALESCE(rb.cnt, 0) * 3 + COALESCE(rc.cnt, 0) * 2 + COALESCE(ru.total_upvotes, 0) * 1) AS activity_score
|
|
||||||
FROM games g
|
|
||||||
LEFT JOIN recent_benchmarks rb ON rb.game_id = g.id
|
|
||||||
LEFT JOIN recent_comments rc ON rc.game_id = g.id
|
|
||||||
LEFT JOIN recent_upvotes ru ON ru.game_id = g.id
|
|
||||||
WHERE (rb.cnt IS NOT NULL OR rc.cnt IS NOT NULL OR ru.total_upvotes IS NOT NULL)
|
|
||||||
ORDER BY activity_score DESC
|
|
||||||
LIMIT 10
|
|
||||||
`)
|
|
||||||
|
|
||||||
const bestNew = await db.execute(sql`
|
|
||||||
SELECT
|
|
||||||
g.id,
|
|
||||||
g.title,
|
|
||||||
g.capsule_image,
|
|
||||||
g.header_image,
|
|
||||||
g.release_date,
|
|
||||||
g.created_at,
|
|
||||||
g.playability_status,
|
|
||||||
AVG(pe.fps_avg) AS avg_fps,
|
|
||||||
COUNT(pe.id) AS benchmark_count
|
|
||||||
FROM games g
|
|
||||||
JOIN game_versions gv ON gv.game_id = g.id
|
|
||||||
JOIN performance_entries pe ON pe.version_id = gv.id
|
|
||||||
WHERE pe.is_removed = false
|
|
||||||
AND (g.created_at >= ${new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)}
|
|
||||||
OR g.release_date IS NOT NULL)
|
|
||||||
GROUP BY g.id, g.title, g.capsule_image, g.header_image, g.release_date, g.created_at, g.playability_status
|
|
||||||
HAVING COUNT(pe.id) >= 3
|
|
||||||
ORDER BY avg_fps DESC
|
|
||||||
LIMIT 10
|
|
||||||
`)
|
|
||||||
|
|
||||||
const mostTested = await db.execute(sql`
|
|
||||||
SELECT
|
|
||||||
g.id,
|
|
||||||
g.title,
|
|
||||||
g.capsule_image,
|
|
||||||
g.header_image,
|
|
||||||
g.playability_status,
|
|
||||||
COUNT(pe.id) AS benchmark_count
|
|
||||||
FROM games g
|
|
||||||
JOIN game_versions gv ON gv.game_id = g.id
|
|
||||||
JOIN performance_entries pe ON pe.version_id = gv.id
|
|
||||||
WHERE pe.is_removed = false
|
|
||||||
GROUP BY g.id, g.title, g.capsule_image, g.header_image, g.playability_status
|
|
||||||
ORDER BY benchmark_count DESC
|
|
||||||
LIMIT 10
|
|
||||||
`)
|
|
||||||
|
|
||||||
const mostReported = await db.execute(sql`
|
|
||||||
SELECT
|
|
||||||
g.id,
|
|
||||||
g.title,
|
|
||||||
g.capsule_image,
|
|
||||||
g.header_image,
|
|
||||||
COUNT(DISTINCT r.id) AS report_count
|
|
||||||
FROM games g
|
|
||||||
JOIN game_versions gv ON gv.game_id = g.id
|
|
||||||
JOIN performance_entries pe ON pe.version_id = gv.id
|
|
||||||
JOIN reports r ON r.entry_id = pe.id
|
|
||||||
WHERE r.status = 'open'
|
|
||||||
GROUP BY g.id, g.title, g.capsule_image, g.header_image
|
|
||||||
ORDER BY report_count DESC
|
|
||||||
LIMIT 10
|
|
||||||
`)
|
|
||||||
|
|
||||||
// Serialize rows for the client component
|
|
||||||
const serializedTrending = trending.rows.map((row) => ({
|
|
||||||
id: String(row.id),
|
|
||||||
title: String(row.title),
|
|
||||||
capsule_image: row.capsule_image ? String(row.capsule_image) : null,
|
|
||||||
header_image: row.header_image ? String(row.header_image) : null,
|
|
||||||
playability_status: row.playability_status ? String(row.playability_status) : null,
|
|
||||||
benchmark_count: Number(row.benchmark_count ?? 0),
|
|
||||||
comment_count: Number(row.comment_count ?? 0),
|
|
||||||
upvote_count: Number(row.upvote_count ?? 0),
|
|
||||||
activity_score: Number(row.activity_score ?? 0),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const serializedBestNew = bestNew.rows.map((row) => ({
|
|
||||||
id: String(row.id),
|
|
||||||
title: String(row.title),
|
|
||||||
capsule_image: row.capsule_image ? String(row.capsule_image) : null,
|
|
||||||
header_image: row.header_image ? String(row.header_image) : null,
|
|
||||||
release_date: row.release_date ? new Date(String(row.release_date)).toISOString() : null,
|
|
||||||
created_at: row.created_at ? new Date(String(row.created_at)).toISOString() : null,
|
|
||||||
playability_status: row.playability_status ? String(row.playability_status) : null,
|
|
||||||
avg_fps: row.avg_fps ? Number(row.avg_fps) : null,
|
|
||||||
benchmark_count: Number(row.benchmark_count ?? 0),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const serializedMostTested = mostTested.rows.map((row) => ({
|
|
||||||
id: String(row.id),
|
|
||||||
title: String(row.title),
|
|
||||||
capsule_image: row.capsule_image ? String(row.capsule_image) : null,
|
|
||||||
header_image: row.header_image ? String(row.header_image) : null,
|
|
||||||
playability_status: row.playability_status ? String(row.playability_status) : null,
|
|
||||||
benchmark_count: Number(row.benchmark_count ?? 0),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const serializedMostReported = mostReported.rows.map((row) => ({
|
|
||||||
id: String(row.id),
|
|
||||||
title: String(row.title),
|
|
||||||
capsule_image: row.capsule_image ? String(row.capsule_image) : null,
|
|
||||||
header_image: row.header_image ? String(row.header_image) : null,
|
|
||||||
report_count: Number(row.report_count ?? 0),
|
|
||||||
}))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardClient
|
|
||||||
trending={serializedTrending}
|
|
||||||
bestNew={serializedBestNew}
|
|
||||||
mostTested={serializedMostTested}
|
|
||||||
mostReported={serializedMostReported}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -25,7 +25,6 @@ const STATIC_ENTRIES: Array<{
|
|||||||
}> = [
|
}> = [
|
||||||
{ urlPath: "", changeFrequency: "weekly", priority: 1 },
|
{ urlPath: "", changeFrequency: "weekly", priority: 1 },
|
||||||
{ urlPath: "/games", changeFrequency: "daily", priority: 0.8 },
|
{ urlPath: "/games", changeFrequency: "daily", priority: 0.8 },
|
||||||
{ urlPath: "/dashboard", changeFrequency: "daily", priority: 0.7 },
|
|
||||||
{ urlPath: "/devices", changeFrequency: "monthly", priority: 0.6 },
|
{ urlPath: "/devices", changeFrequency: "monthly", priority: 0.6 },
|
||||||
{ urlPath: "/updates", changeFrequency: "weekly", priority: 0.5 },
|
{ urlPath: "/updates", changeFrequency: "weekly", priority: 0.5 },
|
||||||
{ urlPath: "/contact", changeFrequency: "yearly", priority: 0.3 },
|
{ urlPath: "/contact", changeFrequency: "yearly", priority: 0.3 },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1036.0",
|
"@aws-sdk/client-s3": "^3.1036.0",
|
||||||
"@better-auth/drizzle-adapter": "^1.6.9",
|
"@better-auth/drizzle-adapter": "^1.6.9",
|
||||||
|
"@better-auth/expo": "^1.6.11",
|
||||||
"@better-auth/passkey": "^1.6.9",
|
"@better-auth/passkey": "^1.6.9",
|
||||||
"@elysia/cron": "^1.4.2",
|
"@elysia/cron": "^1.4.2",
|
||||||
"@elysia/eden": "^1.4.10",
|
"@elysia/eden": "^1.4.10",
|
||||||
@@ -192,6 +193,8 @@
|
|||||||
|
|
||||||
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-Lcco5hOGrMgc4XKAkvB6x72eQm4wCcya8IevMg4wBHY9W9GVg8pu23rpRX6VsVQSO4Ux13S7lFwUWtF7/r9aKw=="],
|
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-Lcco5hOGrMgc4XKAkvB6x72eQm4wCcya8IevMg4wBHY9W9GVg8pu23rpRX6VsVQSO4Ux13S7lFwUWtF7/r9aKw=="],
|
||||||
|
|
||||||
|
"@better-auth/expo": ["@better-auth/expo@1.6.11", "", { "dependencies": { "@better-fetch/fetch": "1.1.21", "better-call": "1.3.5", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.11", "better-auth": "^1.6.11", "expo-constants": ">=17.0.0", "expo-linking": ">=7.0.0", "expo-network": ">=8.0.7", "expo-web-browser": ">=14.0.0" }, "optionalPeers": ["expo-constants", "expo-linking", "expo-network", "expo-web-browser"] }, "sha512-ahqtpj5DRF4Tu8+PZuLPkR10Q6b8AntQNsn4LPcOIp6za5IJDAsaD/go1I5qCYIRi+8YKiIXk9vh4qQM54u+hA=="],
|
||||||
|
|
||||||
"@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "kysely": "^0.28.14" }, "optionalPeers": ["kysely"] }, "sha512-gyjuuxJtZ4o9G9z9q4kqn24X2kvMSp7F+KHogYxF03SnXY/2WleAcuj57iC4wP3e9mGDbjPOrnM5K6Kr3Ktdpw=="],
|
"@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "kysely": "^0.28.14" }, "optionalPeers": ["kysely"] }, "sha512-gyjuuxJtZ4o9G9z9q4kqn24X2kvMSp7F+KHogYxF03SnXY/2WleAcuj57iC4wP3e9mGDbjPOrnM5K6Kr3Ktdpw=="],
|
||||||
|
|
||||||
"@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0" } }, "sha512-XmIG4tUnOXZ+KEcWjHUjOI9Z5donD09dC2t/AQTXifAUIqx7cySg86w0KTM09ArzAxRx1fCqO36Wkt5nULnrkQ=="],
|
"@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0" } }, "sha512-XmIG4tUnOXZ+KEcWjHUjOI9Z5donD09dC2t/AQTXifAUIqx7cySg86w0KTM09ArzAxRx1fCqO36Wkt5nULnrkQ=="],
|
||||||
@@ -1752,7 +1755,7 @@
|
|||||||
|
|
||||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||||
|
|
||||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
"zod": ["zod@4.4.1", "", {}, "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q=="],
|
||||||
|
|
||||||
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
||||||
|
|
||||||
@@ -1774,6 +1777,10 @@
|
|||||||
|
|
||||||
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"@better-auth/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
|
|
||||||
|
"@better-auth/passkey/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||||
|
|
||||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||||
@@ -1784,12 +1791,6 @@
|
|||||||
|
|
||||||
"@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
"@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||||
|
|
||||||
"@serwist/build/zod": ["zod@4.4.1", "", {}, "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q=="],
|
|
||||||
|
|
||||||
"@serwist/next/zod": ["zod@4.4.1", "", {}, "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q=="],
|
|
||||||
|
|
||||||
"@serwist/webpack-plugin/zod": ["zod@4.4.1", "", {}, "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||||
@@ -1808,6 +1809,8 @@
|
|||||||
|
|
||||||
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||||
|
|
||||||
|
"better-auth/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
|
|
||||||
"echarts/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],
|
"echarts/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],
|
||||||
|
|
||||||
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||||
@@ -1820,6 +1823,8 @@
|
|||||||
|
|
||||||
"eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"eslint-plugin-react-hooks/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
|
|
||||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
"glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
"glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { EChartWrapper, CHART_THEME } from "@/components/charts/EChartWrapper"
|
|
||||||
import type { EChartsOption } from "echarts"
|
|
||||||
|
|
||||||
interface BestRelease {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
avg_fps: number | null
|
|
||||||
benchmark_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildOption(games: BestRelease[]): EChartsOption {
|
|
||||||
const data = games.map((g) => ({
|
|
||||||
name: g.title,
|
|
||||||
value: [g.benchmark_count, Math.round((g.avg_fps ?? 0) * 10) / 10, g.benchmark_count],
|
|
||||||
itemStyle: {
|
|
||||||
color: {
|
|
||||||
type: "radial" as const,
|
|
||||||
x: 0.5,
|
|
||||||
y: 0.5,
|
|
||||||
r: 0.5,
|
|
||||||
colorStops: [
|
|
||||||
{ offset: 0, color: CHART_THEME.accent },
|
|
||||||
{ offset: 1, color: CHART_THEME.primary },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
shadowBlur: 10,
|
|
||||||
shadowColor: CHART_THEME.primary + "40",
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
return {
|
|
||||||
backgroundColor: CHART_THEME.bg,
|
|
||||||
tooltip: {
|
|
||||||
trigger: "item",
|
|
||||||
backgroundColor: "#1a1225",
|
|
||||||
borderColor: CHART_THEME.border,
|
|
||||||
textStyle: { color: CHART_THEME.text },
|
|
||||||
formatter: (params: unknown) => {
|
|
||||||
const p = params as { name: string; value: number[] }
|
|
||||||
const v = p.value
|
|
||||||
return `<div style="font-weight:600;margin-bottom:4px">${p.name}</div>
|
|
||||||
<div>Avg FPS: <b>${v[1]}</b></div>
|
|
||||||
<div>Benchmarks: <b>${v[2]}</b></div>`
|
|
||||||
},
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
left: "3%",
|
|
||||||
right: "6%",
|
|
||||||
bottom: "10%",
|
|
||||||
top: "10%",
|
|
||||||
containLabel: true,
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
name: "Benchmarks",
|
|
||||||
nameLocation: "middle",
|
|
||||||
nameGap: 24,
|
|
||||||
nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 12 },
|
|
||||||
type: "value",
|
|
||||||
splitLine: { lineStyle: { color: CHART_THEME.border, type: "dashed" } },
|
|
||||||
axisLabel: { color: CHART_THEME.textMuted, fontSize: 11 },
|
|
||||||
axisLine: { lineStyle: { color: CHART_THEME.border } },
|
|
||||||
axisTick: { show: false },
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
name: "Avg FPS",
|
|
||||||
nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 12 },
|
|
||||||
type: "value",
|
|
||||||
splitLine: { lineStyle: { color: CHART_THEME.border, type: "dashed" } },
|
|
||||||
axisLabel: { color: CHART_THEME.textMuted, fontSize: 11 },
|
|
||||||
axisLine: { lineStyle: { color: CHART_THEME.border } },
|
|
||||||
axisTick: { show: false },
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
type: "scatter",
|
|
||||||
data,
|
|
||||||
symbolSize: (val: number[]) => Math.max(12, Math.min(40, val[2] * 3)),
|
|
||||||
label: {
|
|
||||||
show: true,
|
|
||||||
formatter: (p: unknown) => (p as { name: string }).name,
|
|
||||||
position: "top",
|
|
||||||
color: CHART_THEME.text,
|
|
||||||
fontSize: 10,
|
|
||||||
overflow: "truncate",
|
|
||||||
width: 100,
|
|
||||||
},
|
|
||||||
emphasis: {
|
|
||||||
scale: 1.5,
|
|
||||||
itemStyle: {
|
|
||||||
shadowBlur: 20,
|
|
||||||
shadowColor: CHART_THEME.accent + "60",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
animationDuration: 800,
|
|
||||||
animationEasing: "cubicOut",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BestReleasesChart({ games }: { games: BestRelease[] }) {
|
|
||||||
if (!games.length) return null
|
|
||||||
return <EChartWrapper option={buildOption(games)} height={360} className="w-full" />
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { TrendingUpIcon, SparklesIcon, BarChart3Icon } from "lucide-react"
|
|
||||||
import { TrendingGamesChart } from "./trending-games-chart"
|
|
||||||
import { BestReleasesChart } from "./best-releases-chart"
|
|
||||||
import { MostTestedChart } from "./most-tested-chart"
|
|
||||||
|
|
||||||
interface TrendingGame {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
capsule_image: string | null
|
|
||||||
header_image: string | null
|
|
||||||
playability_status: string | null
|
|
||||||
benchmark_count: number
|
|
||||||
comment_count: number
|
|
||||||
upvote_count: number
|
|
||||||
activity_score: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BestRelease {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
capsule_image: string | null
|
|
||||||
header_image: string | null
|
|
||||||
release_date: string | null
|
|
||||||
created_at: string | null
|
|
||||||
playability_status: string | null
|
|
||||||
avg_fps: number | null
|
|
||||||
benchmark_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MostTestedGame {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
capsule_image: string | null
|
|
||||||
header_image: string | null
|
|
||||||
playability_status: string | null
|
|
||||||
benchmark_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MostReportedGame {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
capsule_image: string | null
|
|
||||||
header_image: string | null
|
|
||||||
report_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DashboardClientProps {
|
|
||||||
trending: TrendingGame[]
|
|
||||||
bestNew: BestRelease[]
|
|
||||||
mostTested: MostTestedGame[]
|
|
||||||
mostReported: MostReportedGame[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardClient({
|
|
||||||
trending,
|
|
||||||
bestNew,
|
|
||||||
mostTested,
|
|
||||||
mostReported,
|
|
||||||
}: DashboardClientProps) {
|
|
||||||
return (
|
|
||||||
<main className="w-full px-4 md:px-8 lg:px-12 py-6 flex flex-col gap-8">
|
|
||||||
<header>
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-text">
|
|
||||||
Community Dashboard
|
|
||||||
</h1>
|
|
||||||
<p className="text-text/60 mt-1 text-sm md:text-base">
|
|
||||||
Real-time insights from the DeckyVault community — trending games, top
|
|
||||||
performers, and activity highlights.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Trending Games */}
|
|
||||||
<section className="rounded-xl border border-border bg-text/[0.03] p-4 md:p-6 flex flex-col gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<TrendingUpIcon className="h-5 w-5 text-primary" />
|
|
||||||
<h2 className="text-lg md:text-xl font-semibold text-text">
|
|
||||||
Trending Games (7 days)
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-text/60 text-sm">
|
|
||||||
Top games by community activity: benchmarks, comments, and upvotes.
|
|
||||||
</p>
|
|
||||||
<TrendingGamesChart games={trending} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Best New Releases */}
|
|
||||||
<section className="rounded-xl border border-border bg-text/[0.03] p-4 md:p-6 flex flex-col gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<SparklesIcon className="h-5 w-5 text-accent" />
|
|
||||||
<h2 className="text-lg md:text-xl font-semibold text-text">
|
|
||||||
Best Performing New Releases
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-text/60 text-sm">
|
|
||||||
Newly added games with the highest average FPS (minimum 3 benchmarks).
|
|
||||||
</p>
|
|
||||||
<BestReleasesChart games={bestNew} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Most Tested & Most Reported */}
|
|
||||||
<section className="rounded-xl border border-border bg-text/[0.03] p-4 md:p-6 flex flex-col gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<BarChart3Icon className="h-5 w-5 text-info" />
|
|
||||||
<h2 className="text-lg md:text-xl font-semibold text-text">
|
|
||||||
Most Tested & Most Reported
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-text/60 text-sm">
|
|
||||||
Games with the most benchmarks submitted and the most open reports.
|
|
||||||
</p>
|
|
||||||
<MostTestedChart mostTested={mostTested} mostReported={mostReported} />
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { EChartWrapper, CHART_THEME } from "@/components/charts/EChartWrapper"
|
|
||||||
import type { EChartsOption } from "echarts"
|
|
||||||
|
|
||||||
interface MostTestedGame {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
benchmark_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MostReportedGame {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
report_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildOption(
|
|
||||||
tested: MostTestedGame[],
|
|
||||||
reported: MostReportedGame[],
|
|
||||||
): EChartsOption {
|
|
||||||
const testedTitles = tested.map((g) => g.title)
|
|
||||||
const testedCounts = tested.map((g) => g.benchmark_count)
|
|
||||||
const reportedTitles = reported.map((g) => g.title)
|
|
||||||
const reportedCounts = reported.map((g) => g.report_count)
|
|
||||||
|
|
||||||
return {
|
|
||||||
backgroundColor: CHART_THEME.bg,
|
|
||||||
tooltip: {
|
|
||||||
trigger: "axis",
|
|
||||||
axisPointer: { type: "shadow" },
|
|
||||||
backgroundColor: "#1a1225",
|
|
||||||
borderColor: CHART_THEME.border,
|
|
||||||
textStyle: { color: CHART_THEME.text },
|
|
||||||
},
|
|
||||||
legend: {
|
|
||||||
data: ["Most Tested", "Most Reported"],
|
|
||||||
textStyle: { color: CHART_THEME.textMuted },
|
|
||||||
bottom: 0,
|
|
||||||
},
|
|
||||||
grid: [
|
|
||||||
{
|
|
||||||
left: "3%",
|
|
||||||
right: "52%",
|
|
||||||
bottom: "14%",
|
|
||||||
top: "8%",
|
|
||||||
containLabel: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
left: "52%",
|
|
||||||
right: "3%",
|
|
||||||
bottom: "14%",
|
|
||||||
top: "8%",
|
|
||||||
containLabel: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
xAxis: [
|
|
||||||
{
|
|
||||||
type: "value",
|
|
||||||
gridIndex: 0,
|
|
||||||
splitLine: { lineStyle: { color: CHART_THEME.border, type: "dashed" } },
|
|
||||||
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisTick: { show: false },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "value",
|
|
||||||
gridIndex: 1,
|
|
||||||
splitLine: { lineStyle: { color: CHART_THEME.border, type: "dashed" } },
|
|
||||||
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisTick: { show: false },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
yAxis: [
|
|
||||||
{
|
|
||||||
type: "category",
|
|
||||||
gridIndex: 0,
|
|
||||||
data: testedTitles,
|
|
||||||
axisLabel: { color: CHART_THEME.text, fontSize: 11, width: 120, overflow: "truncate" },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisTick: { show: false },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "category",
|
|
||||||
gridIndex: 1,
|
|
||||||
data: reportedTitles,
|
|
||||||
axisLabel: { color: CHART_THEME.text, fontSize: 11, width: 120, overflow: "truncate" },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisTick: { show: false },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: "Most Tested",
|
|
||||||
type: "bar",
|
|
||||||
xAxisIndex: 0,
|
|
||||||
yAxisIndex: 0,
|
|
||||||
data: testedCounts,
|
|
||||||
barWidth: "55%",
|
|
||||||
itemStyle: {
|
|
||||||
borderRadius: [0, 4, 4, 0],
|
|
||||||
color: {
|
|
||||||
type: "linear",
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
x2: 1,
|
|
||||||
y2: 0,
|
|
||||||
colorStops: [
|
|
||||||
{ offset: 0, color: CHART_THEME.info },
|
|
||||||
{ offset: 1, color: CHART_THEME.success },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Most Reported",
|
|
||||||
type: "bar",
|
|
||||||
xAxisIndex: 1,
|
|
||||||
yAxisIndex: 1,
|
|
||||||
data: reportedCounts,
|
|
||||||
barWidth: "55%",
|
|
||||||
itemStyle: {
|
|
||||||
borderRadius: [0, 4, 4, 0],
|
|
||||||
color: {
|
|
||||||
type: "linear",
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
x2: 1,
|
|
||||||
y2: 0,
|
|
||||||
colorStops: [
|
|
||||||
{ offset: 0, color: CHART_THEME.warning },
|
|
||||||
{ offset: 1, color: CHART_THEME.accent },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
animationDuration: 800,
|
|
||||||
animationEasing: "cubicOut",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MostTestedChart({
|
|
||||||
mostTested,
|
|
||||||
mostReported,
|
|
||||||
}: {
|
|
||||||
mostTested: MostTestedGame[]
|
|
||||||
mostReported: MostReportedGame[]
|
|
||||||
}) {
|
|
||||||
if (!mostTested.length && !mostReported.length) return null
|
|
||||||
return (
|
|
||||||
<EChartWrapper
|
|
||||||
option={buildOption(mostTested, mostReported)}
|
|
||||||
height={420}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { EChartWrapper, CHART_THEME } from "@/components/charts/EChartWrapper"
|
|
||||||
import type { EChartsOption } from "echarts"
|
|
||||||
|
|
||||||
interface TrendingGame {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
activity_score: number
|
|
||||||
benchmark_count: number
|
|
||||||
comment_count: number
|
|
||||||
upvote_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildOption(games: TrendingGame[]): EChartsOption {
|
|
||||||
const sorted = [...games].reverse()
|
|
||||||
const titles = sorted.map((g) => g.title)
|
|
||||||
const scores = sorted.map((g) => g.activity_score)
|
|
||||||
const benchmarks = sorted.map((g) => g.benchmark_count)
|
|
||||||
|
|
||||||
return {
|
|
||||||
backgroundColor: CHART_THEME.bg,
|
|
||||||
tooltip: {
|
|
||||||
trigger: "axis",
|
|
||||||
axisPointer: { type: "shadow" },
|
|
||||||
backgroundColor: "#1a1225",
|
|
||||||
borderColor: CHART_THEME.border,
|
|
||||||
textStyle: { color: CHART_THEME.text },
|
|
||||||
formatter: (params: unknown) => {
|
|
||||||
const p = params as { dataIndex: number }[]
|
|
||||||
const idx = p[0].dataIndex
|
|
||||||
const g = sorted[idx]
|
|
||||||
return `<div style="font-weight:600;margin-bottom:4px">${g.title}</div>
|
|
||||||
<div>Activity Score: <b>${g.activity_score}</b></div>
|
|
||||||
<div style="font-size:12px;color:${CHART_THEME.textMuted};margin-top:4px">
|
|
||||||
Benchmarks: ${g.benchmark_count} · Comments: ${g.comment_count} · Upvotes: ${g.upvote_count}
|
|
||||||
</div>`
|
|
||||||
},
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
left: "3%",
|
|
||||||
right: "4%",
|
|
||||||
bottom: "3%",
|
|
||||||
top: "3%",
|
|
||||||
containLabel: true,
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
type: "value",
|
|
||||||
splitLine: { lineStyle: { color: CHART_THEME.border, type: "dashed" } },
|
|
||||||
axisLabel: { color: CHART_THEME.textMuted, fontSize: 11 },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisTick: { show: false },
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: "category",
|
|
||||||
data: titles,
|
|
||||||
axisLabel: { color: CHART_THEME.text, fontSize: 12, width: 160, overflow: "truncate" },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisTick: { show: false },
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: "Activity Score",
|
|
||||||
type: "bar",
|
|
||||||
data: scores,
|
|
||||||
barWidth: "60%",
|
|
||||||
itemStyle: {
|
|
||||||
borderRadius: [0, 4, 4, 0],
|
|
||||||
color: {
|
|
||||||
type: "linear",
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
x2: 1,
|
|
||||||
y2: 0,
|
|
||||||
colorStops: [
|
|
||||||
{ offset: 0, color: CHART_THEME.secondary },
|
|
||||||
{ offset: 1, color: CHART_THEME.primary },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Benchmarks",
|
|
||||||
type: "bar",
|
|
||||||
data: benchmarks,
|
|
||||||
barWidth: "60%",
|
|
||||||
barGap: "-100%",
|
|
||||||
itemStyle: {
|
|
||||||
borderRadius: [0, 4, 4, 0],
|
|
||||||
color: "transparent",
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: CHART_THEME.accent,
|
|
||||||
},
|
|
||||||
emphasis: { disabled: true },
|
|
||||||
tooltip: { show: false },
|
|
||||||
silent: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
animationDuration: 800,
|
|
||||||
animationEasing: "cubicOut",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TrendingGamesChart({ games }: { games: TrendingGame[] }) {
|
|
||||||
if (!games.length) return null
|
|
||||||
return <EChartWrapper option={buildOption(games)} height={360} className="w-full" />
|
|
||||||
}
|
|
||||||
+325
-26
@@ -7,6 +7,18 @@ interface ScrapeResult {
|
|||||||
buildId: string | null
|
buildId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrape version/build info from SteamDB for a given Steam App ID.
|
||||||
|
*
|
||||||
|
* SteamDB is a client-side rendered app — the raw HTML from a fetch() is a
|
||||||
|
* skeleton that gets populated by JavaScript. To work around this we:
|
||||||
|
* 1. Search ALL <script> tags for embedded JSON/JS objects (hydration data)
|
||||||
|
* 2. Parse table rows with flexible regex (catches server-rendered fragments)
|
||||||
|
* 3. Try JSON-LD structured data
|
||||||
|
* 4. Extract build IDs from multiple known patterns
|
||||||
|
*
|
||||||
|
* All failures are non-fatal — the submit wizard gracefully degrades.
|
||||||
|
*/
|
||||||
export async function scrapeSteamDBVersion(steamAppId: number): Promise<ScrapeResult & { cached: boolean }> {
|
export async function scrapeSteamDBVersion(steamAppId: number): Promise<ScrapeResult & { cached: boolean }> {
|
||||||
// Check cache first
|
// Check cache first
|
||||||
const cached = getCachedVersion(steamAppId)
|
const cached = getCachedVersion(steamAppId)
|
||||||
@@ -24,6 +36,9 @@ export async function scrapeSteamDBVersion(steamAppId: number): Promise<ScrapeRe
|
|||||||
return { versionString: null, buildId: null, cached: true }
|
return { versionString: null, buildId: null, cached: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Primary: fetch the main SteamDB app page ─────────────────────
|
||||||
|
let html: string | null = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${STEAMDB_APP_URL}/${steamAppId}/`, {
|
const res = await fetch(`${STEAMDB_APP_URL}/${steamAppId}/`, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -34,58 +49,342 @@ export async function scrapeSteamDBVersion(steamAppId: number): Promise<ScrapeRe
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (res.status === 429 || res.status === 403) {
|
if (res.status === 429 || res.status === 403) {
|
||||||
|
console.warn(`[steamdb] Rate-limited or blocked for app ${steamAppId} (HTTP ${res.status})`)
|
||||||
setExtendedCooldown(steamAppId)
|
setExtendedCooldown(steamAppId)
|
||||||
return { versionString: null, buildId: null, cached: true }
|
return { versionString: null, buildId: null, cached: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
console.warn(`[steamdb] Non-OK response for app ${steamAppId}: HTTP ${res.status}`)
|
||||||
setCooldown(steamAppId, 30)
|
setCooldown(steamAppId, 30)
|
||||||
return { versionString: null, buildId: null, cached: true }
|
return { versionString: null, buildId: null, cached: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = await res.text()
|
html = await res.text()
|
||||||
const result = parseSteamDBHtml(html)
|
} catch (err) {
|
||||||
|
console.warn(`[steamdb] Network error fetching app ${steamAppId}:`, err instanceof Error ? err.message : err)
|
||||||
setCachedVersion(steamAppId, result.versionString, result.buildId)
|
|
||||||
return { ...result, cached: false }
|
|
||||||
} catch {
|
|
||||||
setCooldown(steamAppId, 30)
|
setCooldown(steamAppId, 30)
|
||||||
return { versionString: null, buildId: null, cached: true }
|
return { versionString: null, buildId: null, cached: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = parseSteamDBHtml(html, steamAppId)
|
||||||
|
|
||||||
|
// ── Log diagnostic info for debugging ────────────────────────────
|
||||||
|
if (result.versionString || result.buildId) {
|
||||||
|
console.log(`[steamdb] Found data for app ${steamAppId}: version="${result.versionString ?? "?"}", build="${result.buildId ?? "?"}"`)
|
||||||
|
} else {
|
||||||
|
// Log a snippet of the HTML to help debug parsing failures
|
||||||
|
const snippet = html.slice(0, 300).replace(/\s+/g, " ").trim()
|
||||||
|
console.warn(`[steamdb] No version/build found for app ${steamAppId}. HTML preview: ${snippet}...`)
|
||||||
|
}
|
||||||
|
|
||||||
|
setCachedVersion(steamAppId, result.versionString, result.buildId)
|
||||||
|
return { ...result, cached: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSteamDBHtml(html: string): ScrapeResult {
|
// ── Parsing ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseSteamDBHtml(html: string, steamAppId: number): ScrapeResult {
|
||||||
let versionString: string | null = null
|
let versionString: string | null = null
|
||||||
let buildId: string | null = null
|
let buildId: string | null = null
|
||||||
|
|
||||||
// Strategy 1: Look for "Last known name" in meta or table rows
|
// ── Strategy 1 (PRIMARY): Embedded JSON/JS data in <script> tags ──
|
||||||
const lastKnownMatch = html.match(/Last known name[^<]*<[^>]*>([^<]+)</i)
|
// SPAs often embed initial state for hydration. Search ALL script tags
|
||||||
if (lastKnownMatch) {
|
// for JSON-like objects containing known version/build keys.
|
||||||
versionString = lastKnownMatch[1].trim()
|
const scriptMatches = html.match(/<script[^>]*>([\s\S]*?)<\/script>/gi)
|
||||||
}
|
if (scriptMatches) {
|
||||||
|
for (const scriptTag of scriptMatches) {
|
||||||
|
// Skip JSON-LD (handled separately below)
|
||||||
|
if (scriptTag.includes('application/ld+json')) continue
|
||||||
|
|
||||||
// Strategy 2: Try JSON-LD for version
|
const inner = scriptTag.replace(/<script[^>]*>/i, "").replace(/<\/script>/i, "")
|
||||||
const jsonLdMatch = html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)
|
|
||||||
if (jsonLdMatch) {
|
// Try to find JSON objects within the script content
|
||||||
try {
|
const objects = extractTopLevelObjects(inner)
|
||||||
const parsed = JSON.parse(jsonLdMatch[1])
|
for (const obj of objects) {
|
||||||
if (parsed.version) {
|
if (!versionString) versionString = findVersionInObject(obj)
|
||||||
versionString = versionString || parsed.version
|
if (!buildId) buildId = findBuildIdInObject(obj)
|
||||||
|
if (versionString && buildId) break
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// JSON-LD parse failure is non-fatal
|
if (versionString && buildId) break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Extract build ID from the page
|
// ── Strategy 2: <script type="application/json"> tags ──────────────
|
||||||
const buildMatch = html.match(/Build\s*ID[^<]*<\/td>\s*<td[^>]*>(\d+)/i)
|
if (!versionString || !buildId) {
|
||||||
|| html.match(/buildid[^>]*>(\d+)/i)
|
const jsonScripts = html.match(/<script[^>]*type="application\/json"[^>]*>([\s\S]*?)<\/script>/gi)
|
||||||
if (buildMatch) {
|
if (jsonScripts) {
|
||||||
buildId = buildMatch[1].trim()
|
for (const tag of jsonScripts) {
|
||||||
|
const inner = tag.replace(/<script[^>]*>/i, "").replace(/<\/script>/i, "")
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(inner)
|
||||||
|
if (!versionString) versionString = findVersionInObject(obj)
|
||||||
|
if (!buildId) buildId = findBuildIdInObject(obj)
|
||||||
|
if (versionString && buildId) break
|
||||||
|
} catch {
|
||||||
|
// Not valid JSON — continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Strategy 3: Table rows — more flexible patterns ──────────────
|
||||||
|
if (!versionString) {
|
||||||
|
versionString = extractVersionFromTable(html)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Strategy 4: JSON-LD structured data ──────────────────────────
|
||||||
|
if (!versionString) {
|
||||||
|
versionString = extractVersionFromJsonLD(html)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Strategy 5: Build ID from multiple patterns ──────────────────
|
||||||
|
if (!buildId) {
|
||||||
|
buildId = extractBuildIdFromHtml(html)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { versionString, buildId }
|
return { versionString, buildId }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Helpers: JSON object extraction ────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract top-level JSON-like objects from JavaScript code.
|
||||||
|
* Handles patterns like:
|
||||||
|
* window.__DATA__ = { ... }
|
||||||
|
* var appData = { ... }
|
||||||
|
* JSON.parse('{ ... }')
|
||||||
|
* __NEXT_DATA__ = { ... }
|
||||||
|
*/
|
||||||
|
function extractTopLevelObjects(js: string): Record<string, unknown>[] {
|
||||||
|
const objects: Record<string, unknown>[] = []
|
||||||
|
|
||||||
|
// Pattern 1: JSON.parse('...') or JSON.parse("{...}")
|
||||||
|
const parseRegex = /JSON\.parse\((["'])((?:\\.|(?!\1)[^\\])*?)\1\)/g
|
||||||
|
let match
|
||||||
|
while ((match = parseRegex.exec(js)) !== null) {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(match[2])
|
||||||
|
if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) {
|
||||||
|
objects.push(obj as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// JSON parse failure — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 2: Assignment of object literals
|
||||||
|
// Matches: var/let/const/window.NAME = { ... }
|
||||||
|
// We look for balanced braces after `=`
|
||||||
|
const assignRegex = /(?:var|let|const|window\.\w+|self\.\w+|this\.\w+)\s*\w*\s*=\s*(\{)/g
|
||||||
|
while ((match = assignRegex.exec(js)) !== null) {
|
||||||
|
const startIdx = match.index + match[0].length - 1 // position of opening {
|
||||||
|
const objStr = extractBalancedBraces(js, startIdx)
|
||||||
|
if (objStr) {
|
||||||
|
try {
|
||||||
|
// Try as JSON first, then as JS object
|
||||||
|
const obj = safeParseJSObject(objStr)
|
||||||
|
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
||||||
|
objects.push(obj as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Parse failure — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return objects
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract text between balanced { } braces */
|
||||||
|
function extractBalancedBraces(str: string, startIdx: number): string | null {
|
||||||
|
if (str[startIdx] !== "{") return null
|
||||||
|
let depth = 0
|
||||||
|
let inString = false
|
||||||
|
let stringChar = ""
|
||||||
|
for (let i = startIdx; i < str.length; i++) {
|
||||||
|
const ch = str[i]
|
||||||
|
if (inString) {
|
||||||
|
if (ch === "\\") { i++; continue }
|
||||||
|
if (ch === stringChar) { inString = false }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (ch === '"' || ch === "'") { inString = true; stringChar = ch; continue }
|
||||||
|
if (ch === "{") { depth++ }
|
||||||
|
else if (ch === "}") {
|
||||||
|
depth--
|
||||||
|
if (depth === 0) return str.slice(startIdx, i + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a JS object literal string to a plain object (handles unquoted keys) */
|
||||||
|
function safeParseJSObject(jsObj: string): unknown {
|
||||||
|
// First try direct JSON.parse
|
||||||
|
try { return JSON.parse(jsObj) } catch { /* fall through */ }
|
||||||
|
|
||||||
|
// Try converting JS object to valid JSON (quote unquoted keys)
|
||||||
|
try {
|
||||||
|
const jsonLike = jsObj
|
||||||
|
.replace(/([{,]\s*)([a-zA-Z_$][\w$]*)\s*:/g, '$1"$2":') // quote keys
|
||||||
|
.replace(/'/g, '"') // single → double quotes
|
||||||
|
.replace(/,\s*}/g, "}") // trailing commas
|
||||||
|
.replace(/,\s*]/g, "]")
|
||||||
|
return JSON.parse(jsonLike)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers: value extraction from objects ─────────────────────────
|
||||||
|
|
||||||
|
const VERSION_KEYS = [
|
||||||
|
"version", "versionString", "version_string", "displayVersion",
|
||||||
|
"latestVersion", "appVersion", "gameVersion", "name",
|
||||||
|
"lastKnownName", "last_known_name", "Last Known Name",
|
||||||
|
]
|
||||||
|
|
||||||
|
const BUILD_KEYS = [
|
||||||
|
"buildid", "buildId", "build_id", "build", "latestBuild",
|
||||||
|
"appBuild", "publicBuild", "buildNumber", "build_number",
|
||||||
|
]
|
||||||
|
|
||||||
|
function findVersionInObject(obj: Record<string, unknown>, depth = 0): string | null {
|
||||||
|
if (depth > 3 || !obj) return null
|
||||||
|
|
||||||
|
for (const key of VERSION_KEYS) {
|
||||||
|
const val = obj[key]
|
||||||
|
if (typeof val === "string" && val.length > 0 && val.length < 200) {
|
||||||
|
// Filter out non-version strings (URLs, descriptions, etc.)
|
||||||
|
if (!val.startsWith("http") && !val.includes("<") && val.length > 1) {
|
||||||
|
return val.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse into nested objects
|
||||||
|
for (const val of Object.values(obj)) {
|
||||||
|
if (val && typeof val === "object" && !Array.isArray(val)) {
|
||||||
|
const found = findVersionInObject(val as Record<string, unknown>, depth + 1)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBuildIdInObject(obj: Record<string, unknown>, depth = 0): string | null {
|
||||||
|
if (depth > 3 || !obj) return null
|
||||||
|
|
||||||
|
for (const key of BUILD_KEYS) {
|
||||||
|
const val = obj[key]
|
||||||
|
if (typeof val === "number" && val > 0 && val < 99999999) {
|
||||||
|
return String(val)
|
||||||
|
}
|
||||||
|
if (typeof val === "string" && /^\d{3,10}$/.test(val)) {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse into nested objects
|
||||||
|
for (const val of Object.values(obj)) {
|
||||||
|
if (val && typeof val === "object" && !Array.isArray(val)) {
|
||||||
|
const found = findBuildIdInObject(val as Record<string, unknown>, depth + 1)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers: table-based extraction ────────────────────────────────
|
||||||
|
|
||||||
|
function extractVersionFromTable(html: string): string | null {
|
||||||
|
const patterns = [
|
||||||
|
// "Last known name" / "Last recorded name" in a table
|
||||||
|
/(?:Last\s*(?:known|recorded)\s*name)\s*<\/t[hd]>\s*<t[hd][^>]*>([^<]+)</i,
|
||||||
|
// "Version" label in a definition list or table
|
||||||
|
/<t[hd][^>]*>\s*Version\s*<\/t[hd]>\s*<t[hd][^>]*>([^<]+)</i,
|
||||||
|
// "Current version" label
|
||||||
|
/(?:Current|Latest)\s+version[^<]*<\/t[hd]>\s*<t[hd][^>]*>([^<]+)</i,
|
||||||
|
// Generic: any table row with "version" as label
|
||||||
|
/<td[^>]*>([^<]*[Vv]ersion[^<]*)<\/td>\s*<td[^>]*>([^<]+)</i,
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = html.match(pattern)
|
||||||
|
if (match) {
|
||||||
|
const val = (match[2] || match[1]).trim()
|
||||||
|
if (val && val.length > 1 && !val.startsWith("http") && !val.includes("<")) {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broader: look for "Last known name" anywhere nearby a <td> with content
|
||||||
|
const looseMatch = html.match(/Last known name[^<]*(?:<[^>]+>)*?\s*<t[hd][^>]*>([^<]+)</i)
|
||||||
|
if (looseMatch) {
|
||||||
|
const val = looseMatch[1].trim()
|
||||||
|
if (val.length > 1 && !val.startsWith("http")) return val
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers: JSON-LD extraction ────────────────────────────────────
|
||||||
|
|
||||||
|
function extractVersionFromJsonLD(html: string): string | null {
|
||||||
|
const ldMatches = html.match(/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi)
|
||||||
|
if (!ldMatches) return null
|
||||||
|
|
||||||
|
for (const tag of ldMatches) {
|
||||||
|
const inner = tag.replace(/<script[^>]*>/i, "").replace(/<\/script>/i, "")
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(inner)
|
||||||
|
// Walk the JSON-LD graph
|
||||||
|
const version = findVersionInObject(parsed)
|
||||||
|
if (version) return version
|
||||||
|
} catch {
|
||||||
|
// Non-fatal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers: build ID extraction ───────────────────────────────────
|
||||||
|
|
||||||
|
function extractBuildIdFromHtml(html: string): string | null {
|
||||||
|
const patterns = [
|
||||||
|
// Table: Build ID cell
|
||||||
|
/Build\s*ID\s*<\/t[hd]>\s*<t[hd][^>]*>(\d{3,10})/i,
|
||||||
|
// Inline buildid attribute
|
||||||
|
/buildid[^>]*>(\d{3,10})/i,
|
||||||
|
// "Build" label in table
|
||||||
|
/<t[hd][^>]*>\s*Build\s*<\/t[hd]>\s*<t[hd][^>]*>(\d{3,10})/i,
|
||||||
|
// data-build-id attribute
|
||||||
|
/data-build-?id\s*=\s*["'](\d{3,10})["']/i,
|
||||||
|
// "public" branch build in a table
|
||||||
|
/public\s*<\/t[hd]>\s*<t[hd][^>]*>(\d{3,10})/i,
|
||||||
|
// Generic: any bare build ID near "build" text
|
||||||
|
/build[^<]*<\/t[hd]>\s*<t[hd][^>]*>(\d{3,10})/i,
|
||||||
|
// Numeric build ID in JSON-like context
|
||||||
|
/"buildid"\s*:\s*(\d{3,10})/i,
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = html.match(pattern)
|
||||||
|
if (match) {
|
||||||
|
const val = match[1].trim()
|
||||||
|
const num = parseInt(val, 10)
|
||||||
|
if (num > 0 && num < 99999999) return val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// Export for testing
|
// Export for testing
|
||||||
export const _parseSteamDBHtml = parseSteamDBHtml
|
export const _parseSteamDBHtml = parseSteamDBHtml
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"applinks": {},
|
||||||
|
"webcredentials": {
|
||||||
|
"apps": ["YOUR_TEAM_ID.xyz.deckyvault.app"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"relation": [
|
||||||
|
"delegate_permission/common.handle_all_urls",
|
||||||
|
"delegate_permission/common.get_login_creds"
|
||||||
|
],
|
||||||
|
"target": {
|
||||||
|
"namespace": "android_app",
|
||||||
|
"package_name": "xyz.deckyvault.app",
|
||||||
|
"sha256_cert_fingerprints": [
|
||||||
|
"YOUR_SHA256_FINGERPRINT"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user