diff --git a/app/api/auth/mobile-callback/route.ts b/app/api/auth/mobile-callback/route.ts new file mode 100644 index 0000000..db31900 --- /dev/null +++ b/app/api/auth/mobile-callback/route.ts @@ -0,0 +1,35 @@ +import { auth } from "@/lib/auth" +import { NextRequest } from "next/server" + +/** + * Mobile OAuth bridge page. + * + * After a social OAuth flow completes in the mobile WebBrowser, + * Better Auth redirects here with the session cookie set. + * We extract the session token and redirect to the app's + * deep link scheme so the mobile app can store the token. + */ +export async function GET(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }) + + if (!session) { + // No session found — redirect to app with error + const errorUrl = new URL("deckyvault://auth/callback") + errorUrl.searchParams.set("error", "session_not_found") + return Response.redirect(errorUrl.toString()) + } + + const token = session.session.token + const callbackUrl = new URL("deckyvault://auth/callback") + callbackUrl.searchParams.set("token", token) + + return Response.redirect(callbackUrl.toString()) + } catch { + const errorUrl = new URL("deckyvault://auth/callback") + errorUrl.searchParams.set("error", "callback_error") + return Response.redirect(errorUrl.toString()) + } +} diff --git a/lib/api/app.ts b/lib/api/app.ts index 920ab90..2f7892d 100644 --- a/lib/api/app.ts +++ b/lib/api/app.ts @@ -29,6 +29,7 @@ import { steamdbVersionRoutes } from "@/lib/api/steamdb-version" import { searchUnifiedRoutes } from "@/lib/api/search-unified" import { gameStubRoutes } from "@/lib/api/game-stub" import { gameStatsRoutes } from "@/lib/api/game-stats" +import { gamesPerformanceRoutes } from "@/lib/api/games-performance" import { gamesManualRoutes } from "@/lib/api/games-manual" import { compareRoutes } from "@/lib/api/compare" import { playabilityRoutes } from "@/lib/api/playability" @@ -66,6 +67,7 @@ export const app = new Elysia({ prefix: "/api" }) .use( openapi({ path: "/openapi", + embedSpec: true, documentation: { info: { title: "DeckyVault API", @@ -164,6 +166,7 @@ export const app = new Elysia({ prefix: "/api" }) .use(hardwareStatsRoutes) .use(performanceRoutes) .use(gameStatsRoutes) + .use(gamesPerformanceRoutes) .use(dashboardRoutes) .use(dashboardPublicRoutes) .use(playabilityRoutes) diff --git a/lib/api/games-performance.ts b/lib/api/games-performance.ts new file mode 100644 index 0000000..972daa3 --- /dev/null +++ b/lib/api/games-performance.ts @@ -0,0 +1,188 @@ +import { Elysia, t } from "elysia" +import { db } from "@/lib/db/index" +import { + gameVersions, + performanceEntries, + hardware, + user, + gamePlatformSupport, + entryScreenshots, +} from "@/lib/db/schema" +import { and, desc, eq, sql } from "drizzle-orm" +import { getR2PublicUrl } from "@/lib/storage" + +/** + * GET /api/games/:gameId/performance + * + * Returns all non-removed performance entries (presets) for a game, + * with joined user, hardware, platform support, and screenshot data. + * Ordered by isPinned desc, then upvotes desc. + */ +export const gamesPerformanceRoutes = new Elysia({ + prefix: "/games", + detail: { tags: ["Games"] }, +}).get( + "/:gameId/performance", + async ({ params, set }) => { + const { gameId } = params + + const presetRows = await db + .select({ + id: performanceEntries.id, + hardwareSlug: performanceEntries.hardwareSlug, + hardwareName: hardware.name, + upvotes: performanceEntries.upvotes, + settingsJson: performanceEntries.settingsJson, + fpsAvg: performanceEntries.fpsAvg, + fpsLow: performanceEntries.fpsLow, + fpsHigh: performanceEntries.fpsHigh, + fpsOnePercentLow: performanceEntries.fpsOnePercentLow, + upscalerType: performanceEntries.upscalerType, + upscalerVersion: performanceEntries.upscalerVersion, + frameGenMethod: performanceEntries.frameGenMethod, + protonVersion: performanceEntries.protonVersion, + osVersion: performanceEntries.osVersion, + createdAt: performanceEntries.createdAt, + userId: performanceEntries.userId, + userName: user.name, + userImage: user.image, + downvotes: performanceEntries.downvotes, + launchOptions: performanceEntries.launchOptions, + loadTimeSsd: performanceEntries.loadTimeSsd, + loadTimeSd: performanceEntries.loadTimeSd, + tdpWatts: performanceEntries.tdpWatts, + youtubeVideoId: performanceEntries.youtubeVideoId, + customSystem: performanceEntries.customSystem, + userNotes: performanceEntries.userNotes, + versionString: gameVersions.versionString, + buildId: gameVersions.buildId, + gameAntiCheatName: gamePlatformSupport.antiCheatName, + gameAntiCheatStatus: gamePlatformSupport.antiCheatStatus, + verifiedAt: performanceEntries.verifiedAt, + isPinned: performanceEntries.isPinned, + pinnedAt: performanceEntries.pinnedAt, + }) + .from(performanceEntries) + .innerJoin( + gameVersions, + eq(performanceEntries.versionId, gameVersions.id), + ) + .innerJoin( + hardware, + eq(performanceEntries.hardwareSlug, hardware.slug), + ) + .innerJoin(user, eq(performanceEntries.userId, user.id)) + .innerJoin( + gamePlatformSupport, + and( + eq(gamePlatformSupport.gameId, gameVersions.gameId), + eq( + gamePlatformSupport.hardwareSlug, + performanceEntries.hardwareSlug, + ), + ), + ) + .where( + and( + eq(gameVersions.gameId, gameId), + eq(performanceEntries.isRemoved, false), + sql`${performanceEntries.settingsJson} IS NOT NULL`, + ), + ) + .orderBy( + desc(performanceEntries.isPinned), + desc(performanceEntries.upvotes), + ) + + if (presetRows.length === 0) { + return [] + } + + const publicUrl = getR2PublicUrl() + + // Fetch screenshots and hardware details per preset + const presets = await Promise.all( + presetRows.map(async (p) => { + const screenshots = await db + .select({ + id: entryScreenshots.id, + storageKey: entryScreenshots.storageKey, + orderIndex: entryScreenshots.orderIndex, + width: entryScreenshots.width, + height: entryScreenshots.height, + }) + .from(entryScreenshots) + .where(eq(entryScreenshots.entryId, p.id)) + .orderBy(entryScreenshots.orderIndex) + + const [hw] = await db + .select({ + wattHours: hardware.wattHours, + deviceType: hardware.deviceType, + }) + .from(hardware) + .where(eq(hardware.slug, p.hardwareSlug)) + .limit(1) + + const settingsCount = Array.isArray(p.settingsJson) + ? (p.settingsJson as Array<{ settings: unknown[] }>).reduce( + (sum, cat) => sum + cat.settings.length, + 0, + ) + : 0 + + return { + id: p.id, + gameId, + hardwareSlug: p.hardwareSlug, + hardwareName: p.hardwareName, + upvotes: p.upvotes, + downvotes: p.downvotes, + settingsCount, + fpsAvg: p.fpsAvg, + fpsLow: p.fpsLow, + fpsHigh: p.fpsHigh, + fpsOnePercentLow: p.fpsOnePercentLow ?? null, + upscalerType: p.upscalerType, + upscalerVersion: p.upscalerVersion, + frameGenMethod: p.frameGenMethod, + protonVersion: p.protonVersion, + osVersion: p.osVersion, + createdAt: p.createdAt.toISOString(), + settingsJson: p.settingsJson, + launchOptions: p.launchOptions, + loadTimeSsd: p.loadTimeSsd ?? null, + loadTimeSd: p.loadTimeSd ?? null, + tdpWatts: p.tdpWatts ?? null, + youtubeVideoId: p.youtubeVideoId ?? null, + screenshots: screenshots.map((ss) => ({ + id: ss.id, + url: `${publicUrl}/${ss.storageKey}`, + width: ss.width, + height: ss.height, + orderIndex: ss.orderIndex, + })), + hardwareWattHours: hw?.wattHours ? Number(hw.wattHours) : null, + hardwareDeviceType: hw?.deviceType ?? null, + customSystem: p.customSystem ?? false, + userNotes: p.userNotes, + versionString: p.versionString ?? null, + buildId: p.buildId ?? null, + gameAntiCheatName: p.gameAntiCheatName ?? null, + gameAntiCheatStatus: p.gameAntiCheatStatus ?? null, + userId: p.userId, + userName: p.userName, + userImage: p.userImage, + verifiedAt: p.verifiedAt ? p.verifiedAt.toISOString() : null, + isPinned: p.isPinned, + pinnedAt: p.pinnedAt ? p.pinnedAt.toISOString() : null, + } + }), + ) + + return presets + }, + { + params: t.Object({ gameId: t.String() }), + }, +) diff --git a/lib/api/index.ts b/lib/api/index.ts index b24e5da..82d6537 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -7,6 +7,7 @@ export { performanceSubmitRoutes } from "./performance-submit" export { commentsRoutes } from "./comments" export { gamesListingRoutes } from "./games-listing" export { gameStatsRoutes } from "./game-stats" +export { gamesPerformanceRoutes } from "./games-performance" export { hardwareStatsRoutes } from "./hardware-stats" export { savedGamesRoutes } from "./saved-games" export { reportRoutes } from "./reports"