diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 541e3f5ab..ed8a99a75 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -31,7 +31,7 @@ Fixes # (issue) - [ ] My code follows the [style guidelines](https://github.com/Producdevity/EmuReady/blob/master/CONTRIBUTING.md#code-style) of this project - [ ] I have performed a self-review of my code -- [ ] I have made corresponding changes to the documentation +- [ ] I have updated relevant documentation, or no documentation update is needed - [ ] I have checked that all checks (lint, typecheck, test) pass ## Notes for reviewers diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 56d3f5780..4ddbc559e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,8 +16,8 @@ Do not report security issues in public GitHub issues. Contact a maintainer priv Prerequisites: -- Node.js `v22.17.0`, matching [.nvmrc](.nvmrc) -- pnpm through Corepack; this repo uses `pnpm@11.1.0` +- Node.js matching [.nvmrc](.nvmrc) +- pnpm through Corepack, using the `packageManager` version in [package.json](package.json) - PostgreSQL - Clerk credentials for authenticated flows @@ -100,6 +100,13 @@ pnpm test:e2e:ci Playwright reads `.env.test.local` and `.env.test`. See [docs/E2E_TESTING_SETUP.md](docs/E2E_TESTING_SETUP.md). +## Documentation + +- Extend an existing topic before adding a guide. New guides should serve a lasting reader task and be linked from the [documentation index](docs/README.md). +- Keep test results, rollout status, issue audits and review-only screenshots in the pull request. Attach review images to GitHub instead of committing them as documentation assets. +- Link to schemas and configuration files for exact contracts and tool versions. Validate documented configuration examples against their schemas. +- Update affected guidance in the same PR as behavior changes. Remove obsolete or duplicated material; a code change does not automatically require a new documentation file. + ## Pull Requests - Base the branch on the branch the PR targets. Use `master` unless the issue or release flow says to target `staging`. diff --git a/README.md b/README.md index afef37465..be9b210b9 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,6 @@ CI/CD Status - - Deployed on Vercel - License @@ -143,6 +140,8 @@ Then open [http://localhost:3000](http://localhost:3000). Environment setup is documented in [docs/DEVELOPMENT_SETUP.md](docs/DEVELOPMENT_SETUP.md). Docker-specific setup is documented in [docs/DOCKER.md](docs/DOCKER.md), and the production container contract is in [docs/SELF_HOSTING.md](docs/SELF_HOSTING.md). +The [documentation index](docs/README.md) links the development, operations, integration and admin guides. + ## Common Commands ```bash diff --git a/docs/MOBILE_API.md b/docs/MOBILE_API.md index 7472c3fe5..7abb27f05 100644 --- a/docs/MOBILE_API.md +++ b/docs/MOBILE_API.md @@ -1,6 +1,6 @@ # EmuReady Public Integration API (mobile-compatible tRPC) -*Auto-generated on: 2026-07-06T20:07:17.160Z* +*Auto-generated on: 2026-11-13T21:18:52.607Z* ## Summary - **Total Endpoints**: 113 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..da39f6ef1 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,21 @@ +# Documentation + +## Development + +- [Development setup](DEVELOPMENT_SETUP.md) +- [Docker development](DOCKER.md) +- [Authentication setup](AUTHENTICATION_SETUP.md) +- [Browser testing](E2E_TESTING_SETUP.md) +- [Error handling](ERROR_HANDLING.md) + +## Operations and integrations + +- [Self-hosting](SELF_HOSTING.md) +- [Container releases](container-image.md) +- [CORS configuration](CORS_CONFIGURATION.md) +- [Webhook troubleshooting](WEBHOOK_TROUBLESHOOTING.md) +- [Mobile API reference](MOBILE_API.md) — generated by `pnpm docs:generate`. + +## Administration + +- [Edit custom fields as JSON](admin/custom-fields.md) diff --git a/docs/admin/custom-fields.md b/docs/admin/custom-fields.md new file mode 100644 index 000000000..6332376bf --- /dev/null +++ b/docs/admin/custom-fields.md @@ -0,0 +1,48 @@ +# Edit custom fields as JSON + +On an emulator's custom fields page, choose **Edit JSON** to paste or edit its fields, attributes, categories and ordering. Choose **Save JSON** and confirm the emulator. + +The JSON describes the complete setup. Omitted unused fields and categories are removed. Fields with existing Handheld or PC Report values cannot be removed or changed incompatibly. + +## Format + +```json +{ + "version": 1, + "categories": [ + { "name": "Graphics", "displayOrder": 0 } + ], + "fields": [ + { + "name": "renderer", + "label": "Graphics API", + "type": "SELECT", + "category": "Graphics", + "options": [ + { "value": "vulkan", "label": "Vulkan" }, + { "value": "opengl", "label": "OpenGL" } + ], + "defaultValue": "vulkan", + "isRequired": false, + "displayOrder": 0, + "categoryOrder": 0 + } + ] +} +``` + +- Keep the exported `id` when renaming an existing field or category. This preserves its identity and report values. New items may omit IDs; pasted items without a matching destination ID are matched by name or created. +- A field's `category` is a category name, or `null` for no group. Update field references when renaming a category. Empty categories are allowed. +- A category's `displayOrder` controls category order; uncategorized fields appear last. Within a group, fields sort by `categoryOrder`, then by field `displayOrder` to break ties. +- Use JSON booleans and numbers for Boolean and Range defaults, including `false` and `0`. A Select default must match an option's `value`. +- Range settings use `rangeMin`, `rangeMax`, `rangeUnit` and `rangeDecimals`. Text, Textarea and URL fields may use `placeholder`. + +The [configuration schema](../../src/features/emulators/custom-field-config/shared/config.schemas.ts) defines accepted attributes and validation. The example above is checked against that schema by the test suite. + +## Saving and conflicts + +Changing populated fields' types, removing select options, changing range units or narrowing ranges is blocked. Rename or relabel items while retaining their IDs to preserve report values. + +If another edit changes the setup while the editor is open, saving returns a conflict. Copy the draft, reopen the editor and reconcile it with the current configuration before saving again. + +Custom-field management permission is required. Developers must also be verified for the emulator; the [access policy](../../src/features/emulators/custom-field-config/server/config.policy.ts) defines the role checks. diff --git a/eslint.config.mjs b/eslint.config.mjs index 41e68bf24..4f3b045bf 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -88,7 +88,8 @@ const featureLayerBoundaryZones = featureLayerRootNames.flatMap((featureRootName const featureToAppRouteBoundaryZone = { target: './src/features', from: './src/app', - message: 'Feature modules must not import from Next.js app routes. Compose features in app routes.', + message: + 'Feature modules must not import from Next.js app routes. Compose features in app routes.', } const featureBoundaryZones = [ @@ -103,6 +104,7 @@ const eslintConfig = [ ignores: [ '*.log', '*.tsbuildinfo', + '**/.agents/**', '.clerk/**', '.husky/**', '.idea/**', @@ -142,14 +144,13 @@ const eslintConfig = [ 'no-case-declarations': 'off', 'no-prototype-builtins': 'off', 'no-redeclare': 'off', - // TODO: set to error after fixing existing React Compiler rule violations. - 'react-hooks/purity': 'warn', - 'react-hooks/static-components': 'warn', - 'react-hooks/immutability': 'warn', - 'react-hooks/incompatible-library': 'warn', - 'react-hooks/preserve-manual-memoization': 'warn', - 'react-hooks/refs': 'warn', - 'react-hooks/set-state-in-effect': 'warn', + 'react-hooks/purity': 'error', + 'react-hooks/static-components': 'error', + 'react-hooks/immutability': 'error', + 'react-hooks/incompatible-library': 'error', + 'react-hooks/preserve-manual-memoization': 'error', + 'react-hooks/refs': 'error', + 'react-hooks/set-state-in-effect': 'error', }, }, { diff --git a/next.config.ts b/next.config.ts index edaf7f3e8..7344958ba 100644 --- a/next.config.ts +++ b/next.config.ts @@ -150,6 +150,15 @@ function createContentSecurityPolicy(): string { const nextConfig: NextConfig = { output: 'standalone', + async rewrites() { + return [ + { + source: '/assets/emulators/:logo.png', + destination: '/assets/emulators/:logo.webp', + }, + ] + }, + // Keep build identity stable and protect clients from version skew while // Coolify briefly overlaps the old and new containers during deployment. ...(nextBuildId ? { deploymentId: nextBuildId, generateBuildId: () => nextBuildId } : {}), diff --git a/playwright.config.ts b/playwright.config.ts index 1b2db2c75..770304494 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -31,6 +31,7 @@ function createWebServerEnv(): { [key: string]: string } { */ export default defineConfig({ testDir: './tests', + testIgnore: ['**/.agents/**'], /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI when test.only is left on. */ @@ -74,6 +75,7 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'] }, testIgnore: [ + '**/.agents/**', '**/auth.setup.ts', '**/global.setup.ts', '**/data-setup.spec.ts', diff --git a/prisma/seeders/customFieldTemplatesSeeder.ts b/prisma/seeders/customFieldTemplatesSeeder.ts index 258bb5010..24f08eab1 100644 --- a/prisma/seeders/customFieldTemplatesSeeder.ts +++ b/prisma/seeders/customFieldTemplatesSeeder.ts @@ -1,2022 +1,14 @@ -import { type PrismaClient, CustomFieldType, type Prisma } from '@orm/client' - -type TemplateFieldData = { - name: string - label: string - type: CustomFieldType - options?: { value: string; label: string }[] - defaultValue?: string | number | boolean | null - placeholder?: string - isRequired: boolean - displayOrder: number - rangeMin?: number - rangeMax?: number - rangeUnit?: string - rangeDecimals?: number -} - -type CustomFieldTemplateData = { - name: string - fields: TemplateFieldData[] -} - -const customFieldTemplates: CustomFieldTemplateData[] = [ - { - name: 'Ryujinx Fields', - fields: [ - { - name: 'graphics_api', - label: 'Graphics API', - type: CustomFieldType.SELECT, - options: [ - { value: 'Vulkan (Recommended)', label: 'Vulkan (Recommended)' }, - { value: 'OpenGL', label: 'OpenGL' }, - ], - defaultValue: 'Vulkan (Recommended)', - isRequired: false, - displayOrder: 1, - }, - { - name: 'resolution_scale', - label: 'Resolution Scale', - type: CustomFieldType.RANGE, - defaultValue: 1, - isRequired: false, - displayOrder: 2, - rangeMin: 1, - rangeMax: 8, - rangeUnit: 'x', - rangeDecimals: 0, - }, - { - name: 'vsync', - label: 'VSync', - type: CustomFieldType.SELECT, - options: [ - { - value: 'Match Emulated Display (Default)', - label: 'Match Emulated Display (Default)', - }, - { value: 'Off', label: 'Off' }, - { value: 'Double Buffering', label: 'Double Buffering' }, - ], - defaultValue: 'Match Emulated Display (Default)', - isRequired: false, - displayOrder: 3, - }, - { - name: 'async_shader_compilation', - label: 'Async Shader Compilation', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 4, - }, - { - name: 'memory_manager', - label: 'Memory Manager Mode', - type: CustomFieldType.SELECT, - options: [ - { value: 'Host Managed (Default)', label: 'Host Managed (Default)' }, - { value: 'Software', label: 'Software' }, - ], - defaultValue: 'Host Managed (Default)', - isRequired: false, - displayOrder: 5, - }, - { - name: 'system_region', - label: 'System Region', - type: CustomFieldType.SELECT, - options: [ - { value: 'USA', label: 'USA' }, - { value: 'Europe', label: 'Europe' }, - { value: 'Japan', label: 'Japan' }, - { value: 'Australia', label: 'Australia' }, - { value: 'China', label: 'China' }, - { value: 'Korea', label: 'Korea' }, - { value: 'Taiwan', label: 'Taiwan' }, - ], - defaultValue: 'USA', - isRequired: false, - displayOrder: 6, - }, - { - name: 'audio_backend', - label: 'Audio Backend (PC)', - type: CustomFieldType.SELECT, - options: [ - { value: 'SDL2', label: 'SDL2' }, - { value: 'OpenAL', label: 'OpenAL' }, - { value: 'SoundIO', label: 'SoundIO' }, - { value: 'XAudio2 (Windows)', label: 'XAudio2 (Windows)' }, - ], - defaultValue: 'SDL2', - placeholder: 'Windows/Linux/Mac audio backend (PC)', - isRequired: false, - displayOrder: 7, - }, - { - name: 'texture_recompression', - label: 'Texture Recompression', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 8, - }, - { - name: 'ptc_cache', - label: 'PPTC (Profiled Persistent Translation Cache)', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 9, - }, - ], - }, - { - name: 'Cemu Fields', - fields: [ - { - name: 'graphics_api', - label: 'Graphics API', - type: CustomFieldType.SELECT, - options: [ - { value: 'Vulkan (Recommended)', label: 'Vulkan (Recommended)' }, - { value: 'OpenGL', label: 'OpenGL' }, - { value: 'Direct3D 12', label: 'Direct3D 12' }, - ], - defaultValue: 'Vulkan (Recommended)', - isRequired: false, - displayOrder: 1, - }, - { - name: 'vsync', - label: 'VSync', - type: CustomFieldType.SELECT, - options: [ - { - value: 'Match Emulated Display (Experimental)', - label: 'Match Emulated Display (Experimental)', - }, - { value: 'Off', label: 'Off' }, - { value: 'Double Buffering', label: 'Double Buffering' }, - ], - defaultValue: 'Match Emulated Display (Experimental)', - isRequired: false, - displayOrder: 2, - }, - { - name: 'async_shader_compiler', - label: 'Async Shader Compiler', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 3, - }, - { - name: 'gpu_buffer_cache_accuracy', - label: 'GPU Buffer Cache Accuracy', - type: CustomFieldType.SELECT, - options: [ - { value: 'High (Slow)', label: 'High (Slow)' }, - { value: 'Medium (Default)', label: 'Medium (Default)' }, - { value: 'Low (Fast)', label: 'Low (Fast)' }, - ], - defaultValue: 'Medium (Default)', - isRequired: false, - displayOrder: 4, - }, - { - name: 'console_region', - label: 'Console Region', - type: CustomFieldType.SELECT, - options: [ - { value: 'Auto (Default)', label: 'Auto (Default)' }, - { value: 'USA', label: 'USA' }, - { value: 'Europe', label: 'Europe' }, - { value: 'Japan', label: 'Japan' }, - { value: 'China', label: 'China' }, - { value: 'Korea', label: 'Korea' }, - { value: 'Taiwan', label: 'Taiwan' }, - ], - defaultValue: 'Auto (Default)', - isRequired: false, - displayOrder: 5, - }, - { - name: 'upscale_filter', - label: 'Upscale Filter', - type: CustomFieldType.SELECT, - options: [ - { value: 'Bilinear', label: 'Bilinear' }, - { value: 'Bicubic', label: 'Bicubic' }, - ], - defaultValue: 'Bilinear', - isRequired: false, - displayOrder: 6, - }, - { - name: 'audio_api', - label: 'Audio API (PC)', - type: CustomFieldType.SELECT, - options: [ - { value: 'XAudio2 (Windows)', label: 'XAudio2 (Windows)' }, - { value: 'Cubeb (Mac/Linux)', label: 'Cubeb (Mac/Linux)' }, - ], - defaultValue: 'XAudio2 (Windows)', - placeholder: 'Audio backend for Windows/Mac/Linux (PC)', - isRequired: false, - displayOrder: 7, - }, - { - name: 'triple_buffering', - label: 'Triple Buffering', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 8, - }, - { - name: 'precompiled_shaders', - label: 'Precompiled Shaders', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 9, - }, - ], - }, - { - name: 'AetherSX2 Fields', - fields: [ - { - name: 'renderer', - label: 'Renderer', - type: CustomFieldType.SELECT, - options: [ - { value: 'Vulkan (Recommended)', label: 'Vulkan (Recommended)' }, - { value: 'OpenGL ES', label: 'OpenGL ES' }, - ], - defaultValue: 'Vulkan (Recommended)', - placeholder: 'Graphics renderer for Android devices (Android)', - isRequired: false, - displayOrder: 1, - }, - { - name: 'bios_region', - label: 'BIOS Region', - type: CustomFieldType.SELECT, - options: [ - { value: 'USA (NTSC-U)', label: 'USA (NTSC-U)' }, - { value: 'Europe (PAL)', label: 'Europe (PAL)' }, - { value: 'Japan (NTSC-J)', label: 'Japan (NTSC-J)' }, - ], - defaultValue: 'USA (NTSC-U)', - isRequired: true, - displayOrder: 2, - }, - { - name: 'resolution_scale', - label: 'Internal Resolution', - type: CustomFieldType.RANGE, - defaultValue: 1, - isRequired: false, - displayOrder: 3, - rangeMin: 1, - rangeMax: 4, - rangeUnit: 'x', - rangeDecimals: 0, - }, - { - name: 'multi_threaded_vu', - label: 'Multi-threaded VU', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 4, - }, - { - name: 'speed_hacks', - label: 'Enable Speed Hacks', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 5, - }, - { - name: 'threading_mode', - label: 'Threading Mode', - type: CustomFieldType.SELECT, - options: [ - { - value: 'Multi-threaded (Default)', - label: 'Multi-threaded (Default)', - }, - { value: 'Single-threaded', label: 'Single-threaded' }, - ], - defaultValue: 'Multi-threaded (Default)', - placeholder: 'CPU threading configuration for Android (Android)', - isRequired: false, - displayOrder: 6, - }, - { - name: 'texture_filtering', - label: 'Texture Filtering', - type: CustomFieldType.SELECT, - options: [ - { value: 'Bilinear (PS2)', label: 'Bilinear (PS2)' }, - { value: 'Force Bilinear', label: 'Force Bilinear' }, - { value: 'Off', label: 'Off' }, - ], - defaultValue: 'Bilinear (PS2)', - isRequired: false, - displayOrder: 7, - }, - { - name: 'frame_pacing', - label: 'Frame Pacing', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 8, - }, - { - name: 'fast_cdvd', - label: 'Fast CDVD', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 9, - }, - ], - }, - { - name: 'Dolphin Fields', - fields: [ - { - name: 'graphics_backend', - label: 'Graphics Backend', - type: CustomFieldType.SELECT, - options: [ - { value: 'Vulkan', label: 'Vulkan' }, - { value: 'Direct3D 11 (PC)', label: 'Direct3D 11 (PC)' }, - { value: 'Direct3D 12 (PC)', label: 'Direct3D 12 (PC)' }, - { value: 'OpenGL', label: 'OpenGL' }, - { value: 'OpenGL ES (Android)', label: 'OpenGL ES (Android)' }, - { value: 'Metal (macOS)', label: 'Metal (macOS)' }, - ], - defaultValue: 'Vulkan', - isRequired: false, - displayOrder: 1, - }, - { - name: 'enable_dual_core', - label: 'Enable Dual Core', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 2, - }, - { - name: 'dsp_emulator', - label: 'DSP Emulator Engine', - type: CustomFieldType.SELECT, - options: [ - { value: 'HLE (Fast, Default)', label: 'HLE (Fast, Default)' }, - { value: 'LLE (Accurate, Slow)', label: 'LLE (Accurate, Slow)' }, - ], - defaultValue: 'HLE (Fast, Default)', - isRequired: false, - displayOrder: 3, - }, - { - name: 'internal_resolution', - label: 'Internal Resolution', - type: CustomFieldType.RANGE, - defaultValue: 1, - isRequired: false, - displayOrder: 4, - rangeMin: 1, - rangeMax: 8, - rangeUnit: 'x', - rangeDecimals: 0, - }, - { - name: 'shader_compilation', - label: 'Shader Compilation', - type: CustomFieldType.SELECT, - options: [ - { value: 'Specialized (Default)', label: 'Specialized (Default)' }, - { value: 'Hybrid Ubershaders', label: 'Hybrid Ubershaders' }, - { value: 'Exclusive Ubershaders', label: 'Exclusive Ubershaders' }, - { value: 'Skip Drawing', label: 'Skip Drawing' }, - ], - defaultValue: 'Specialized (Default)', - isRequired: false, - displayOrder: 5, - }, - { - name: 'anti_aliasing', - label: 'Anti-Aliasing', - type: CustomFieldType.SELECT, - options: [ - { value: 'Off', label: 'Off' }, - { value: '2x MSAA', label: '2x MSAA' }, - { value: '4x MSAA', label: '4x MSAA' }, - { value: '8x MSAA', label: '8x MSAA' }, - { value: '2x SSAA', label: '2x SSAA' }, - { value: '4x SSAA', label: '4x SSAA' }, - ], - defaultValue: 'Off', - isRequired: false, - displayOrder: 6, - }, - { - name: 'anisotropic_filtering', - label: 'Anisotropic Filtering', - type: CustomFieldType.RANGE, - defaultValue: 1, - isRequired: false, - displayOrder: 7, - rangeMin: 1, - rangeMax: 16, - rangeUnit: 'x', - rangeDecimals: 0, - }, - { - name: 'vsync', - label: 'VSync', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 8, - }, - { - name: 'skip_efb_access', - label: 'Skip EFB Access from CPU', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 9, - }, - { - name: 'ignore_format_changes', - label: 'Ignore Format Changes', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 10, - }, - ], - }, - { - name: 'PCSX2 Fields', - fields: [ - { - name: 'renderer', - label: 'Renderer', - type: CustomFieldType.SELECT, - options: [ - { value: 'Vulkan (Fastest)', label: 'Vulkan (Fastest)' }, - { value: 'OpenGL (Balance)', label: 'OpenGL (Balance)' }, - { value: 'Direct3D 11 (Fallback)', label: 'Direct3D 11 (Fallback)' }, - { value: 'Direct3D 12 (Fallback)', label: 'Direct3D 12 (Fallback)' }, - { - value: 'Software (Most Accurate)', - label: 'Software (Most Accurate)', - }, - ], - defaultValue: 'Vulkan (Fastest)', - placeholder: 'Graphics renderer for Windows/Linux (PC)', - isRequired: false, - displayOrder: 1, - }, - { - name: 'internal_resolution', - label: 'Internal Resolution', - type: CustomFieldType.RANGE, - defaultValue: 1, - isRequired: false, - displayOrder: 2, - rangeMin: 1, - rangeMax: 8, - rangeUnit: 'x', - rangeDecimals: 0, - }, - { - name: 'aspect_ratio', - label: 'Aspect Ratio', - type: CustomFieldType.SELECT, - options: [ - { value: 'Auto (4:3) Recommended', label: 'Auto (4:3) Recommended' }, - { - value: 'Fit to Window/Fullscreen', - label: 'Fit to Window/Fullscreen', - }, - { value: 'Widescreen (16:9)', label: 'Widescreen (16:9)' }, - ], - defaultValue: 'Auto (4:3) Recommended', - isRequired: false, - displayOrder: 3, - }, - { - name: 'texture_filtering', - label: 'Texture Filtering', - type: CustomFieldType.SELECT, - options: [ - { value: 'Bilinear (PS2) Default', label: 'Bilinear (PS2) Default' }, - { value: 'Force Bilinear', label: 'Force Bilinear' }, - { value: 'Off', label: 'Off' }, - ], - defaultValue: 'Bilinear (PS2) Default', - isRequired: false, - displayOrder: 4, - }, - { - name: 'anisotropic_filtering', - label: 'Anisotropic Filtering', - type: CustomFieldType.RANGE, - defaultValue: 1, - isRequired: false, - displayOrder: 5, - rangeMin: 1, - rangeMax: 16, - rangeUnit: 'x', - rangeDecimals: 0, - }, - { - name: 'anti_aliasing', - label: 'Anti-Aliasing (FXAA)', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 6, - }, - { - name: 'anti_blur', - label: 'Anti Blur', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 7, - }, - { - name: 'deinterlacing', - label: 'Deinterlacing', - type: CustomFieldType.SELECT, - options: [ - { value: 'Automatic (Default)', label: 'Automatic (Default)' }, - { - value: 'Native Progressive Scan', - label: 'Native Progressive Scan', - }, - { value: 'Weave', label: 'Weave' }, - { value: 'Bob', label: 'Bob' }, - { value: 'Blend', label: 'Blend' }, - ], - defaultValue: 'Automatic (Default)', - isRequired: false, - displayOrder: 8, - }, - { - name: 'ee_cyclerate', - label: 'EE Cycle Rate', - type: CustomFieldType.RANGE, - defaultValue: 0, - isRequired: false, - displayOrder: 9, - rangeMin: -3, - rangeMax: 3, - rangeUnit: '', - rangeDecimals: 0, - }, - { - name: 'vu_cycle_stealing', - label: 'VU Cycle Stealing', - type: CustomFieldType.RANGE, - defaultValue: 0, - isRequired: false, - displayOrder: 10, - rangeMin: 0, - rangeMax: 4, - rangeUnit: '', - rangeDecimals: 0, - }, - ], - }, - { - name: 'PPSSPP Fields', - fields: [ - { - name: 'backend', - label: 'Backend', - type: CustomFieldType.SELECT, - options: [ - { value: 'Vulkan (Recommended)', label: 'Vulkan (Recommended)' }, - { value: 'OpenGL (Android/PC)', label: 'OpenGL (Android/PC)' }, - { value: 'Direct3D 11 (PC)', label: 'Direct3D 11 (PC)' }, - { value: 'Direct3D 9 (PC)', label: 'Direct3D 9 (PC)' }, - ], - defaultValue: 'Vulkan (Recommended)', - isRequired: false, - displayOrder: 1, - }, - { - name: 'rendering_resolution', - label: 'Rendering Resolution', - type: CustomFieldType.RANGE, - defaultValue: 1, - isRequired: false, - displayOrder: 2, - rangeMin: 1, - rangeMax: 8, - rangeUnit: 'x', - rangeDecimals: 0, - }, - { - name: 'msaa', - label: 'Antialiasing (MSAA)', - type: CustomFieldType.SELECT, - options: [ - { value: 'Off', label: 'Off' }, - { value: '2x MSAA', label: '2x MSAA' }, - { value: '4x MSAA', label: '4x MSAA' }, - { value: '8x MSAA', label: '8x MSAA' }, - ], - defaultValue: 'Off', - placeholder: 'Vulkan backend only (PC)', - isRequired: false, - displayOrder: 3, - }, - { - name: 'vsync', - label: 'VSync', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 4, - }, - { - name: 'frame_skipping', - label: 'Frame Skipping', - type: CustomFieldType.RANGE, - defaultValue: 0, - isRequired: false, - displayOrder: 5, - rangeMin: 0, - rangeMax: 5, - rangeUnit: '', - rangeDecimals: 0, - }, - { - name: 'texture_filtering', - label: 'Texture Filtering', - type: CustomFieldType.SELECT, - options: [ - { value: 'Auto (Default)', label: 'Auto (Default)' }, - { value: 'Nearest', label: 'Nearest' }, - { value: 'Linear', label: 'Linear' }, - { value: 'Auto Max Quality', label: 'Auto Max Quality' }, - ], - defaultValue: 'Auto (Default)', - isRequired: false, - displayOrder: 6, - }, - { - name: 'anisotropic_filtering', - label: 'Anisotropic Filtering', - type: CustomFieldType.RANGE, - defaultValue: 1, - isRequired: false, - displayOrder: 7, - rangeMin: 1, - rangeMax: 16, - rangeUnit: 'x', - rangeDecimals: 0, - }, - { - name: 'texture_scaling', - label: 'Texture Scaling', - type: CustomFieldType.SELECT, - options: [ - { value: 'Off', label: 'Off' }, - { value: 'xBRZ (Sharp)', label: 'xBRZ (Sharp)' }, - { value: 'Bicubic (Smooth)', label: 'Bicubic (Smooth)' }, - { value: 'Hybrid', label: 'Hybrid' }, - ], - defaultValue: 'Off', - isRequired: false, - displayOrder: 8, - }, - { - name: 'lower_resolution_effects', - label: 'Lower Resolution for Effects', - type: CustomFieldType.SELECT, - options: [ - { value: 'Off', label: 'Off' }, - { value: 'Safe', label: 'Safe' }, - { value: 'Balanced', label: 'Balanced' }, - { value: 'Aggressive', label: 'Aggressive' }, - ], - defaultValue: 'Off', - isRequired: false, - displayOrder: 9, - }, - { - name: 'multithreaded', - label: 'Multithreaded (MT)', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 10, - }, - { - name: 'fast_memory', - label: 'Fast Memory', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 11, - }, - ], - }, - { - name: 'RetroArch Fields', - fields: [ - { - name: 'video_driver', - label: 'Video Driver', - type: CustomFieldType.SELECT, - options: [ - { value: 'Vulkan', label: 'Vulkan' }, - { value: 'OpenGL', label: 'OpenGL' }, - { value: 'Direct3D 11', label: 'Direct3D 11' }, - { value: 'Direct3D 12', label: 'Direct3D 12' }, - ], - defaultValue: 'Vulkan', - isRequired: false, - displayOrder: 1, - }, - { - name: 'video_scale', - label: 'Video Scale', - type: CustomFieldType.RANGE, - defaultValue: 1, - isRequired: false, - displayOrder: 2, - rangeMin: 1, - rangeMax: 8, - rangeUnit: 'x', - rangeDecimals: 0, - }, - { - name: 'vsync', - label: 'VSync', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 3, - }, - { - name: 'frame_delay', - label: 'Frame Delay', - type: CustomFieldType.RANGE, - defaultValue: 0, - isRequired: false, - displayOrder: 4, - rangeMin: 0, - rangeMax: 15, - rangeUnit: 'ms', - rangeDecimals: 0, - }, - { - name: 'hard_gpu_sync', - label: 'Hard GPU Sync', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 5, - }, - { - name: 'gpu_sync_frames', - label: 'GPU Sync Frames', - type: CustomFieldType.RANGE, - defaultValue: 0, - isRequired: false, - displayOrder: 6, - rangeMin: 0, - rangeMax: 3, - rangeUnit: '', - rangeDecimals: 0, - }, - { - name: 'rewind', - label: 'Rewind', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 7, - }, - { - name: 'run_ahead', - label: 'Run-Ahead', - type: CustomFieldType.RANGE, - defaultValue: 0, - isRequired: false, - displayOrder: 8, - rangeMin: 0, - rangeMax: 6, - rangeUnit: 'frames', - rangeDecimals: 0, - }, - ], - }, - { - name: 'Xenia Fields', - fields: [ - { - name: 'gpu', - label: 'GPU Backend', - type: CustomFieldType.SELECT, - options: [ - { value: 'Vulkan', label: 'Vulkan' }, - { value: 'Direct3D 12', label: 'Direct3D 12' }, - ], - defaultValue: 'Vulkan', - isRequired: false, - displayOrder: 1, - }, - { - name: 'resolution_scale', - label: 'Resolution Scale', - type: CustomFieldType.RANGE, - defaultValue: 1, - isRequired: false, - displayOrder: 2, - rangeMin: 1, - rangeMax: 3, - rangeUnit: 'x', - rangeDecimals: 0, - }, - { - name: 'vsync', - label: 'VSync', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 3, - }, - { - name: 'gamma_render_target_as_srgb', - label: 'Gamma Render Target as sRGB', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 4, - }, - { - name: 'gpu_allow_invalid_fetch_constants', - label: 'Allow Invalid Fetch Constants', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 5, - }, - { - name: 'cpu', - label: 'CPU Backend', - type: CustomFieldType.SELECT, - options: [ - { value: 'x64', label: 'x64' }, - { value: 'Any', label: 'Any' }, - ], - defaultValue: 'x64', - isRequired: false, - displayOrder: 6, - }, - ], - }, - { - name: 'RPCS3 Fields', - fields: [ - { - name: 'renderer', - label: 'Renderer', - type: CustomFieldType.SELECT, - options: [ - { value: 'Vulkan', label: 'Vulkan' }, - { value: 'OpenGL', label: 'OpenGL' }, - ], - defaultValue: 'Vulkan', - isRequired: false, - displayOrder: 1, - }, - { - name: 'resolution_scale', - label: 'Resolution Scale', - type: CustomFieldType.RANGE, - defaultValue: 100, - isRequired: false, - displayOrder: 2, - rangeMin: 50, - rangeMax: 300, - rangeUnit: '%', - rangeDecimals: 0, - }, - { - name: 'anisotropic_filter', - label: 'Anisotropic Filter', - type: CustomFieldType.SELECT, - options: [ - { value: 'Automatic', label: 'Automatic' }, - { value: '2x', label: '2x' }, - { value: '4x', label: '4x' }, - { value: '8x', label: '8x' }, - { value: '16x', label: '16x' }, - ], - defaultValue: 'Automatic', - isRequired: false, - displayOrder: 3, - }, - { - name: 'anti_aliasing', - label: 'Anti-Aliasing', - type: CustomFieldType.SELECT, - options: [ - { value: 'Disabled', label: 'Disabled' }, - { value: 'FXAA', label: 'FXAA' }, - { value: 'SMAA', label: 'SMAA' }, - ], - defaultValue: 'Disabled', - isRequired: false, - displayOrder: 4, - }, - { - name: 'vsync', - label: 'VSync', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 5, - }, - { - name: 'frame_limit', - label: 'Frame Limit', - type: CustomFieldType.SELECT, - options: [ - { value: 'Off', label: 'Off' }, - { value: '30', label: '30 FPS' }, - { value: '60', label: '60 FPS' }, - { value: 'Auto', label: 'Auto' }, - ], - defaultValue: 'Auto', - isRequired: false, - displayOrder: 6, - }, - { - name: 'spu_threads', - label: 'SPU Threads', - type: CustomFieldType.RANGE, - defaultValue: 2, - isRequired: false, - displayOrder: 7, - rangeMin: 1, - rangeMax: 6, - rangeUnit: '', - rangeDecimals: 0, - }, - { - name: 'spu_block_size', - label: 'SPU Block Size', - type: CustomFieldType.SELECT, - options: [ - { value: 'Safe', label: 'Safe' }, - { value: 'Mega', label: 'Mega' }, - { value: 'Giga', label: 'Giga' }, - ], - defaultValue: 'Safe', - isRequired: false, - displayOrder: 8, - }, - ], - }, - { - name: 'Yuzu Fields', - fields: [ - { - name: 'api', - label: 'API', - type: CustomFieldType.SELECT, - options: [ - { value: 'Vulkan', label: 'Vulkan' }, - { value: 'OpenGL', label: 'OpenGL' }, - ], - defaultValue: 'Vulkan', - isRequired: false, - displayOrder: 1, - }, - { - name: 'resolution_scale', - label: 'Resolution Scale', - type: CustomFieldType.RANGE, - defaultValue: 1, - isRequired: false, - displayOrder: 2, - rangeMin: 1, - rangeMax: 8, - rangeUnit: 'x', - rangeDecimals: 0, - }, - { - name: 'anti_aliasing', - label: 'Anti-Aliasing', - type: CustomFieldType.SELECT, - options: [ - { value: 'None', label: 'None' }, - { value: 'FXAA', label: 'FXAA' }, - { value: 'SMAA', label: 'SMAA' }, - ], - defaultValue: 'None', - isRequired: false, - displayOrder: 3, - }, - { - name: 'vsync_mode', - label: 'VSync Mode', - type: CustomFieldType.SELECT, - options: [ - { value: 'Immediate (Off)', label: 'Immediate (Off)' }, - { value: 'Mailbox', label: 'Mailbox' }, - { value: 'FIFO (On)', label: 'FIFO (On)' }, - { value: 'FIFO Relaxed', label: 'FIFO Relaxed' }, - ], - defaultValue: 'FIFO (On)', - isRequired: false, - displayOrder: 4, - }, - { - name: 'async_gpu_emulation', - label: 'Async GPU Emulation', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 5, - }, - { - name: 'async_shader_compilation', - label: 'Async Shader Compilation', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 6, - }, - { - name: 'cpu_accuracy', - label: 'CPU Accuracy', - type: CustomFieldType.SELECT, - options: [ - { value: 'Auto', label: 'Auto' }, - { value: 'Accurate', label: 'Accurate' }, - { value: 'Unsafe', label: 'Unsafe' }, - { value: 'Paranoid', label: 'Paranoid' }, - ], - defaultValue: 'Auto', - isRequired: false, - displayOrder: 7, - }, - { - name: 'multicore_scheduling', - label: 'Multicore CPU Emulation', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 8, - }, - ], - }, - { - name: 'Drastic Fields', - fields: [ - { - name: 'renderer', - label: 'Renderer', - type: CustomFieldType.SELECT, - options: [ - { value: 'Software', label: 'Software' }, - { value: 'OpenGL', label: 'OpenGL' }, - { value: 'OpenGL ES', label: 'OpenGL ES' }, - ], - defaultValue: 'OpenGL', - isRequired: false, - displayOrder: 1, - }, - { - name: 'resolution_scale', - label: 'Resolution Scale', - type: CustomFieldType.RANGE, - defaultValue: 1, - isRequired: false, - displayOrder: 2, - rangeMin: 1, - rangeMax: 4, - rangeUnit: 'x', - rangeDecimals: 0, - }, - { - name: 'texture_filtering', - label: 'Texture Filtering', - type: CustomFieldType.SELECT, - options: [ - { value: 'None', label: 'None' }, - { value: 'Linear', label: 'Linear' }, - { value: 'Nearest', label: 'Nearest' }, - ], - defaultValue: 'Linear', - isRequired: false, - displayOrder: 3, - }, - { - name: 'frame_skip', - label: 'Frame Skip', - type: CustomFieldType.RANGE, - defaultValue: 0, - isRequired: false, - displayOrder: 4, - rangeMin: 0, - rangeMax: 9, - rangeUnit: '', - rangeDecimals: 0, - }, - { - name: 'threaded_3d', - label: 'Threaded 3D', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 5, - }, - { - name: 'high_resolution_3d', - label: 'High Resolution 3D', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 6, - }, - { - name: 'jit_enabled', - label: 'JIT Enabled', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 7, - }, - { - name: 'sound_enabled', - label: 'Sound Enabled', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 8, - }, - ], - }, - { - name: 'melonDS Fields', - fields: [ - { - name: 'renderer', - label: 'Renderer', - type: CustomFieldType.SELECT, - options: [ - { value: 'Software', label: 'Software' }, - { value: 'OpenGL', label: 'OpenGL' }, - ], - defaultValue: 'OpenGL', - isRequired: false, - displayOrder: 1, - }, - { - name: 'resolution_scale', - label: 'Resolution Scale', - type: CustomFieldType.RANGE, - defaultValue: 1, - isRequired: false, - displayOrder: 2, - rangeMin: 1, - rangeMax: 8, - rangeUnit: 'x', - rangeDecimals: 0, - }, - { - name: 'texture_filtering', - label: 'Texture Filtering', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 3, - }, - { - name: 'anti_aliasing', - label: 'Anti-Aliasing', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 4, - }, - { - name: 'threaded_rendering', - label: 'Threaded Rendering', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 5, - }, - { - name: 'jit_enabled', - label: 'JIT Enabled', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 6, - }, - { - name: 'dsi_mode', - label: 'DSi Mode', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 7, - }, - { - name: 'audio_interpolation', - label: 'Audio Interpolation', - type: CustomFieldType.SELECT, - options: [ - { value: 'None', label: 'None' }, - { value: 'Linear', label: 'Linear' }, - { value: 'Cosine', label: 'Cosine' }, - { value: 'Cubic', label: 'Cubic' }, - ], - defaultValue: 'Linear', - isRequired: false, - displayOrder: 8, - }, - ], - }, - { - name: 'Yaba Sanshiro Fields', - fields: [ - { - name: 'video_interface', - label: 'Video Interface', - type: CustomFieldType.SELECT, - options: [ - { value: 'Software', label: 'Software' }, - { value: 'OpenGL ES 3.0', label: 'OpenGL ES 3.0' }, - { value: 'Vulkan', label: 'Vulkan' }, - ], - defaultValue: 'OpenGL ES 3.0', - isRequired: false, - displayOrder: 1, - }, - { - name: 'resolution', - label: 'Resolution', - type: CustomFieldType.SELECT, - options: [ - { value: 'Original', label: 'Original' }, - { value: '2x', label: '2x' }, - { value: '4x', label: '4x' }, - { value: '720p', label: '720p' }, - { value: '1080p', label: '1080p' }, - ], - defaultValue: 'Original', - isRequired: false, - displayOrder: 2, - }, - { - name: 'polygon_generation', - label: 'Polygon Generation', - type: CustomFieldType.SELECT, - options: [ - { value: 'CPU', label: 'CPU' }, - { value: 'GPU Tessellation', label: 'GPU Tessellation' }, - { value: 'GPU Compute Shader', label: 'GPU Compute Shader' }, - ], - defaultValue: 'CPU', - isRequired: false, - displayOrder: 3, - }, - { - name: 'mesh_mode', - label: 'Mesh Mode', - type: CustomFieldType.SELECT, - options: [ - { value: 'Original', label: 'Original' }, - { value: 'Improved', label: 'Improved' }, - ], - defaultValue: 'Original', - isRequired: false, - displayOrder: 4, - }, - { - name: 'bandpass_filter', - label: 'Bandpass Filter', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 5, - }, - { - name: 'rotate_screen', - label: 'Rotate Screen', - type: CustomFieldType.SELECT, - options: [ - { value: '0', label: '0°' }, - { value: '90', label: '90°' }, - { value: '180', label: '180°' }, - { value: '270', label: '270°' }, - ], - defaultValue: '0', - isRequired: false, - displayOrder: 6, - }, - { - name: 'fps_mode', - label: 'FPS Mode', - type: CustomFieldType.SELECT, - options: [ - { value: 'Auto', label: 'Auto' }, - { value: '50Hz', label: '50Hz' }, - { value: '60Hz', label: '60Hz' }, - ], - defaultValue: 'Auto', - isRequired: false, - displayOrder: 7, - }, - { - name: 'sound_engine', - label: 'Sound Engine', - type: CustomFieldType.SELECT, - options: [ - { value: 'New', label: 'New' }, - { value: 'Old', label: 'Old' }, - ], - defaultValue: 'New', - isRequired: false, - displayOrder: 8, - }, - ], - }, - { - name: 'Vita3K Fields', - fields: [ - { - name: 'backend_renderer', - label: 'Backend Renderer', - type: CustomFieldType.SELECT, - options: [ - { value: 'OpenGL', label: 'OpenGL' }, - { value: 'Vulkan', label: 'Vulkan' }, - ], - defaultValue: 'OpenGL', - isRequired: false, - displayOrder: 1, - }, - { - name: 'gpu_backend', - label: 'GPU Backend', - type: CustomFieldType.SELECT, - options: [ - { value: 'Unicorn', label: 'Unicorn' }, - { value: 'Dynarmic', label: 'Dynarmic' }, - ], - defaultValue: 'Dynarmic', - isRequired: false, - displayOrder: 2, - }, - { - name: 'resolution_multiplier', - label: 'Resolution Multiplier', - type: CustomFieldType.RANGE, - defaultValue: 1, - isRequired: false, - displayOrder: 3, - rangeMin: 1, - rangeMax: 4, - rangeUnit: 'x', - rangeDecimals: 0, - }, - { - name: 'anisotropic_filtering', - label: 'Anisotropic Filtering', - type: CustomFieldType.SELECT, - options: [ - { value: '1x', label: '1x (Off)' }, - { value: '2x', label: '2x' }, - { value: '4x', label: '4x' }, - { value: '8x', label: '8x' }, - { value: '16x', label: '16x' }, - ], - defaultValue: '1x', - isRequired: false, - displayOrder: 4, - }, - { - name: 'texture_cache', - label: 'Texture Cache', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 5, - }, - { - name: 'shader_cache', - label: 'Shader Cache', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 6, - }, - { - name: 'spirv_shader', - label: 'SPIR-V Shader', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 7, - }, - { - name: 'vsync', - label: 'VSync', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 8, - }, - ], - }, - { - name: 'ShadPS4 Fields', - fields: [ - { - name: 'gpu_backend', - label: 'GPU Backend', - type: CustomFieldType.SELECT, - options: [ - { value: 'Vulkan', label: 'Vulkan' }, - { value: 'OpenGL', label: 'OpenGL' }, - ], - defaultValue: 'Vulkan', - isRequired: false, - displayOrder: 1, - }, - { - name: 'resolution_scale', - label: 'Resolution Scale', - type: CustomFieldType.RANGE, - defaultValue: 1, - isRequired: false, - displayOrder: 2, - rangeMin: 1, - rangeMax: 4, - rangeUnit: 'x', - rangeDecimals: 0, - }, - { - name: 'anisotropic_filtering', - label: 'Anisotropic Filtering', - type: CustomFieldType.SELECT, - options: [ - { value: 'Automatic', label: 'Automatic' }, - { value: '2x', label: '2x' }, - { value: '4x', label: '4x' }, - { value: '8x', label: '8x' }, - { value: '16x', label: '16x' }, - ], - defaultValue: 'Automatic', - isRequired: false, - displayOrder: 3, - }, - { - name: 'vsync', - label: 'VSync', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 4, - }, - { - name: 'enable_shader_validation', - label: 'Enable Shader Validation', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 5, - }, - { - name: 'rdoc_enable', - label: 'RenderDoc Enable', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 6, - }, - { - name: 'null_gpu', - label: 'Null GPU', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 7, - }, - { - name: 'dump_shaders', - label: 'Dump Shaders', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 8, - }, - ], - }, - { - name: 'Redream Fields', - fields: [ - { - name: 'backend', - label: 'Backend', - type: CustomFieldType.SELECT, - options: [ - { value: 'OpenGL', label: 'OpenGL' }, - { value: 'Vulkan', label: 'Vulkan' }, - ], - defaultValue: 'OpenGL', - isRequired: false, - displayOrder: 1, - }, - { - name: 'resolution', - label: 'Resolution', - type: CustomFieldType.SELECT, - options: [ - { value: '640x480', label: '640x480 (Original)' }, - { value: '1280x960', label: '1280x960 (2x)' }, - { value: '1920x1440', label: '1920x1440 (3x)' }, - { value: '2560x1920', label: '2560x1920 (4x)' }, - ], - defaultValue: '640x480', - isRequired: false, - displayOrder: 2, - }, - { - name: 'region', - label: 'Region', - type: CustomFieldType.SELECT, - options: [ - { value: 'Auto', label: 'Auto' }, - { value: 'Japan', label: 'Japan' }, - { value: 'USA', label: 'USA' }, - { value: 'Europe', label: 'Europe' }, - ], - defaultValue: 'Auto', - isRequired: false, - displayOrder: 3, - }, - { - name: 'broadcast', - label: 'Broadcast', - type: CustomFieldType.SELECT, - options: [ - { value: 'Auto', label: 'Auto' }, - { value: 'NTSC', label: 'NTSC' }, - { value: 'PAL', label: 'PAL' }, - { value: 'PAL_M', label: 'PAL-M' }, - { value: 'PAL_N', label: 'PAL-N' }, - ], - defaultValue: 'Auto', - isRequired: false, - displayOrder: 4, - }, - { - name: 'language', - label: 'Language', - type: CustomFieldType.SELECT, - options: [ - { value: 'Japanese', label: 'Japanese' }, - { value: 'English', label: 'English' }, - { value: 'German', label: 'German' }, - { value: 'French', label: 'French' }, - { value: 'Spanish', label: 'Spanish' }, - { value: 'Italian', label: 'Italian' }, - ], - defaultValue: 'English', - isRequired: false, - displayOrder: 5, - }, - { - name: 'audio_latency', - label: 'Audio Latency', - type: CustomFieldType.RANGE, - defaultValue: 100, - isRequired: false, - displayOrder: 6, - rangeMin: 20, - rangeMax: 200, - rangeUnit: 'ms', - rangeDecimals: 0, - }, - { - name: 'enable_ta_stats', - label: 'Enable TA Stats', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 7, - }, - { - name: 'enable_pvr_stats', - label: 'Enable PVR Stats', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 8, - }, - ], - }, - { - name: 'Citra Fields', - fields: [ - { - name: 'graphics_api', - label: 'Graphics API', - type: CustomFieldType.SELECT, - options: [ - { value: 'OpenGL', label: 'OpenGL' }, - { value: 'Software', label: 'Software' }, - { value: 'Vulkan', label: 'Vulkan' }, - ], - defaultValue: 'OpenGL', - isRequired: false, - displayOrder: 1, - }, - { - name: 'resolution_factor', - label: 'Resolution Factor', - type: CustomFieldType.RANGE, - defaultValue: 1, - isRequired: false, - displayOrder: 2, - rangeMin: 1, - rangeMax: 10, - rangeUnit: 'x', - rangeDecimals: 0, - }, - { - name: 'texture_filter', - label: 'Texture Filter', - type: CustomFieldType.SELECT, - options: [ - { value: 'None', label: 'None' }, - { value: 'Anime4K', label: 'Anime4K' }, - { value: 'Bicubic', label: 'Bicubic' }, - { value: 'ScaleForce', label: 'ScaleForce' }, - { value: 'xBRZ', label: 'xBRZ' }, - ], - defaultValue: 'None', - isRequired: false, - displayOrder: 3, - }, - { - name: 'texture_sampling', - label: 'Texture Sampling', - type: CustomFieldType.SELECT, - options: [ - { value: 'Game Controlled', label: 'Game Controlled' }, - { value: 'Nearest Neighbor', label: 'Nearest Neighbor' }, - { value: 'Linear', label: 'Linear' }, - ], - defaultValue: 'Game Controlled', - isRequired: false, - displayOrder: 4, - }, - { - name: 'layout_option', - label: 'Layout Option', - type: CustomFieldType.SELECT, - options: [ - { value: 'Default', label: 'Default' }, - { value: 'Single Screen', label: 'Single Screen' }, - { value: 'Large Screen', label: 'Large Screen' }, - { value: 'Side by Side', label: 'Side by Side' }, - { value: 'Separate Windows', label: 'Separate Windows' }, - ], - defaultValue: 'Default', - isRequired: false, - displayOrder: 5, - }, - { - name: 'swap_screen', - label: 'Swap Screen', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 6, - }, - { - name: 'upright_screen', - label: 'Upright Screen', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 7, - }, - { - name: 'use_hw_shader', - label: 'Use Hardware Shader', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 8, - }, - { - name: 'use_shader_jit', - label: 'Use Shader JIT', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 9, - }, - { - name: 'use_vsync_new', - label: 'Use VSync', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 10, - }, - { - name: 'shaders_accurate_mul', - label: 'Shaders Accurate Multiplication', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 11, - }, - { - name: 'use_disk_shader_cache', - label: 'Use Disk Shader Cache', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 12, - }, - ], - }, - { - name: 'DuckStation Fields', - fields: [ - { - name: 'renderer', - label: 'Renderer', - type: CustomFieldType.SELECT, - options: [ - { value: 'Vulkan (Recommended)', label: 'Vulkan (Recommended)' }, - { value: 'OpenGL', label: 'OpenGL' }, - { value: 'Direct3D 11 (PC)', label: 'Direct3D 11 (PC)' }, - { value: 'Direct3D 12 (PC)', label: 'Direct3D 12 (PC)' }, - { value: 'Software', label: 'Software' }, - ], - defaultValue: 'Vulkan (Recommended)', - isRequired: false, - displayOrder: 1, - }, - { - name: 'internal_resolution', - label: 'Internal Resolution', - type: CustomFieldType.RANGE, - defaultValue: 1, - isRequired: false, - displayOrder: 2, - rangeMin: 1, - rangeMax: 16, - rangeUnit: 'x', - rangeDecimals: 0, - }, - { - name: 'texture_filtering', - label: 'Texture Filtering', - type: CustomFieldType.SELECT, - options: [ - { value: 'Nearest-Neighbor', label: 'Nearest-Neighbor' }, - { value: 'Bilinear', label: 'Bilinear' }, - { - value: 'Bilinear (No Edge Blending)', - label: 'Bilinear (No Edge Blending)', - }, - { value: 'JINC2 (Slow)', label: 'JINC2 (Slow)' }, - { value: 'xBR (Very Slow)', label: 'xBR (Very Slow)' }, - ], - defaultValue: 'Bilinear', - isRequired: false, - displayOrder: 3, - }, - { - name: 'anti_aliasing', - label: 'Anti-Aliasing', - type: CustomFieldType.SELECT, - options: [ - { value: 'Disabled', label: 'Disabled' }, - { value: '2x MSAA', label: '2x MSAA' }, - { value: '4x MSAA', label: '4x MSAA' }, - { value: '8x MSAA', label: '8x MSAA' }, - { value: '16x MSAA', label: '16x MSAA' }, - { value: '2x SSAA', label: '2x SSAA' }, - { value: '4x SSAA', label: '4x SSAA' }, - { value: '8x SSAA', label: '8x SSAA' }, - { value: '16x SSAA', label: '16x SSAA' }, - ], - defaultValue: 'Disabled', - isRequired: false, - displayOrder: 4, - }, - { - name: 'pgxp_geometry', - label: 'PGXP Geometry Correction', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 5, - }, - { - name: 'pgxp_texture', - label: 'PGXP Texture Correction', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 6, - }, - { - name: 'widescreen_hack', - label: 'Widescreen Hack', - type: CustomFieldType.BOOLEAN, - defaultValue: false, - isRequired: false, - displayOrder: 7, - }, - { - name: 'cpu_overclock', - label: 'CPU Overclock', - type: CustomFieldType.RANGE, - defaultValue: 100, - isRequired: false, - displayOrder: 8, - rangeMin: 10, - rangeMax: 1000, - rangeUnit: '%', - rangeDecimals: 0, - }, - { - name: 'gpu_overclock', - label: 'GPU Overclock', - type: CustomFieldType.RANGE, - defaultValue: 100, - isRequired: false, - displayOrder: 9, - rangeMin: 10, - rangeMax: 2000, - rangeUnit: '%', - rangeDecimals: 0, - }, - { - name: 'cdrom_read_speedup', - label: 'CD-ROM Read Speedup', - type: CustomFieldType.RANGE, - defaultValue: 1, - isRequired: false, - displayOrder: 10, - rangeMin: 1, - rangeMax: 10, - rangeUnit: 'x', - rangeDecimals: 0, - }, - { - name: 'cdrom_seek_speedup', - label: 'CD-ROM Seek Speedup', - type: CustomFieldType.RANGE, - defaultValue: 1, - isRequired: false, - displayOrder: 11, - rangeMin: 1, - rangeMax: 20, - rangeUnit: 'x', - rangeDecimals: 0, - }, - { - name: 'vsync', - label: 'VSync', - type: CustomFieldType.BOOLEAN, - defaultValue: true, - isRequired: false, - displayOrder: 12, - }, - { - name: 'audio_backend', - label: 'Audio Backend', - type: CustomFieldType.SELECT, - options: [ - { value: 'Cubeb', label: 'Cubeb' }, - { value: 'SDL', label: 'SDL' }, - { value: 'XAudio2 (Windows)', label: 'XAudio2 (Windows)' }, - { value: 'Null', label: 'Null (No Audio)' }, - ], - defaultValue: 'Cubeb', - isRequired: false, - displayOrder: 13, - }, - { - name: 'recompiler', - label: 'CPU Recompiler', - type: CustomFieldType.SELECT, - options: [ - { - value: 'Fastest (Cached Interpreter)', - label: 'Fastest (Cached Interpreter)', - }, - { value: 'Fast (Recompiler)', label: 'Fast (Recompiler)' }, - { value: 'Accurate (Interpreter)', label: 'Accurate (Interpreter)' }, - ], - defaultValue: 'Fast (Recompiler)', - isRequired: false, - displayOrder: 14, - }, - { - name: 'memory_card_type', - label: 'Memory Card Type', - type: CustomFieldType.SELECT, - options: [ - { - value: 'Shared Between All Games', - label: 'Shared Between All Games', - }, - { value: 'Per Game', label: 'Per Game' }, - { value: 'Per Game Title', label: 'Per Game Title' }, - { value: 'None', label: 'None' }, - ], - defaultValue: 'Per Game Title', - isRequired: false, - displayOrder: 15, - }, - ], - }, -] +import { CustomFieldTemplateRepository } from '@/features/emulators/custom-field-templates/server/template.repository' +import { CreateCustomFieldTemplateSchema } from '@/features/emulators/custom-field-templates/shared/template.schemas' +import { type PrismaClient } from '@orm/client' +import { customFieldTemplates } from './data/custom-field-templates' async function customFieldTemplatesSeeder(prisma: PrismaClient) { console.info('🌱 Seeding custom field templates...') - - for (const templateData of customFieldTemplates) { - const { name, fields } = templateData - - // Create or update the template - const template = await prisma.customFieldTemplate.upsert({ - where: { name }, - update: {}, - create: { - name, - }, - }) - - // Remove existing fields and create new ones - await prisma.customFieldTemplateField.deleteMany({ - where: { templateId: template.id }, - }) - - for (const fieldData of fields) { - await prisma.customFieldTemplateField.create({ - data: { - templateId: template.id, - name: fieldData.name, - label: fieldData.label, - type: fieldData.type, - options: fieldData.options, - defaultValue: - fieldData.defaultValue !== undefined - ? (fieldData.defaultValue as Prisma.InputJsonValue) - : undefined, - placeholder: fieldData.placeholder, - isRequired: fieldData.isRequired, - displayOrder: fieldData.displayOrder, - rangeMin: fieldData.rangeMin, - rangeMax: fieldData.rangeMax, - rangeUnit: fieldData.rangeUnit, - rangeDecimals: fieldData.rangeDecimals, - }, - }) - } + const repository = new CustomFieldTemplateRepository(prisma) + for (const template of customFieldTemplates) { + await repository.syncCatalogTemplate(CreateCustomFieldTemplateSchema.parse(template)) } - console.info('✅ Custom field templates seeded successfully') } diff --git a/prisma/seeders/data/custom-field-templates.ts b/prisma/seeders/data/custom-field-templates.ts new file mode 100644 index 000000000..be644d4e7 --- /dev/null +++ b/prisma/seeders/data/custom-field-templates.ts @@ -0,0 +1,1950 @@ +import { type z } from 'zod' +import { type CreateCustomFieldTemplateSchema } from '@/features/emulators/custom-field-templates/shared/template.schemas' +import { CustomFieldType } from '@orm' + +export const customFieldTemplates: z.input[] = [ + { + name: 'Ryujinx Fields', + fields: [ + { + name: 'graphics_api', + label: 'Graphics API', + type: CustomFieldType.SELECT, + options: [ + { value: 'Vulkan (Recommended)', label: 'Vulkan (Recommended)' }, + { value: 'OpenGL', label: 'OpenGL' }, + ], + defaultValue: 'Vulkan (Recommended)', + isRequired: false, + displayOrder: 1, + }, + { + name: 'resolution_scale', + label: 'Resolution Scale', + type: CustomFieldType.RANGE, + defaultValue: 1, + isRequired: false, + displayOrder: 2, + rangeMin: 1, + rangeMax: 8, + rangeUnit: 'x', + rangeDecimals: 0, + }, + { + name: 'vsync', + label: 'VSync', + type: CustomFieldType.SELECT, + options: [ + { + value: 'Match Emulated Display (Default)', + label: 'Match Emulated Display (Default)', + }, + { value: 'Off', label: 'Off' }, + { value: 'Double Buffering', label: 'Double Buffering' }, + ], + defaultValue: 'Match Emulated Display (Default)', + isRequired: false, + displayOrder: 3, + }, + { + name: 'async_shader_compilation', + label: 'Async Shader Compilation', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 4, + }, + { + name: 'memory_manager', + label: 'Memory Manager Mode', + type: CustomFieldType.SELECT, + options: [ + { value: 'Host Managed (Default)', label: 'Host Managed (Default)' }, + { value: 'Software', label: 'Software' }, + ], + defaultValue: 'Host Managed (Default)', + isRequired: false, + displayOrder: 5, + }, + { + name: 'system_region', + label: 'System Region', + type: CustomFieldType.SELECT, + options: [ + { value: 'USA', label: 'USA' }, + { value: 'Europe', label: 'Europe' }, + { value: 'Japan', label: 'Japan' }, + { value: 'Australia', label: 'Australia' }, + { value: 'China', label: 'China' }, + { value: 'Korea', label: 'Korea' }, + { value: 'Taiwan', label: 'Taiwan' }, + ], + defaultValue: 'USA', + isRequired: false, + displayOrder: 6, + }, + { + name: 'audio_backend', + label: 'Audio Backend (PC)', + type: CustomFieldType.SELECT, + options: [ + { value: 'SDL2', label: 'SDL2' }, + { value: 'OpenAL', label: 'OpenAL' }, + { value: 'SoundIO', label: 'SoundIO' }, + { value: 'XAudio2 (Windows)', label: 'XAudio2 (Windows)' }, + ], + defaultValue: 'SDL2', + isRequired: false, + displayOrder: 7, + }, + { + name: 'texture_recompression', + label: 'Texture Recompression', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 8, + }, + { + name: 'ptc_cache', + label: 'PPTC (Profiled Persistent Translation Cache)', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 9, + }, + ], + }, + { + name: 'Cemu Fields', + fields: [ + { + name: 'graphics_api', + label: 'Graphics API', + type: CustomFieldType.SELECT, + options: [ + { value: 'Vulkan (Recommended)', label: 'Vulkan (Recommended)' }, + { value: 'OpenGL', label: 'OpenGL' }, + { value: 'Direct3D 12', label: 'Direct3D 12' }, + ], + defaultValue: 'Vulkan (Recommended)', + isRequired: false, + displayOrder: 1, + }, + { + name: 'vsync', + label: 'VSync', + type: CustomFieldType.SELECT, + options: [ + { + value: 'Match Emulated Display (Experimental)', + label: 'Match Emulated Display (Experimental)', + }, + { value: 'Off', label: 'Off' }, + { value: 'Double Buffering', label: 'Double Buffering' }, + ], + defaultValue: 'Match Emulated Display (Experimental)', + isRequired: false, + displayOrder: 2, + }, + { + name: 'async_shader_compiler', + label: 'Async Shader Compiler', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 3, + }, + { + name: 'gpu_buffer_cache_accuracy', + label: 'GPU Buffer Cache Accuracy', + type: CustomFieldType.SELECT, + options: [ + { value: 'High (Slow)', label: 'High (Slow)' }, + { value: 'Medium (Default)', label: 'Medium (Default)' }, + { value: 'Low (Fast)', label: 'Low (Fast)' }, + ], + defaultValue: 'Medium (Default)', + isRequired: false, + displayOrder: 4, + }, + { + name: 'console_region', + label: 'Console Region', + type: CustomFieldType.SELECT, + options: [ + { value: 'Auto (Default)', label: 'Auto (Default)' }, + { value: 'USA', label: 'USA' }, + { value: 'Europe', label: 'Europe' }, + { value: 'Japan', label: 'Japan' }, + { value: 'China', label: 'China' }, + { value: 'Korea', label: 'Korea' }, + { value: 'Taiwan', label: 'Taiwan' }, + ], + defaultValue: 'Auto (Default)', + isRequired: false, + displayOrder: 5, + }, + { + name: 'upscale_filter', + label: 'Upscale Filter', + type: CustomFieldType.SELECT, + options: [ + { value: 'Bilinear', label: 'Bilinear' }, + { value: 'Bicubic', label: 'Bicubic' }, + ], + defaultValue: 'Bilinear', + isRequired: false, + displayOrder: 6, + }, + { + name: 'audio_api', + label: 'Audio API (PC)', + type: CustomFieldType.SELECT, + options: [ + { value: 'XAudio2 (Windows)', label: 'XAudio2 (Windows)' }, + { value: 'Cubeb (Mac/Linux)', label: 'Cubeb (Mac/Linux)' }, + ], + defaultValue: 'XAudio2 (Windows)', + isRequired: false, + displayOrder: 7, + }, + { + name: 'triple_buffering', + label: 'Triple Buffering', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 8, + }, + { + name: 'precompiled_shaders', + label: 'Precompiled Shaders', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 9, + }, + ], + }, + { + name: 'AetherSX2 Fields', + fields: [ + { + name: 'renderer', + label: 'Renderer', + type: CustomFieldType.SELECT, + options: [ + { value: 'Vulkan (Recommended)', label: 'Vulkan (Recommended)' }, + { value: 'OpenGL ES', label: 'OpenGL ES' }, + ], + defaultValue: 'Vulkan (Recommended)', + isRequired: false, + displayOrder: 1, + }, + { + name: 'bios_region', + label: 'BIOS Region', + type: CustomFieldType.SELECT, + options: [ + { value: 'USA (NTSC-U)', label: 'USA (NTSC-U)' }, + { value: 'Europe (PAL)', label: 'Europe (PAL)' }, + { value: 'Japan (NTSC-J)', label: 'Japan (NTSC-J)' }, + ], + defaultValue: 'USA (NTSC-U)', + isRequired: true, + displayOrder: 2, + }, + { + name: 'resolution_scale', + label: 'Internal Resolution', + type: CustomFieldType.RANGE, + defaultValue: 1, + isRequired: false, + displayOrder: 3, + rangeMin: 1, + rangeMax: 4, + rangeUnit: 'x', + rangeDecimals: 0, + }, + { + name: 'multi_threaded_vu', + label: 'Multi-threaded VU', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 4, + }, + { + name: 'speed_hacks', + label: 'Enable Speed Hacks', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 5, + }, + { + name: 'threading_mode', + label: 'Threading Mode', + type: CustomFieldType.SELECT, + options: [ + { + value: 'Multi-threaded (Default)', + label: 'Multi-threaded (Default)', + }, + { value: 'Single-threaded', label: 'Single-threaded' }, + ], + defaultValue: 'Multi-threaded (Default)', + isRequired: false, + displayOrder: 6, + }, + { + name: 'texture_filtering', + label: 'Texture Filtering', + type: CustomFieldType.SELECT, + options: [ + { value: 'Bilinear (PS2)', label: 'Bilinear (PS2)' }, + { value: 'Force Bilinear', label: 'Force Bilinear' }, + { value: 'Off', label: 'Off' }, + ], + defaultValue: 'Bilinear (PS2)', + isRequired: false, + displayOrder: 7, + }, + { + name: 'frame_pacing', + label: 'Frame Pacing', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 8, + }, + { + name: 'fast_cdvd', + label: 'Fast CDVD', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 9, + }, + ], + }, + { + name: 'Dolphin Fields', + fields: [ + { + name: 'graphics_backend', + label: 'Graphics Backend', + type: CustomFieldType.SELECT, + options: [ + { value: 'Vulkan', label: 'Vulkan' }, + { value: 'Direct3D 11 (PC)', label: 'Direct3D 11 (PC)' }, + { value: 'Direct3D 12 (PC)', label: 'Direct3D 12 (PC)' }, + { value: 'OpenGL', label: 'OpenGL' }, + { value: 'OpenGL ES (Android)', label: 'OpenGL ES (Android)' }, + { value: 'Metal (macOS)', label: 'Metal (macOS)' }, + ], + defaultValue: 'Vulkan', + isRequired: false, + displayOrder: 1, + }, + { + name: 'enable_dual_core', + label: 'Enable Dual Core', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 2, + }, + { + name: 'dsp_emulator', + label: 'DSP Emulator Engine', + type: CustomFieldType.SELECT, + options: [ + { value: 'HLE (Fast, Default)', label: 'HLE (Fast, Default)' }, + { value: 'LLE (Accurate, Slow)', label: 'LLE (Accurate, Slow)' }, + ], + defaultValue: 'HLE (Fast, Default)', + isRequired: false, + displayOrder: 3, + }, + { + name: 'internal_resolution', + label: 'Internal Resolution', + type: CustomFieldType.RANGE, + defaultValue: 1, + isRequired: false, + displayOrder: 4, + rangeMin: 1, + rangeMax: 8, + rangeUnit: 'x', + rangeDecimals: 0, + }, + { + name: 'shader_compilation', + label: 'Shader Compilation', + type: CustomFieldType.SELECT, + options: [ + { value: 'Specialized (Default)', label: 'Specialized (Default)' }, + { value: 'Hybrid Ubershaders', label: 'Hybrid Ubershaders' }, + { value: 'Exclusive Ubershaders', label: 'Exclusive Ubershaders' }, + { value: 'Skip Drawing', label: 'Skip Drawing' }, + ], + defaultValue: 'Specialized (Default)', + isRequired: false, + displayOrder: 5, + }, + { + name: 'anti_aliasing', + label: 'Anti-Aliasing', + type: CustomFieldType.SELECT, + options: [ + { value: 'Off', label: 'Off' }, + { value: '2x MSAA', label: '2x MSAA' }, + { value: '4x MSAA', label: '4x MSAA' }, + { value: '8x MSAA', label: '8x MSAA' }, + { value: '2x SSAA', label: '2x SSAA' }, + { value: '4x SSAA', label: '4x SSAA' }, + ], + defaultValue: 'Off', + isRequired: false, + displayOrder: 6, + }, + { + name: 'anisotropic_filtering', + label: 'Anisotropic Filtering', + type: CustomFieldType.RANGE, + defaultValue: 1, + isRequired: false, + displayOrder: 7, + rangeMin: 1, + rangeMax: 16, + rangeUnit: 'x', + rangeDecimals: 0, + }, + { + name: 'vsync', + label: 'VSync', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 8, + }, + { + name: 'skip_efb_access', + label: 'Skip EFB Access from CPU', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 9, + }, + { + name: 'ignore_format_changes', + label: 'Ignore Format Changes', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 10, + }, + ], + }, + { + name: 'PCSX2 Fields', + fields: [ + { + name: 'renderer', + label: 'Renderer', + type: CustomFieldType.SELECT, + options: [ + { value: 'Vulkan (Fastest)', label: 'Vulkan (Fastest)' }, + { value: 'OpenGL (Balance)', label: 'OpenGL (Balance)' }, + { value: 'Direct3D 11 (Fallback)', label: 'Direct3D 11 (Fallback)' }, + { value: 'Direct3D 12 (Fallback)', label: 'Direct3D 12 (Fallback)' }, + { + value: 'Software (Most Accurate)', + label: 'Software (Most Accurate)', + }, + ], + defaultValue: 'Vulkan (Fastest)', + isRequired: false, + displayOrder: 1, + }, + { + name: 'internal_resolution', + label: 'Internal Resolution', + type: CustomFieldType.RANGE, + defaultValue: 1, + isRequired: false, + displayOrder: 2, + rangeMin: 1, + rangeMax: 8, + rangeUnit: 'x', + rangeDecimals: 0, + }, + { + name: 'aspect_ratio', + label: 'Aspect Ratio', + type: CustomFieldType.SELECT, + options: [ + { value: 'Auto (4:3) Recommended', label: 'Auto (4:3) Recommended' }, + { + value: 'Fit to Window/Fullscreen', + label: 'Fit to Window/Fullscreen', + }, + { value: 'Widescreen (16:9)', label: 'Widescreen (16:9)' }, + ], + defaultValue: 'Auto (4:3) Recommended', + isRequired: false, + displayOrder: 3, + }, + { + name: 'texture_filtering', + label: 'Texture Filtering', + type: CustomFieldType.SELECT, + options: [ + { value: 'Bilinear (PS2) Default', label: 'Bilinear (PS2) Default' }, + { value: 'Force Bilinear', label: 'Force Bilinear' }, + { value: 'Off', label: 'Off' }, + ], + defaultValue: 'Bilinear (PS2) Default', + isRequired: false, + displayOrder: 4, + }, + { + name: 'anisotropic_filtering', + label: 'Anisotropic Filtering', + type: CustomFieldType.RANGE, + defaultValue: 1, + isRequired: false, + displayOrder: 5, + rangeMin: 1, + rangeMax: 16, + rangeUnit: 'x', + rangeDecimals: 0, + }, + { + name: 'anti_aliasing', + label: 'Anti-Aliasing (FXAA)', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 6, + }, + { + name: 'anti_blur', + label: 'Anti Blur', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 7, + }, + { + name: 'deinterlacing', + label: 'Deinterlacing', + type: CustomFieldType.SELECT, + options: [ + { value: 'Automatic (Default)', label: 'Automatic (Default)' }, + { + value: 'Native Progressive Scan', + label: 'Native Progressive Scan', + }, + { value: 'Weave', label: 'Weave' }, + { value: 'Bob', label: 'Bob' }, + { value: 'Blend', label: 'Blend' }, + ], + defaultValue: 'Automatic (Default)', + isRequired: false, + displayOrder: 8, + }, + { + name: 'ee_cyclerate', + label: 'EE Cycle Rate', + type: CustomFieldType.RANGE, + defaultValue: 0, + isRequired: false, + displayOrder: 9, + rangeMin: -3, + rangeMax: 3, + rangeUnit: '', + rangeDecimals: 0, + }, + { + name: 'vu_cycle_stealing', + label: 'VU Cycle Stealing', + type: CustomFieldType.RANGE, + defaultValue: 0, + isRequired: false, + displayOrder: 10, + rangeMin: 0, + rangeMax: 4, + rangeUnit: '', + rangeDecimals: 0, + }, + ], + }, + { + name: 'PPSSPP Fields', + fields: [ + { + name: 'backend', + label: 'Backend', + type: CustomFieldType.SELECT, + options: [ + { value: 'Vulkan (Recommended)', label: 'Vulkan (Recommended)' }, + { value: 'OpenGL (Android/PC)', label: 'OpenGL (Android/PC)' }, + { value: 'Direct3D 11 (PC)', label: 'Direct3D 11 (PC)' }, + { value: 'Direct3D 9 (PC)', label: 'Direct3D 9 (PC)' }, + ], + defaultValue: 'Vulkan (Recommended)', + isRequired: false, + displayOrder: 1, + }, + { + name: 'rendering_resolution', + label: 'Rendering Resolution', + type: CustomFieldType.RANGE, + defaultValue: 1, + isRequired: false, + displayOrder: 2, + rangeMin: 1, + rangeMax: 8, + rangeUnit: 'x', + rangeDecimals: 0, + }, + { + name: 'msaa', + label: 'Antialiasing (MSAA)', + type: CustomFieldType.SELECT, + options: [ + { value: 'Off', label: 'Off' }, + { value: '2x MSAA', label: '2x MSAA' }, + { value: '4x MSAA', label: '4x MSAA' }, + { value: '8x MSAA', label: '8x MSAA' }, + ], + defaultValue: 'Off', + isRequired: false, + displayOrder: 3, + }, + { + name: 'vsync', + label: 'VSync', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 4, + }, + { + name: 'frame_skipping', + label: 'Frame Skipping', + type: CustomFieldType.RANGE, + defaultValue: 0, + isRequired: false, + displayOrder: 5, + rangeMin: 0, + rangeMax: 5, + rangeUnit: '', + rangeDecimals: 0, + }, + { + name: 'texture_filtering', + label: 'Texture Filtering', + type: CustomFieldType.SELECT, + options: [ + { value: 'Auto (Default)', label: 'Auto (Default)' }, + { value: 'Nearest', label: 'Nearest' }, + { value: 'Linear', label: 'Linear' }, + { value: 'Auto Max Quality', label: 'Auto Max Quality' }, + ], + defaultValue: 'Auto (Default)', + isRequired: false, + displayOrder: 6, + }, + { + name: 'anisotropic_filtering', + label: 'Anisotropic Filtering', + type: CustomFieldType.RANGE, + defaultValue: 1, + isRequired: false, + displayOrder: 7, + rangeMin: 1, + rangeMax: 16, + rangeUnit: 'x', + rangeDecimals: 0, + }, + { + name: 'texture_scaling', + label: 'Texture Scaling', + type: CustomFieldType.SELECT, + options: [ + { value: 'Off', label: 'Off' }, + { value: 'xBRZ (Sharp)', label: 'xBRZ (Sharp)' }, + { value: 'Bicubic (Smooth)', label: 'Bicubic (Smooth)' }, + { value: 'Hybrid', label: 'Hybrid' }, + ], + defaultValue: 'Off', + isRequired: false, + displayOrder: 8, + }, + { + name: 'lower_resolution_effects', + label: 'Lower Resolution for Effects', + type: CustomFieldType.SELECT, + options: [ + { value: 'Off', label: 'Off' }, + { value: 'Safe', label: 'Safe' }, + { value: 'Balanced', label: 'Balanced' }, + { value: 'Aggressive', label: 'Aggressive' }, + ], + defaultValue: 'Off', + isRequired: false, + displayOrder: 9, + }, + { + name: 'multithreaded', + label: 'Multithreaded (MT)', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 10, + }, + { + name: 'fast_memory', + label: 'Fast Memory', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 11, + }, + ], + }, + { + name: 'RetroArch Fields', + fields: [ + { + name: 'video_driver', + label: 'Video Driver', + type: CustomFieldType.SELECT, + options: [ + { value: 'Vulkan', label: 'Vulkan' }, + { value: 'OpenGL', label: 'OpenGL' }, + { value: 'Direct3D 11', label: 'Direct3D 11' }, + { value: 'Direct3D 12', label: 'Direct3D 12' }, + ], + defaultValue: 'Vulkan', + isRequired: false, + displayOrder: 1, + }, + { + name: 'video_scale', + label: 'Video Scale', + type: CustomFieldType.RANGE, + defaultValue: 1, + isRequired: false, + displayOrder: 2, + rangeMin: 1, + rangeMax: 8, + rangeUnit: 'x', + rangeDecimals: 0, + }, + { + name: 'vsync', + label: 'VSync', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 3, + }, + { + name: 'frame_delay', + label: 'Frame Delay', + type: CustomFieldType.RANGE, + defaultValue: 0, + isRequired: false, + displayOrder: 4, + rangeMin: 0, + rangeMax: 15, + rangeUnit: 'ms', + rangeDecimals: 0, + }, + { + name: 'hard_gpu_sync', + label: 'Hard GPU Sync', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 5, + }, + { + name: 'gpu_sync_frames', + label: 'GPU Sync Frames', + type: CustomFieldType.RANGE, + defaultValue: 0, + isRequired: false, + displayOrder: 6, + rangeMin: 0, + rangeMax: 3, + rangeUnit: '', + rangeDecimals: 0, + }, + { + name: 'rewind', + label: 'Rewind', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 7, + }, + { + name: 'run_ahead', + label: 'Run-Ahead', + type: CustomFieldType.RANGE, + defaultValue: 0, + isRequired: false, + displayOrder: 8, + rangeMin: 0, + rangeMax: 6, + rangeUnit: 'frames', + rangeDecimals: 0, + }, + ], + }, + { + name: 'Xenia Fields', + fields: [ + { + name: 'gpu', + label: 'GPU Backend', + type: CustomFieldType.SELECT, + options: [ + { value: 'Vulkan', label: 'Vulkan' }, + { value: 'Direct3D 12', label: 'Direct3D 12' }, + ], + defaultValue: 'Vulkan', + isRequired: false, + displayOrder: 1, + }, + { + name: 'resolution_scale', + label: 'Resolution Scale', + type: CustomFieldType.RANGE, + defaultValue: 1, + isRequired: false, + displayOrder: 2, + rangeMin: 1, + rangeMax: 3, + rangeUnit: 'x', + rangeDecimals: 0, + }, + { + name: 'vsync', + label: 'VSync', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 3, + }, + { + name: 'gamma_render_target_as_srgb', + label: 'Gamma Render Target as sRGB', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 4, + }, + { + name: 'gpu_allow_invalid_fetch_constants', + label: 'Allow Invalid Fetch Constants', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 5, + }, + { + name: 'cpu', + label: 'CPU Backend', + type: CustomFieldType.SELECT, + options: [ + { value: 'x64', label: 'x64' }, + { value: 'Any', label: 'Any' }, + ], + defaultValue: 'x64', + isRequired: false, + displayOrder: 6, + }, + ], + }, + { + name: 'RPCS3 Fields', + fields: [ + { + name: 'renderer', + label: 'Renderer', + type: CustomFieldType.SELECT, + options: [ + { value: 'Vulkan', label: 'Vulkan' }, + { value: 'OpenGL', label: 'OpenGL' }, + ], + defaultValue: 'Vulkan', + isRequired: false, + displayOrder: 1, + }, + { + name: 'resolution_scale', + label: 'Resolution Scale', + type: CustomFieldType.RANGE, + defaultValue: 100, + isRequired: false, + displayOrder: 2, + rangeMin: 50, + rangeMax: 300, + rangeUnit: '%', + rangeDecimals: 0, + }, + { + name: 'anisotropic_filter', + label: 'Anisotropic Filter', + type: CustomFieldType.SELECT, + options: [ + { value: 'Automatic', label: 'Automatic' }, + { value: '2x', label: '2x' }, + { value: '4x', label: '4x' }, + { value: '8x', label: '8x' }, + { value: '16x', label: '16x' }, + ], + defaultValue: 'Automatic', + isRequired: false, + displayOrder: 3, + }, + { + name: 'anti_aliasing', + label: 'Anti-Aliasing', + type: CustomFieldType.SELECT, + options: [ + { value: 'Disabled', label: 'Disabled' }, + { value: 'FXAA', label: 'FXAA' }, + { value: 'SMAA', label: 'SMAA' }, + ], + defaultValue: 'Disabled', + isRequired: false, + displayOrder: 4, + }, + { + name: 'vsync', + label: 'VSync', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 5, + }, + { + name: 'frame_limit', + label: 'Frame Limit', + type: CustomFieldType.SELECT, + options: [ + { value: 'Off', label: 'Off' }, + { value: '30', label: '30 FPS' }, + { value: '60', label: '60 FPS' }, + { value: 'Auto', label: 'Auto' }, + ], + defaultValue: 'Auto', + isRequired: false, + displayOrder: 6, + }, + { + name: 'spu_threads', + label: 'SPU Threads', + type: CustomFieldType.RANGE, + defaultValue: 2, + isRequired: false, + displayOrder: 7, + rangeMin: 1, + rangeMax: 6, + rangeUnit: '', + rangeDecimals: 0, + }, + { + name: 'spu_block_size', + label: 'SPU Block Size', + type: CustomFieldType.SELECT, + options: [ + { value: 'Safe', label: 'Safe' }, + { value: 'Mega', label: 'Mega' }, + { value: 'Giga', label: 'Giga' }, + ], + defaultValue: 'Safe', + isRequired: false, + displayOrder: 8, + }, + ], + }, + { + name: 'Yuzu Fields', + fields: [ + { + name: 'api', + label: 'API', + type: CustomFieldType.SELECT, + options: [ + { value: 'Vulkan', label: 'Vulkan' }, + { value: 'OpenGL', label: 'OpenGL' }, + ], + defaultValue: 'Vulkan', + isRequired: false, + displayOrder: 1, + }, + { + name: 'resolution_scale', + label: 'Resolution Scale', + type: CustomFieldType.RANGE, + defaultValue: 1, + isRequired: false, + displayOrder: 2, + rangeMin: 1, + rangeMax: 8, + rangeUnit: 'x', + rangeDecimals: 0, + }, + { + name: 'anti_aliasing', + label: 'Anti-Aliasing', + type: CustomFieldType.SELECT, + options: [ + { value: 'None', label: 'None' }, + { value: 'FXAA', label: 'FXAA' }, + { value: 'SMAA', label: 'SMAA' }, + ], + defaultValue: 'None', + isRequired: false, + displayOrder: 3, + }, + { + name: 'vsync_mode', + label: 'VSync Mode', + type: CustomFieldType.SELECT, + options: [ + { value: 'Immediate (Off)', label: 'Immediate (Off)' }, + { value: 'Mailbox', label: 'Mailbox' }, + { value: 'FIFO (On)', label: 'FIFO (On)' }, + { value: 'FIFO Relaxed', label: 'FIFO Relaxed' }, + ], + defaultValue: 'FIFO (On)', + isRequired: false, + displayOrder: 4, + }, + { + name: 'async_gpu_emulation', + label: 'Async GPU Emulation', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 5, + }, + { + name: 'async_shader_compilation', + label: 'Async Shader Compilation', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 6, + }, + { + name: 'cpu_accuracy', + label: 'CPU Accuracy', + type: CustomFieldType.SELECT, + options: [ + { value: 'Auto', label: 'Auto' }, + { value: 'Accurate', label: 'Accurate' }, + { value: 'Unsafe', label: 'Unsafe' }, + { value: 'Paranoid', label: 'Paranoid' }, + ], + defaultValue: 'Auto', + isRequired: false, + displayOrder: 7, + }, + { + name: 'multicore_scheduling', + label: 'Multicore CPU Emulation', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 8, + }, + ], + }, + { + name: 'Drastic Fields', + fields: [ + { + name: 'renderer', + label: 'Renderer', + type: CustomFieldType.SELECT, + options: [ + { value: 'Software', label: 'Software' }, + { value: 'OpenGL', label: 'OpenGL' }, + { value: 'OpenGL ES', label: 'OpenGL ES' }, + ], + defaultValue: 'OpenGL', + isRequired: false, + displayOrder: 1, + }, + { + name: 'resolution_scale', + label: 'Resolution Scale', + type: CustomFieldType.RANGE, + defaultValue: 1, + isRequired: false, + displayOrder: 2, + rangeMin: 1, + rangeMax: 4, + rangeUnit: 'x', + rangeDecimals: 0, + }, + { + name: 'texture_filtering', + label: 'Texture Filtering', + type: CustomFieldType.SELECT, + options: [ + { value: 'None', label: 'None' }, + { value: 'Linear', label: 'Linear' }, + { value: 'Nearest', label: 'Nearest' }, + ], + defaultValue: 'Linear', + isRequired: false, + displayOrder: 3, + }, + { + name: 'frame_skip', + label: 'Frame Skip', + type: CustomFieldType.RANGE, + defaultValue: 0, + isRequired: false, + displayOrder: 4, + rangeMin: 0, + rangeMax: 9, + rangeUnit: '', + rangeDecimals: 0, + }, + { + name: 'threaded_3d', + label: 'Threaded 3D', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 5, + }, + { + name: 'high_resolution_3d', + label: 'High Resolution 3D', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 6, + }, + { + name: 'jit_enabled', + label: 'JIT Enabled', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 7, + }, + { + name: 'sound_enabled', + label: 'Sound Enabled', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 8, + }, + ], + }, + { + name: 'melonDS Fields', + fields: [ + { + name: 'renderer', + label: 'Renderer', + type: CustomFieldType.SELECT, + options: [ + { value: 'Software', label: 'Software' }, + { value: 'OpenGL', label: 'OpenGL' }, + ], + defaultValue: 'OpenGL', + isRequired: false, + displayOrder: 1, + }, + { + name: 'resolution_scale', + label: 'Resolution Scale', + type: CustomFieldType.RANGE, + defaultValue: 1, + isRequired: false, + displayOrder: 2, + rangeMin: 1, + rangeMax: 8, + rangeUnit: 'x', + rangeDecimals: 0, + }, + { + name: 'texture_filtering', + label: 'Texture Filtering', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 3, + }, + { + name: 'anti_aliasing', + label: 'Anti-Aliasing', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 4, + }, + { + name: 'threaded_rendering', + label: 'Threaded Rendering', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 5, + }, + { + name: 'jit_enabled', + label: 'JIT Enabled', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 6, + }, + { + name: 'dsi_mode', + label: 'DSi Mode', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 7, + }, + { + name: 'audio_interpolation', + label: 'Audio Interpolation', + type: CustomFieldType.SELECT, + options: [ + { value: 'None', label: 'None' }, + { value: 'Linear', label: 'Linear' }, + { value: 'Cosine', label: 'Cosine' }, + { value: 'Cubic', label: 'Cubic' }, + ], + defaultValue: 'Linear', + isRequired: false, + displayOrder: 8, + }, + ], + }, + { + name: 'Yaba Sanshiro Fields', + fields: [ + { + name: 'video_interface', + label: 'Video Interface', + type: CustomFieldType.SELECT, + options: [ + { value: 'Software', label: 'Software' }, + { value: 'OpenGL ES 3.0', label: 'OpenGL ES 3.0' }, + { value: 'Vulkan', label: 'Vulkan' }, + ], + defaultValue: 'OpenGL ES 3.0', + isRequired: false, + displayOrder: 1, + }, + { + name: 'resolution', + label: 'Resolution', + type: CustomFieldType.SELECT, + options: [ + { value: 'Original', label: 'Original' }, + { value: '2x', label: '2x' }, + { value: '4x', label: '4x' }, + { value: '720p', label: '720p' }, + { value: '1080p', label: '1080p' }, + ], + defaultValue: 'Original', + isRequired: false, + displayOrder: 2, + }, + { + name: 'polygon_generation', + label: 'Polygon Generation', + type: CustomFieldType.SELECT, + options: [ + { value: 'CPU', label: 'CPU' }, + { value: 'GPU Tessellation', label: 'GPU Tessellation' }, + { value: 'GPU Compute Shader', label: 'GPU Compute Shader' }, + ], + defaultValue: 'CPU', + isRequired: false, + displayOrder: 3, + }, + { + name: 'mesh_mode', + label: 'Mesh Mode', + type: CustomFieldType.SELECT, + options: [ + { value: 'Original', label: 'Original' }, + { value: 'Improved', label: 'Improved' }, + ], + defaultValue: 'Original', + isRequired: false, + displayOrder: 4, + }, + { + name: 'bandpass_filter', + label: 'Bandpass Filter', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 5, + }, + { + name: 'rotate_screen', + label: 'Rotate Screen', + type: CustomFieldType.SELECT, + options: [ + { value: '0', label: '0°' }, + { value: '90', label: '90°' }, + { value: '180', label: '180°' }, + { value: '270', label: '270°' }, + ], + defaultValue: '0', + isRequired: false, + displayOrder: 6, + }, + { + name: 'fps_mode', + label: 'FPS Mode', + type: CustomFieldType.SELECT, + options: [ + { value: 'Auto', label: 'Auto' }, + { value: '50Hz', label: '50Hz' }, + { value: '60Hz', label: '60Hz' }, + ], + defaultValue: 'Auto', + isRequired: false, + displayOrder: 7, + }, + { + name: 'sound_engine', + label: 'Sound Engine', + type: CustomFieldType.SELECT, + options: [ + { value: 'New', label: 'New' }, + { value: 'Old', label: 'Old' }, + ], + defaultValue: 'New', + isRequired: false, + displayOrder: 8, + }, + ], + }, + { + name: 'Vita3K Fields', + fields: [ + { + name: 'backend_renderer', + label: 'Backend Renderer', + type: CustomFieldType.SELECT, + options: [ + { value: 'OpenGL', label: 'OpenGL' }, + { value: 'Vulkan', label: 'Vulkan' }, + ], + defaultValue: 'OpenGL', + isRequired: false, + displayOrder: 1, + }, + { + name: 'gpu_backend', + label: 'GPU Backend', + type: CustomFieldType.SELECT, + options: [ + { value: 'Unicorn', label: 'Unicorn' }, + { value: 'Dynarmic', label: 'Dynarmic' }, + ], + defaultValue: 'Dynarmic', + isRequired: false, + displayOrder: 2, + }, + { + name: 'resolution_multiplier', + label: 'Resolution Multiplier', + type: CustomFieldType.RANGE, + defaultValue: 1, + isRequired: false, + displayOrder: 3, + rangeMin: 1, + rangeMax: 4, + rangeUnit: 'x', + rangeDecimals: 0, + }, + { + name: 'anisotropic_filtering', + label: 'Anisotropic Filtering', + type: CustomFieldType.SELECT, + options: [ + { value: '1x', label: '1x (Off)' }, + { value: '2x', label: '2x' }, + { value: '4x', label: '4x' }, + { value: '8x', label: '8x' }, + { value: '16x', label: '16x' }, + ], + defaultValue: '1x', + isRequired: false, + displayOrder: 4, + }, + { + name: 'texture_cache', + label: 'Texture Cache', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 5, + }, + { + name: 'shader_cache', + label: 'Shader Cache', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 6, + }, + { + name: 'spirv_shader', + label: 'SPIR-V Shader', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 7, + }, + { + name: 'vsync', + label: 'VSync', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 8, + }, + ], + }, + { + name: 'ShadPS4 Fields', + fields: [ + { + name: 'gpu_backend', + label: 'GPU Backend', + type: CustomFieldType.SELECT, + options: [ + { value: 'Vulkan', label: 'Vulkan' }, + { value: 'OpenGL', label: 'OpenGL' }, + ], + defaultValue: 'Vulkan', + isRequired: false, + displayOrder: 1, + }, + { + name: 'resolution_scale', + label: 'Resolution Scale', + type: CustomFieldType.RANGE, + defaultValue: 1, + isRequired: false, + displayOrder: 2, + rangeMin: 1, + rangeMax: 4, + rangeUnit: 'x', + rangeDecimals: 0, + }, + { + name: 'anisotropic_filtering', + label: 'Anisotropic Filtering', + type: CustomFieldType.SELECT, + options: [ + { value: 'Automatic', label: 'Automatic' }, + { value: '2x', label: '2x' }, + { value: '4x', label: '4x' }, + { value: '8x', label: '8x' }, + { value: '16x', label: '16x' }, + ], + defaultValue: 'Automatic', + isRequired: false, + displayOrder: 3, + }, + { + name: 'vsync', + label: 'VSync', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 4, + }, + { + name: 'enable_shader_validation', + label: 'Enable Shader Validation', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 5, + }, + { + name: 'rdoc_enable', + label: 'RenderDoc Enable', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 6, + }, + { + name: 'null_gpu', + label: 'Null GPU', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 7, + }, + { + name: 'dump_shaders', + label: 'Dump Shaders', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 8, + }, + ], + }, + { + name: 'Redream Fields', + fields: [ + { + name: 'backend', + label: 'Backend', + type: CustomFieldType.SELECT, + options: [ + { value: 'OpenGL', label: 'OpenGL' }, + { value: 'Vulkan', label: 'Vulkan' }, + ], + defaultValue: 'OpenGL', + isRequired: false, + displayOrder: 1, + }, + { + name: 'resolution', + label: 'Resolution', + type: CustomFieldType.SELECT, + options: [ + { value: '640x480', label: '640x480 (Original)' }, + { value: '1280x960', label: '1280x960 (2x)' }, + { value: '1920x1440', label: '1920x1440 (3x)' }, + { value: '2560x1920', label: '2560x1920 (4x)' }, + ], + defaultValue: '640x480', + isRequired: false, + displayOrder: 2, + }, + { + name: 'region', + label: 'Region', + type: CustomFieldType.SELECT, + options: [ + { value: 'Auto', label: 'Auto' }, + { value: 'Japan', label: 'Japan' }, + { value: 'USA', label: 'USA' }, + { value: 'Europe', label: 'Europe' }, + ], + defaultValue: 'Auto', + isRequired: false, + displayOrder: 3, + }, + { + name: 'broadcast', + label: 'Broadcast', + type: CustomFieldType.SELECT, + options: [ + { value: 'Auto', label: 'Auto' }, + { value: 'NTSC', label: 'NTSC' }, + { value: 'PAL', label: 'PAL' }, + { value: 'PAL_M', label: 'PAL-M' }, + { value: 'PAL_N', label: 'PAL-N' }, + ], + defaultValue: 'Auto', + isRequired: false, + displayOrder: 4, + }, + { + name: 'language', + label: 'Language', + type: CustomFieldType.SELECT, + options: [ + { value: 'Japanese', label: 'Japanese' }, + { value: 'English', label: 'English' }, + { value: 'German', label: 'German' }, + { value: 'French', label: 'French' }, + { value: 'Spanish', label: 'Spanish' }, + { value: 'Italian', label: 'Italian' }, + ], + defaultValue: 'English', + isRequired: false, + displayOrder: 5, + }, + { + name: 'audio_latency', + label: 'Audio Latency', + type: CustomFieldType.RANGE, + defaultValue: 100, + isRequired: false, + displayOrder: 6, + rangeMin: 20, + rangeMax: 200, + rangeUnit: 'ms', + rangeDecimals: 0, + }, + { + name: 'enable_ta_stats', + label: 'Enable TA Stats', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 7, + }, + { + name: 'enable_pvr_stats', + label: 'Enable PVR Stats', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 8, + }, + ], + }, + { + name: 'Citra Fields', + fields: [ + { + name: 'graphics_api', + label: 'Graphics API', + type: CustomFieldType.SELECT, + options: [ + { value: 'OpenGL', label: 'OpenGL' }, + { value: 'Software', label: 'Software' }, + { value: 'Vulkan', label: 'Vulkan' }, + ], + defaultValue: 'OpenGL', + isRequired: false, + displayOrder: 1, + }, + { + name: 'resolution_factor', + label: 'Resolution Factor', + type: CustomFieldType.RANGE, + defaultValue: 1, + isRequired: false, + displayOrder: 2, + rangeMin: 1, + rangeMax: 10, + rangeUnit: 'x', + rangeDecimals: 0, + }, + { + name: 'texture_filter', + label: 'Texture Filter', + type: CustomFieldType.SELECT, + options: [ + { value: 'None', label: 'None' }, + { value: 'Anime4K', label: 'Anime4K' }, + { value: 'Bicubic', label: 'Bicubic' }, + { value: 'ScaleForce', label: 'ScaleForce' }, + { value: 'xBRZ', label: 'xBRZ' }, + ], + defaultValue: 'None', + isRequired: false, + displayOrder: 3, + }, + { + name: 'texture_sampling', + label: 'Texture Sampling', + type: CustomFieldType.SELECT, + options: [ + { value: 'Game Controlled', label: 'Game Controlled' }, + { value: 'Nearest Neighbor', label: 'Nearest Neighbor' }, + { value: 'Linear', label: 'Linear' }, + ], + defaultValue: 'Game Controlled', + isRequired: false, + displayOrder: 4, + }, + { + name: 'layout_option', + label: 'Layout Option', + type: CustomFieldType.SELECT, + options: [ + { value: 'Default', label: 'Default' }, + { value: 'Single Screen', label: 'Single Screen' }, + { value: 'Large Screen', label: 'Large Screen' }, + { value: 'Side by Side', label: 'Side by Side' }, + { value: 'Separate Windows', label: 'Separate Windows' }, + ], + defaultValue: 'Default', + isRequired: false, + displayOrder: 5, + }, + { + name: 'swap_screen', + label: 'Swap Screen', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 6, + }, + { + name: 'upright_screen', + label: 'Upright Screen', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 7, + }, + { + name: 'use_hw_shader', + label: 'Use Hardware Shader', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 8, + }, + { + name: 'use_shader_jit', + label: 'Use Shader JIT', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 9, + }, + { + name: 'use_vsync_new', + label: 'Use VSync', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 10, + }, + { + name: 'shaders_accurate_mul', + label: 'Shaders Accurate Multiplication', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 11, + }, + { + name: 'use_disk_shader_cache', + label: 'Use Disk Shader Cache', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 12, + }, + ], + }, + { + name: 'DuckStation Fields', + fields: [ + { + name: 'renderer', + label: 'Renderer', + type: CustomFieldType.SELECT, + options: [ + { value: 'Vulkan (Recommended)', label: 'Vulkan (Recommended)' }, + { value: 'OpenGL', label: 'OpenGL' }, + { value: 'Direct3D 11 (PC)', label: 'Direct3D 11 (PC)' }, + { value: 'Direct3D 12 (PC)', label: 'Direct3D 12 (PC)' }, + { value: 'Software', label: 'Software' }, + ], + defaultValue: 'Vulkan (Recommended)', + isRequired: false, + displayOrder: 1, + }, + { + name: 'internal_resolution', + label: 'Internal Resolution', + type: CustomFieldType.RANGE, + defaultValue: 1, + isRequired: false, + displayOrder: 2, + rangeMin: 1, + rangeMax: 16, + rangeUnit: 'x', + rangeDecimals: 0, + }, + { + name: 'texture_filtering', + label: 'Texture Filtering', + type: CustomFieldType.SELECT, + options: [ + { value: 'Nearest-Neighbor', label: 'Nearest-Neighbor' }, + { value: 'Bilinear', label: 'Bilinear' }, + { + value: 'Bilinear (No Edge Blending)', + label: 'Bilinear (No Edge Blending)', + }, + { value: 'JINC2 (Slow)', label: 'JINC2 (Slow)' }, + { value: 'xBR (Very Slow)', label: 'xBR (Very Slow)' }, + ], + defaultValue: 'Bilinear', + isRequired: false, + displayOrder: 3, + }, + { + name: 'anti_aliasing', + label: 'Anti-Aliasing', + type: CustomFieldType.SELECT, + options: [ + { value: 'Disabled', label: 'Disabled' }, + { value: '2x MSAA', label: '2x MSAA' }, + { value: '4x MSAA', label: '4x MSAA' }, + { value: '8x MSAA', label: '8x MSAA' }, + { value: '16x MSAA', label: '16x MSAA' }, + { value: '2x SSAA', label: '2x SSAA' }, + { value: '4x SSAA', label: '4x SSAA' }, + { value: '8x SSAA', label: '8x SSAA' }, + { value: '16x SSAA', label: '16x SSAA' }, + ], + defaultValue: 'Disabled', + isRequired: false, + displayOrder: 4, + }, + { + name: 'pgxp_geometry', + label: 'PGXP Geometry Correction', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 5, + }, + { + name: 'pgxp_texture', + label: 'PGXP Texture Correction', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 6, + }, + { + name: 'widescreen_hack', + label: 'Widescreen Hack', + type: CustomFieldType.BOOLEAN, + defaultValue: false, + isRequired: false, + displayOrder: 7, + }, + { + name: 'cpu_overclock', + label: 'CPU Overclock', + type: CustomFieldType.RANGE, + defaultValue: 100, + isRequired: false, + displayOrder: 8, + rangeMin: 10, + rangeMax: 1000, + rangeUnit: '%', + rangeDecimals: 0, + }, + { + name: 'gpu_overclock', + label: 'GPU Overclock', + type: CustomFieldType.RANGE, + defaultValue: 100, + isRequired: false, + displayOrder: 9, + rangeMin: 10, + rangeMax: 2000, + rangeUnit: '%', + rangeDecimals: 0, + }, + { + name: 'cdrom_read_speedup', + label: 'CD-ROM Read Speedup', + type: CustomFieldType.RANGE, + defaultValue: 1, + isRequired: false, + displayOrder: 10, + rangeMin: 1, + rangeMax: 10, + rangeUnit: 'x', + rangeDecimals: 0, + }, + { + name: 'cdrom_seek_speedup', + label: 'CD-ROM Seek Speedup', + type: CustomFieldType.RANGE, + defaultValue: 1, + isRequired: false, + displayOrder: 11, + rangeMin: 1, + rangeMax: 20, + rangeUnit: 'x', + rangeDecimals: 0, + }, + { + name: 'vsync', + label: 'VSync', + type: CustomFieldType.BOOLEAN, + defaultValue: true, + isRequired: false, + displayOrder: 12, + }, + { + name: 'audio_backend', + label: 'Audio Backend', + type: CustomFieldType.SELECT, + options: [ + { value: 'Cubeb', label: 'Cubeb' }, + { value: 'SDL', label: 'SDL' }, + { value: 'XAudio2 (Windows)', label: 'XAudio2 (Windows)' }, + { value: 'Null', label: 'Null (No Audio)' }, + ], + defaultValue: 'Cubeb', + isRequired: false, + displayOrder: 13, + }, + { + name: 'recompiler', + label: 'CPU Recompiler', + type: CustomFieldType.SELECT, + options: [ + { + value: 'Fastest (Cached Interpreter)', + label: 'Fastest (Cached Interpreter)', + }, + { value: 'Fast (Recompiler)', label: 'Fast (Recompiler)' }, + { value: 'Accurate (Interpreter)', label: 'Accurate (Interpreter)' }, + ], + defaultValue: 'Fast (Recompiler)', + isRequired: false, + displayOrder: 14, + }, + { + name: 'memory_card_type', + label: 'Memory Card Type', + type: CustomFieldType.SELECT, + options: [ + { + value: 'Shared Between All Games', + label: 'Shared Between All Games', + }, + { value: 'Per Game', label: 'Per Game' }, + { value: 'Per Game Title', label: 'Per Game Title' }, + { value: 'None', label: 'None' }, + ], + defaultValue: 'Per Game Title', + isRequired: false, + displayOrder: 15, + }, + ], + }, +] diff --git a/prisma/seeders/devicesSeeder.ts b/prisma/seeders/devicesSeeder.ts index a14c8304d..0f8473c59 100644 --- a/prisma/seeders/devicesSeeder.ts +++ b/prisma/seeders/devicesSeeder.ts @@ -6,7 +6,14 @@ type DeviceData = { socName?: string } -const devices: DeviceData[] = [ +export const devices: DeviceData[] = [ + // Alldocube + { + brandName: 'Alldocube', + modelName: 'iPlay 70 mini Ultra', + socName: 'Snapdragon 7+ Gen 3', + }, + // Apple { brandName: 'Apple', @@ -39,7 +46,7 @@ const devices: DeviceData[] = [ { brandName: 'Apple', modelName: 'iPhone SE (3rd Gen)', - socName: 'A15 Bionic', + socName: 'Apple A15 Bionic', }, { brandName: 'Apple', @@ -124,6 +131,13 @@ const devices: DeviceData[] = [ { brandName: 'GPD', modelName: 'XP Plus', socName: 'Dimensity 1200' }, { brandName: 'GPD', modelName: 'XP', socName: 'Helio G95' }, + // Infinix + { + brandName: 'Infinix', + modelName: 'Note 50 Pro+ 5G', + socName: 'Dimensity 8350 Ultimate', + }, + // Lenovo { brandName: 'Lenovo', modelName: 'Legion Go', socName: 'AMD Z1 Extreme' }, { @@ -152,12 +166,21 @@ const devices: DeviceData[] = [ { brandName: 'OnePlus', modelName: '12R', socName: 'Snapdragon 8 Gen 2' }, { brandName: 'OnePlus', modelName: '13', socName: 'Snapdragon 8 Elite' }, { brandName: 'OnePlus', modelName: '13R', socName: 'Snapdragon 8 Gen 3' }, + { brandName: 'OnePlus', modelName: '15R', socName: 'Snapdragon 8 Gen 5' }, + { brandName: 'OnePlus', modelName: 'Ace 6', socName: 'Snapdragon 8 Elite' }, + { brandName: 'OnePlus', modelName: 'Ace 6T', socName: 'Snapdragon 8 Gen 5' }, { brandName: 'OnePlus', modelName: '9 Pro', socName: 'Snapdragon 888' }, { brandName: 'OnePlus', modelName: 'Open', socName: 'Snapdragon 8 Gen 2' }, + // OPPO + { brandName: 'Oppo', modelName: 'Find X9 Pro', socName: 'Dimensity 9500' }, + // Pimax { brandName: 'Pimax', modelName: 'Portal', socName: 'Snapdragon 8 Gen 2' }, + // Rakuten + { brandName: 'Rakuten', modelName: 'Hand 5G', socName: 'Snapdragon 480' }, + // Razer { brandName: 'Razer', modelName: 'Edge 5G', socName: 'Snapdragon 8 Gen 1' }, { brandName: 'Razer', modelName: 'Edge', socName: 'Snapdragon 8 Gen 1' }, @@ -176,7 +199,6 @@ const devices: DeviceData[] = [ { brandName: 'Realme', modelName: 'C55', socName: 'Helio G88' }, { brandName: 'Realme', modelName: 'C75', socName: 'Helio G92 Max' }, { brandName: 'Realme', modelName: 'GT 2 Pro', socName: 'Snapdragon 8 Gen 1' }, - { brandName: 'Realme', modelName: 'GT 2 Pro', socName: 'Snapdragon 8 Gen 1' }, { brandName: 'Realme', modelName: 'GT 3', socName: 'Snapdragon 8+ Gen 1' }, { brandName: 'Realme', modelName: 'GT 5 Pro', socName: 'Snapdragon 8 Gen 3' }, { brandName: 'Realme', modelName: 'GT 5', socName: 'Snapdragon 8 Gen 2' }, @@ -391,27 +413,27 @@ const devices: DeviceData[] = [ { brandName: 'Samsung', modelName: 'Galaxy Tab A7', - socName: 'MediaTek Helio P22T', + socName: 'Snapdragon 662', }, { brandName: 'Samsung', modelName: 'Galaxy Tab A7 Lite', - socName: 'Unisoc Tiger T618', + socName: 'Helio P22T', }, { brandName: 'Samsung', modelName: 'Galaxy Tab A8', - socName: 'Unisoc Tiger T618', + socName: 'Tiger T618', }, { brandName: 'Samsung', modelName: 'Galaxy Tab A9', - socName: 'Unisoc Tiger T616', + socName: 'Helio G99', }, { brandName: 'Samsung', modelName: 'Galaxy Tab A9+', - socName: 'Unisoc Tiger T616', + socName: 'Snapdragon 695', }, { brandName: 'Samsung', @@ -446,7 +468,7 @@ const devices: DeviceData[] = [ { brandName: 'Samsung', modelName: 'Galaxy Tab S10 Ultra', - socName: 'MediaTek Dimensity 9300+', + socName: 'Dimensity 9300+', }, { brandName: 'Samsung', @@ -691,27 +713,19 @@ const devices: DeviceData[] = [ // Realme { brandName: 'Realme', modelName: 'GT 7 Pro', socName: 'Snapdragon 8 Elite' }, { brandName: 'Realme', modelName: 'GT 6', socName: 'Snapdragon 8s Gen 3' }, - { brandName: 'Realme', modelName: 'GT 5 Pro', socName: 'Snapdragon 8 Gen 3' }, - { brandName: 'Realme', modelName: 'GT 5', socName: 'Snapdragon 8 Gen 2' }, { brandName: 'Realme', modelName: 'GT Neo6', socName: 'Snapdragon 8s Gen 3' }, { brandName: 'Realme', modelName: 'GT Neo5', socName: 'Snapdragon 8+ Gen 1' }, - { brandName: 'Realme', modelName: 'GT 3', socName: 'Snapdragon 8+ Gen 1' }, - { brandName: 'Realme', modelName: 'GT 2 Pro', socName: 'Snapdragon 8 Gen 1' }, { brandName: 'Realme', modelName: 'GT 2', socName: 'Snapdragon 888' }, { brandName: 'Realme', modelName: 'GT Master', socName: 'Snapdragon 778G' }, { brandName: 'Realme', modelName: 'GT', socName: 'Snapdragon 888' }, { brandName: 'Realme', modelName: '13 Pro+', socName: 'Snapdragon 7s Gen 2' }, { brandName: 'Realme', modelName: '13 Pro', socName: 'Snapdragon 7s Gen 2' }, - { brandName: 'Realme', modelName: '12 Pro+', socName: 'Dimensity 7050' }, - { brandName: 'Realme', modelName: '12 Pro', socName: 'Snapdragon 6 Gen 1' }, - { brandName: 'Realme', modelName: '11 Pro+', socName: 'Dimensity 7050' }, - { brandName: 'Realme', modelName: '11 Pro', socName: 'Dimensity 7050' }, // Nothing { brandName: 'Nothing', modelName: 'Phone (2a) Plus', - socName: 'Dimensity 7350', + socName: 'Dimensity 7350 Pro', }, { brandName: 'Nothing', modelName: 'Phone (2a)', socName: 'Dimensity 7200' }, { @@ -797,6 +811,18 @@ const devices: DeviceData[] = [ modelName: 'Galaxy S25 Ultra', socName: 'Snapdragon 8 Elite', }, + { + brandName: 'Samsung', + modelName: 'Galaxy S26 Ultra', + socName: 'Snapdragon 8 Elite Gen 5', + }, + { brandName: 'Samsung', modelName: 'Galaxy S25 FE', socName: 'Exynos 2400' }, + { brandName: 'Samsung', modelName: 'Galaxy A56 5G', socName: 'Exynos 1580' }, + { + brandName: 'Samsung', + modelName: 'Galaxy Z Fold8 Ultra', + socName: 'Snapdragon 8 Elite Gen 5', + }, { brandName: 'Samsung', modelName: 'Galaxy S25+', @@ -917,6 +943,7 @@ const devices: DeviceData[] = [ modelName: 'X1', socName: 'Intel Core Ultra 7 155H', }, + { brandName: 'OneXPlayer', modelName: '3', socName: 'Intel Arc G3 Extreme' }, { brandName: 'OneXPlayer', modelName: '2 Pro', socName: 'AMD Ryzen 7 7840U' }, { brandName: 'OneXPlayer', modelName: '2', socName: 'AMD Ryzen 7 6800U' }, { diff --git a/prisma/seeders/emulatorsSeeder.ts b/prisma/seeders/emulatorsSeeder.ts index 0b8adb032..63b138347 100644 --- a/prisma/seeders/emulatorsSeeder.ts +++ b/prisma/seeders/emulatorsSeeder.ts @@ -10,80 +10,135 @@ const emulators: EmulatorData[] = [ { name: 'AetherSX2', supportedSystemNames: ['Sony PlayStation 2'], - logo: 'aethersx2.png', + logo: 'aethersx2.webp', }, { name: 'APS3E', supportedSystemNames: ['Sony PlayStation 3'], - logo: 'aps3e.png', + logo: 'aps3e.webp', }, { name: 'ARMSX2', supportedSystemNames: ['Sony PlayStation 2'], - logo: 'armsx2.png', + logo: 'armsx2.webp', + }, + { + name: 'ARMSX3', + supportedSystemNames: ['Sony PlayStation 3'], + logo: 'armsx3.webp', + }, + { + name: 'Astris', + supportedSystemNames: ['Nintendo Switch'], + logo: 'astris.webp', }, { name: 'Azahar', supportedSystemNames: ['Nintendo 3DS'], - logo: 'azahar.png', + logo: 'azahar.webp', + }, + { + name: 'aX360e', + supportedSystemNames: ['Microsoft Xbox 360'], + logo: 'ax360e.webp', + }, + { + name: 'BannerHub v6', + supportedSystemNames: ['Microsoft Windows'], + logo: 'bannerhub.webp', }, - { name: 'Cemu', supportedSystemNames: ['Nintendo Wii U'], logo: 'cemu.png' }, - { name: 'Citra', supportedSystemNames: ['Nintendo 3DS'], logo: 'citra.png' }, + { name: 'Cemu', supportedSystemNames: ['Nintendo Wii U'], logo: 'cemu.webp' }, + { name: 'Citra', supportedSystemNames: ['Nintendo 3DS'], logo: 'citra.webp' }, { name: 'Citron', supportedSystemNames: ['Nintendo Switch'], - logo: 'citron.png', + logo: 'citron.webp', }, { name: 'Cxbx-Reloaded', supportedSystemNames: ['Microsoft Xbox'], - logo: 'cxbx.png', + logo: 'cxbx.webp', }, { name: 'Dolphin', supportedSystemNames: ['Nintendo GameCube', 'Nintendo Wii'], - logo: 'dolphin.png', + logo: 'dolphin.webp', }, { name: 'DraStic', supportedSystemNames: ['Nintendo DS'], - logo: 'drastic.png', + logo: 'drastic.webp', + }, + { + name: 'DroidDeck', + supportedSystemNames: ['Microsoft Windows'], + logo: 'droiddeck.webp', }, { name: 'DuckStation', supportedSystemNames: ['Sony PlayStation'], - logo: 'duckstation.png', + logo: 'duckstation.webp', + }, + { + name: 'Eden', + supportedSystemNames: ['Nintendo Switch', 'Nintendo Switch 2'], + logo: 'eden.webp', }, - { name: 'Eden', supportedSystemNames: ['Nintendo Switch'], logo: 'eden.png' }, { name: 'ExaGear', supportedSystemNames: ['Microsoft Windows'], - logo: 'exagear.png', + logo: 'exagear.webp', + }, + { + name: 'ePSXe', + supportedSystemNames: ['Sony PlayStation'], + logo: 'epsxe.webp', + }, + { + name: 'Fin', + supportedSystemNames: [ + 'Nintendo GameCube', + 'Nintendo Wii', + 'Nintendo Wii U', + 'Nintendo 3DS', + 'Nintendo DS', + ], + logo: 'fin.webp', }, { name: 'Flycast', supportedSystemNames: ['Sega Dreamcast'], - logo: 'flycast.png', + logo: 'flycast.webp', }, { name: 'GameHub', supportedSystemNames: ['Microsoft Windows'], - logo: 'gamefusion.png', + logo: 'gamefusion.webp', }, { name: 'GameNative', supportedSystemNames: ['Microsoft Windows'], - logo: 'gamenative.png', + logo: 'gamenative.webp', }, { name: 'Horizon', supportedSystemNames: ['Microsoft Windows'], - logo: 'horizon.png', + logo: 'horizon.webp', + }, + { + name: 'HakuX', + supportedSystemNames: ['Microsoft Xbox'], + logo: 'hakux.webp', + }, + { + name: 'JoiPlay', + supportedSystemNames: ['Microsoft Windows'], + logo: 'joiplay.webp', }, { name: 'Kenji-NX', supportedSystemNames: ['Nintendo Switch'], - logo: 'kenjinx.png', + logo: 'kenjinx.webp', }, { name: 'Lemuroid', @@ -93,52 +148,67 @@ const emulators: EmulatorData[] = [ 'Sony PlayStation', 'Sony PlayStation Portable', ], - logo: 'lemuroid.png', + logo: 'lemuroid.webp', + }, + { + name: 'Lemon', + supportedSystemNames: ['Nintendo Switch'], + logo: 'lemon.webp', }, { name: 'MiceWine', supportedSystemNames: ['Microsoft Windows'], - logo: 'micewine.png', + logo: 'micewine.webp', }, { name: 'Mobox', supportedSystemNames: ['Microsoft Windows'], - logo: 'mobox.png', + logo: 'mobox.webp', }, { name: 'NethersX2', supportedSystemNames: ['Sony PlayStation 2'], - logo: 'nethersx2.png', + logo: 'nethersx2.webp', + }, + { + name: 'NX', + supportedSystemNames: ['Nintendo Switch', 'Nintendo Switch 2'], + logo: 'nx.webp', }, { name: 'PCSX2', supportedSystemNames: ['Sony PlayStation 2'], - logo: 'pcsx2.png', + logo: 'pcsx2.webp', }, { name: 'PPSSPP', supportedSystemNames: ['Sony PlayStation Portable'], - logo: 'ppsspp.png', + logo: 'ppsspp.webp', + }, + { + name: 'PicPic', + supportedSystemNames: ['PICO-8'], + logo: 'picpic.webp', }, { name: 'Pluvia', supportedSystemNames: ['Microsoft Windows'], - logo: 'pluvia.png', + logo: 'pluvia.webp', }, { name: 'RPCS3', supportedSystemNames: ['Sony PlayStation 3'], - logo: 'rpcs3.png', + logo: 'rpcs3.webp', }, { name: 'RPCSX', supportedSystemNames: ['Sony PlayStation 3', 'Sony PlayStation 4'], - logo: 'rpcsx.png', + logo: 'rpcsx.webp', }, { name: 'Redream', supportedSystemNames: ['Sega Dreamcast'], - logo: 'redream.png', + logo: 'redream.webp', }, { name: 'RetroArch', @@ -149,84 +219,129 @@ const emulators: EmulatorData[] = [ 'Sony PlayStation Portable', 'Sony PlayStation', ], - logo: 'retroarch.png', + logo: 'retroarch.webp', }, { name: 'Ryujinx', supportedSystemNames: ['Nintendo Switch'], - logo: 'ryujinx.png', + logo: 'ryujinx.webp', + }, + { + name: "Rosalie's Mupen GUI", + supportedSystemNames: ['Nintendo 64'], + logo: 'rmg.webp', }, { name: 'ShadPS4', supportedSystemNames: ['Sony PlayStation 4'], - logo: 'shadps4.png', + logo: 'shadps4.webp', }, { name: 'Skyline', supportedSystemNames: ['Nintendo Switch'], - logo: 'skyline.png', + logo: 'skyline.webp', }, { name: 'Sudachi', supportedSystemNames: ['Nintendo Switch'], - logo: 'sudachi.png', + logo: 'sudachi.webp', }, { name: 'Sumi', supportedSystemNames: ['Nintendo Switch'], - logo: 'sumi.png', + logo: 'sumi.webp', }, { name: 'Torzu', supportedSystemNames: ['Nintendo Switch'], - logo: 'torzu.png', + logo: 'torzu.webp', + }, + { + name: 'touchHLE', + supportedSystemNames: ['Apple iOS'], + logo: 'touchhle.webp', }, { name: 'UTM', supportedSystemNames: ['Microsoft Windows'], - logo: 'utm.png', + logo: 'utm.webp', }, { name: 'Vita3K', supportedSystemNames: ['Sony PlayStation Vita'], - logo: 'vita3k.png', + logo: 'vita3k.webp', + }, + { + name: 'Vita3K+', + supportedSystemNames: ['Sony PlayStation Vita'], + logo: 'vita3k-plus.webp', + }, + { + name: 'WatermelonDS', + supportedSystemNames: ['Nintendo DS'], + logo: 'watermelonds.webp', }, { name: 'Winlator', supportedSystemNames: ['Microsoft Windows'], - logo: 'winlator.png', + logo: 'winlator.webp', + }, + { + name: 'WinNative', + supportedSystemNames: ['Microsoft Windows'], + logo: 'winnative.webp', + }, + { + name: 'WPR', + supportedSystemNames: ['Microsoft Windows Phone'], + logo: 'wpr.webp', + }, + { + name: 'X1 BOX', + supportedSystemNames: ['Microsoft Xbox'], + logo: 'x1-box.webp', }, { name: 'XBSX2', supportedSystemNames: ['Sony PlayStation 2'], - logo: 'xbsx2.png', + logo: 'xbsx2.webp', }, { name: 'Xenia', supportedSystemNames: ['Microsoft Xbox 360'], - logo: 'xenia.png', + logo: 'xenia.webp', + }, + { + name: 'XenDroid', + supportedSystemNames: ['Microsoft Xbox 360'], + logo: 'xendroid.webp', + }, + { + name: 'Xendroid+', + supportedSystemNames: ['Microsoft Xbox 360'], + logo: 'xendroid-plus.webp', }, - { name: 'Xemu', supportedSystemNames: ['Microsoft Xbox'], logo: 'xemu.png' }, + { name: 'Xemu', supportedSystemNames: ['Microsoft Xbox'], logo: 'xemu.webp' }, { name: 'Yaba Sanshiro', supportedSystemNames: ['Sega Saturn'], - logo: 'yaba.png', + logo: 'yaba.webp', }, - { name: 'Yuzu', supportedSystemNames: ['Nintendo Switch'], logo: 'yuzu.png' }, + { name: 'Yuzu', supportedSystemNames: ['Nintendo Switch'], logo: 'yuzu.webp' }, { name: 'melonDS', supportedSystemNames: ['Nintendo DS'], - logo: 'melonds.png', + logo: 'melonds.webp', }, { name: 'MeloNX', supportedSystemNames: ['Nintendo Switch'], - logo: 'melonx.png', + logo: 'melonx.webp', }, { name: 'Lime3DS', supportedSystemNames: ['Nintendo 3DS'], - logo: 'lime3ds.png', + logo: 'lime3ds.webp', }, ] diff --git a/prisma/seeders/socSeeder.ts b/prisma/seeders/socSeeder.ts index 69e8f397a..334fa2bf1 100644 --- a/prisma/seeders/socSeeder.ts +++ b/prisma/seeders/socSeeder.ts @@ -9,7 +9,7 @@ type SoCData = { gpuModel?: string } -const socs: SoCData[] = [ +export const socs: SoCData[] = [ // Qualcomm Snapdragon { name: 'Snapdragon 8 Elite Gen 5', @@ -19,6 +19,13 @@ const socs: SoCData[] = [ cpuCores: 8, gpuModel: 'Adreno 840', }, + { + name: 'Snapdragon 8 Gen 5', + manufacturer: 'Qualcomm', + architecture: 'ARM64', + processNode: '3nm', + cpuCores: 8, + }, { name: 'Snapdragon 8 Elite', manufacturer: 'Qualcomm', @@ -35,6 +42,13 @@ const socs: SoCData[] = [ cpuCores: 8, gpuModel: 'Adreno 750', }, + { + name: 'Snapdragon 7+ Gen 3', + manufacturer: 'Qualcomm', + architecture: 'ARM64', + processNode: '4nm', + cpuCores: 8, + }, { name: 'Snapdragon 7s Gen 2', manufacturer: 'Qualcomm', @@ -139,6 +153,14 @@ const socs: SoCData[] = [ cpuCores: 8, gpuModel: 'Adreno 650', }, + { + name: 'Snapdragon 855+', + manufacturer: 'Qualcomm', + architecture: 'ARM64', + processNode: '7nm', + cpuCores: 8, + gpuModel: 'Adreno 640', + }, { name: 'Snapdragon 855', manufacturer: 'Qualcomm', @@ -147,6 +169,14 @@ const socs: SoCData[] = [ cpuCores: 8, gpuModel: 'Adreno 640', }, + { + name: 'Snapdragon 782G', + manufacturer: 'Qualcomm', + architecture: 'ARM64', + processNode: '6nm', + cpuCores: 8, + gpuModel: 'Adreno 642L', + }, { name: 'Snapdragon 750G', manufacturer: 'Qualcomm', @@ -187,6 +217,14 @@ const socs: SoCData[] = [ cpuCores: 8, gpuModel: 'Adreno 610', }, + { + name: 'Snapdragon 665', + manufacturer: 'Qualcomm', + architecture: 'ARM64', + processNode: '11nm', + cpuCores: 8, + gpuModel: 'Adreno 610', + }, { name: 'Snapdragon 662', manufacturer: 'Qualcomm', @@ -253,6 +291,14 @@ const socs: SoCData[] = [ }, // MediaTek Dimensity + { + name: 'Dimensity 9500', + manufacturer: 'MediaTek', + architecture: 'ARM64', + processNode: '3nm', + cpuCores: 8, + gpuModel: 'Arm G1-Ultra', + }, { name: 'Dimensity 9300', manufacturer: 'MediaTek', @@ -325,6 +371,13 @@ const socs: SoCData[] = [ cpuCores: 8, gpuModel: 'Mali-G72 MP3', }, + { + name: 'Helio P22T', + manufacturer: 'MediaTek', + architecture: 'ARM64', + cpuCores: 8, + gpuModel: 'PowerVR GE8320', + }, { name: 'Helio P22', manufacturer: 'MediaTek', @@ -495,6 +548,14 @@ const socs: SoCData[] = [ cpuCores: 8, gpuModel: 'Mali-G78', }, + { + name: 'Exynos 1580', + manufacturer: 'Samsung', + architecture: 'ARM64', + processNode: '4nm', + cpuCores: 8, + gpuModel: 'Xclipse 540', + }, { name: 'Exynos 1480', manufacturer: 'Samsung', @@ -805,6 +866,12 @@ const socs: SoCData[] = [ }, // Intel + { + name: 'Intel Arc G3 Extreme', + manufacturer: 'Intel', + architecture: 'x86_64', + gpuModel: 'Intel Arc B390', + }, { name: 'Intel Core Ultra 7 155H', manufacturer: 'Intel', @@ -1025,6 +1092,14 @@ const socs: SoCData[] = [ cpuCores: 8, gpuModel: 'Mali-G615', }, + { + name: 'Dimensity 8350 Ultimate', + manufacturer: 'MediaTek', + architecture: 'ARM64', + processNode: '4nm', + cpuCores: 8, + gpuModel: 'Mali-G615 MC6', + }, { name: 'Dimensity 8250', manufacturer: 'MediaTek', @@ -1041,6 +1116,14 @@ const socs: SoCData[] = [ cpuCores: 8, gpuModel: 'Mali-G610', }, + { + name: 'Dimensity 8000', + manufacturer: 'MediaTek', + architecture: 'ARM64', + processNode: '5nm', + cpuCores: 8, + gpuModel: 'Mali-G610 MC6', + }, { name: 'Dimensity 8050', manufacturer: 'MediaTek', @@ -1049,6 +1132,14 @@ const socs: SoCData[] = [ cpuCores: 8, gpuModel: 'Mali-G77', }, + { + name: 'Dimensity 7350 Pro', + manufacturer: 'MediaTek', + architecture: 'ARM64', + processNode: '4nm', + cpuCores: 8, + gpuModel: 'Mali-G610 MC4', + }, { name: 'Dimensity 7300', manufacturer: 'MediaTek', @@ -1129,14 +1220,6 @@ const socs: SoCData[] = [ cpuCores: 8, gpuModel: 'Mali-G57', }, - { - name: 'Helio G88', - manufacturer: 'MediaTek', - architecture: 'ARM64', - processNode: '12nm', - cpuCores: 8, - gpuModel: 'Mali-G52', - }, { name: 'Helio G85', manufacturer: 'MediaTek', diff --git a/prisma/seeders/systemsSeeder.ts b/prisma/seeders/systemsSeeder.ts index b1579626b..b0c2b251a 100644 --- a/prisma/seeders/systemsSeeder.ts +++ b/prisma/seeders/systemsSeeder.ts @@ -8,11 +8,20 @@ type SystemData = { } const systems: SystemData[] = [ + { + name: 'Apple iOS', + key: 'apple_ios', + tgdbPlatformId: PLATFORM_MAPPINGS.tgdb.apple_ios ?? undefined, + }, { name: 'Microsoft Windows', key: 'microsoft_windows', tgdbPlatformId: PLATFORM_MAPPINGS.tgdb.microsoft_windows ?? undefined, }, + { + name: 'Microsoft Windows Phone', + key: 'microsoft_windows_phone', + }, { name: 'Microsoft Xbox 360', key: 'microsoft_xbox_360', @@ -48,6 +57,11 @@ const systems: SystemData[] = [ key: 'nintendo_switch', tgdbPlatformId: PLATFORM_MAPPINGS.tgdb.nintendo_switch ?? undefined, }, + { + name: 'Nintendo Switch 2', + key: 'nintendo_switch_2', + tgdbPlatformId: PLATFORM_MAPPINGS.tgdb.nintendo_switch_2 ?? undefined, + }, { name: 'Nintendo Wii U', key: 'nintendo_wii_u', @@ -58,6 +72,10 @@ const systems: SystemData[] = [ key: 'nintendo_wii', tgdbPlatformId: PLATFORM_MAPPINGS.tgdb.nintendo_wii ?? undefined, }, + { + name: 'PICO-8', + key: 'pico_8', + }, { name: 'Sega Dreamcast', key: 'sega_dreamcast', diff --git a/public/api-docs/mobile-openapi.json b/public/api-docs/mobile-openapi.json index 1109ec888..4e45d7afb 100644 --- a/public/api-docs/mobile-openapi.json +++ b/public/api-docs/mobile-openapi.json @@ -212,15 +212,21 @@ "format": "uuid" }, "limit": { - "type": "number", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, "default": 20 }, "offset": { - "type": "number", + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, "default": 0 }, "page": { - "type": "number" + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 }, "sortField": { "type": "string", @@ -464,7 +470,7 @@ "description": "Filter by brand ID" }, "limit": { - "type": "number", + "type": "integer", "minimum": 1, "maximum": 1000, "default": 50, @@ -488,7 +494,7 @@ "maxLength": 100 }, "limit": { - "type": "number", + "type": "integer", "minimum": 1, "maximum": 100, "default": 50 @@ -528,8 +534,9 @@ "page": { "anyOf": [ { - "type": "number", + "type": "integer", "minimum": 1, + "maximum": 9007199254740991, "default": 1 }, { @@ -538,7 +545,7 @@ ] }, "limit": { - "type": "number", + "type": "integer", "minimum": 1, "maximum": 50, "default": 20 @@ -1444,15 +1451,21 @@ "format": "uuid" }, "limit": { - "type": "number", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, "default": 20 }, "offset": { - "type": "number", + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, "default": 0 }, "page": { - "type": "number" + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 }, "sortField": { "type": "string", @@ -1670,8 +1683,9 @@ "page": { "anyOf": [ { - "type": "number", + "type": "integer", "minimum": 1, + "maximum": 9007199254740991, "default": 1 }, { @@ -1681,7 +1695,7 @@ "description": "Page number for pagination" }, "limit": { - "type": "number", + "type": "integer", "minimum": 1, "maximum": 50, "default": 20, @@ -1732,14 +1746,16 @@ "items": { "anyOf": [ { - "type": "number" + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 }, { "type": "string" } ] }, - "description": "Filter by performance IDs" + "description": "Filter by performance IDs as integers or numeric strings" }, "search": { "type": "string", @@ -1807,7 +1823,9 @@ "format": "uuid" }, "performanceId": { - "type": "number" + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 }, "notes": { "anyOf": [ @@ -1943,7 +1961,9 @@ "format": "uuid" }, "performanceId": { - "type": "number" + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 }, "notes": { "type": "string" @@ -2403,8 +2423,9 @@ "page": { "anyOf": [ { - "type": "number", + "type": "integer", "minimum": 1, + "maximum": 9007199254740991, "default": 1 }, { @@ -2414,7 +2435,7 @@ "description": "Page number for pagination" }, "limit": { - "type": "number", + "type": "integer", "minimum": 1, "maximum": 50, "default": 20, @@ -2450,8 +2471,9 @@ "page": { "anyOf": [ { - "type": "number", + "type": "integer", "minimum": 1, + "maximum": 9007199254740991, "default": 1 }, { @@ -2460,7 +2482,7 @@ ] }, "limit": { - "type": "number", + "type": "integer", "minimum": 1, "maximum": 50, "default": 20 @@ -2532,11 +2554,12 @@ "format": "uuid" }, "performanceId": { - "type": "number" + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 }, "memorySize": { "type": "integer", - "exclusiveMinimum": 0, "minimum": 1, "maximum": 256 }, @@ -2680,10 +2703,12 @@ "format": "uuid" }, "performanceId": { - "type": "number" + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 }, "memorySize": { - "type": "number", + "type": "integer", "minimum": 1, "maximum": 256 }, @@ -2948,7 +2973,7 @@ "format": "uuid" }, "limit": { - "type": "number", + "type": "integer", "minimum": 1, "maximum": 100, "default": 50 @@ -3024,7 +3049,7 @@ "format": "uuid" }, "limit": { - "type": "number", + "type": "integer", "minimum": 1, "maximum": 100, "default": 50 @@ -3093,7 +3118,7 @@ "type": "object", "properties": { "limit": { - "type": "number", + "type": "integer", "minimum": 1, "maximum": 50, "default": 20 @@ -3118,7 +3143,7 @@ "format": "uuid" }, "memorySize": { - "type": "number", + "type": "integer", "minimum": 1, "maximum": 256 }, @@ -3168,7 +3193,7 @@ "format": "uuid" }, "memorySize": { - "type": "number", + "type": "integer", "minimum": 1, "maximum": 256 }, @@ -7655,32 +7680,61 @@ "success": true, "results": [ { - "game_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "steam_app_id": "220", - "title": "Half-Life 2", - "performance": { - "id": 1, - "label": "Perfect", - "rank": 1, - "description": null - }, - "emulator": { - "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "name": "GameHub", - "logo": null - }, - "device": { - "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "modelName": "Steam Deck", - "soc": null - }, - "listing": { - "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "notes": "Runs well", - "upvoteCount": 4, - "downvoteCount": 1, - "voteCount": 5, - "successRate": 0.8 + "steamAppId": "220", + "matchStrategy": "metadata", + "game": { + "id": "00000000-0000-4000-8000-000000000001", + "title": "Half-Life 2", + "systemId": "00000000-0000-4000-8000-000000000002", + "imageUrl": null, + "boxartUrl": null, + "bannerUrl": null, + "tgdbGameId": null, + "metadata": { + "steamAppId": "220" + }, + "isErotic": false, + "status": "APPROVED", + "createdAt": "2025-01-01T00:00:00.000Z", + "system": { + "id": "00000000-0000-4000-8000-000000000002", + "name": "Microsoft Windows", + "key": "microsoft_windows" + }, + "_count": { + "listings": 1 + }, + "listings": [ + { + "id": "00000000-0000-4000-8000-000000000005", + "gameId": "00000000-0000-4000-8000-000000000001", + "deviceId": "00000000-0000-4000-8000-000000000003", + "emulatorId": "00000000-0000-4000-8000-000000000004", + "performanceId": 1, + "notes": "Runs well", + "upvoteCount": 4, + "downvoteCount": 1, + "voteCount": 5, + "successRate": 0.8, + "device": { + "id": "00000000-0000-4000-8000-000000000003", + "modelName": "Steam Deck", + "soc": null + }, + "emulator": { + "id": "00000000-0000-4000-8000-000000000004", + "name": "GameNative", + "logo": null + }, + "performance": { + "id": 1, + "label": "Perfect", + "rank": 1, + "description": null + }, + "customFieldValues": [] + } + ] } } ], diff --git a/public/assets/emulators/README.md b/public/assets/emulators/README.md new file mode 100644 index 000000000..3e662e482 --- /dev/null +++ b/public/assets/emulators/README.md @@ -0,0 +1,47 @@ +# Emulator artwork + +Icons are transparent 500 × 500 WebP files. Keep artwork attribution here when adding or replacing an icon. Review galleries, issue audits and verification results belong in the pull request. + +## Existing artwork + +The original 51 PNG icons came from the [existing asset collection](https://github.com/Producdevity/EmuReady/tree/da5e5cfb7d357b96d69cdfe2e3df10b02f2d2f91/public/assets/emulators). They were re-encoded as lossless WebP at effort 6, preserving visible pixels and alpha values. Their base filenames are unchanged. + +Existing database records and cached clients may still request `.png` filenames. The rewrite in `next.config.ts` serves those requests from the corresponding WebP file. Catalog maintainers can retire it after persisted filenames and supported clients no longer need the old paths. + +## Prepared project artwork + +Sources were retrieved on 2026-11-12. Commit-pinned project assets are used where available. Marks remain owned by their respective projects. + +| Output | Source | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| droiddeck.webp | [Project asset](https://raw.githubusercontent.com/Droid-Deck/DroidDeck/b1b6ab79317e6d752f3539863a214b1ca8cc65d8/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png) | +| hakux.webp | [Project asset](https://raw.githubusercontent.com/rfandango/hakuX/a7cdb5a975e31730d488c47f6f10440b16c1e6f4/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png) | +| bannerhub.webp | [Project asset](https://raw.githubusercontent.com/The412Banner/bannerhub-revanced/ebac0df01aaba9a3c05fae76fb9e7636fa9bdabc/patches/src/main/resources/bannerhub-icon/ic_launcher_foreground.png) | +| lemon.webp | [Project asset](https://raw.githubusercontent.com/Ghael-V/Lemon-Project/b1482ae3d446dc17c5bd68e8ad41ca3ee7563c42/dist/lemon-logo.svg) | +| touchhle.webp | [Project asset](https://raw.githubusercontent.com/touchHLE/touchHLE/ca1de89c578e79746f33c1332e28efd106c334ba/res/icon.png) | +| wpr.webp | [Project asset](https://raw.githubusercontent.com/8212369/WPR/8c52462b4e5af9fdd81e077f861b16135d1971b1/UI/WPR.UI.Android/Icon.png) | +| xendroid.webp | [Project asset](https://raw.githubusercontent.com/rfandango/XenDroid/779680ade075d2d2a4c589c6a8d57a8e26c116c0/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp) | +| openmw-ds.webp | [Project asset](https://raw.githubusercontent.com/Josh-Daniels/OpenMW-DS/857da9a372fce1a15102dd1148522ab5d76e2dcd/app/src/main/res/drawable/openmw_ds_logo.png) | +| winnative.webp | [Project asset](https://raw.githubusercontent.com/WinNative-Emu/WinNative/72d145bccff348f71b1c8baf90a180ccaa474464/app/src/standard/res/mipmap-xxxhdpi/ic_launcher_foreground.png) | +| vita3k-plus.webp | [Project asset](https://raw.githubusercontent.com/nckstwrt/Vita3K-Plus/10f932e93c724a8c66bebdb90a49149607262673/data/image/icon.png) | +| x1-box.webp | [Project asset](https://raw.githubusercontent.com/WinDroidEmulation/x1-box/4eed5f7c840073e14bf46dcd49170a20b4d5d1d0/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp) | +| astris.webp | [Project asset](https://github.com/user-attachments/assets/3f4de9db-ce3f-4b5b-bcf6-5f344aba2f1b) | +| rmg.webp | [Project asset](https://raw.githubusercontent.com/Rosalie241/RMG/d16e25474dfc56787d82f02b0fb664def58b7f2a/Artwork/RMG.svg) | +| lichii.webp | [Project asset](https://raw.githubusercontent.com/zortos293/Lichii/88764523934ce327fe2d95e86a6e717cb345c451/dist/branding/lichii_icon.png) | +| melondualds.webp | [Project asset](https://raw.githubusercontent.com/rafaelvcaetano/melonDS-android/aaf263d233e65c348d5830db32c593f8b7a8e4c9/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png) | +| zomdroid.webp | [Project asset](https://raw.githubusercontent.com/udarmolota/zomdroid/74991ab2399a52472bfb608e672ee8ac6f8f9939/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png) | +| watermelonds.webp | [Project vector mark](https://github.com/SapphireRhodonite/WatermelonDS/blob/9bd6a0db59f944cda961e1df7f6e692eb6d4e65b/app/src/main/res/drawable/ic_watermelon_mark.xml) | +| armsx3.webp | [Source artwork](https://github.com/ARMSX2/ARMSX3/blob/a2881a6069225d052131dd5c650796e2d9b792f1/assets/boot/armsx3-logo-source.png) | +| xendroid-plus.webp | [Project foreground](https://github.com/phforner0/Xendroid-Plus/blob/e4003ba664d6e625d49ace3aa7c74c38d748f61b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp) | +| ax360e.webp | [Project launcher](https://github.com/aenu1/ax360e/blob/1df063dfabd363bd082cff8501c8c78ee35851d4/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp) | +| idtech4amm.webp | [Project alternate icon](https://github.com/glKarin/com.n0n3m4.diii4a/blob/4a14e1ab186160394ed37dc8c204104bc725b218/idTech4Amm/src/main/res/drawable/icon_2025.png) | +| bizhawk.webp | [Project ICO](https://github.com/TASEmulators/BizHawk/blob/d148601a4629c2c230e41647d7c94eee794011c7/src/BizHawk.Client.EmuHawk/images/logo.ico) | +| epsxe.webp | [Official wordmark](https://www.epsxe.com/android/images/logo.png) | +| joiplay.webp | [Official mark](https://joiplay.net/assets/images/icon.png) | +| fin.webp | [Developer's App Store listing](https://apps.apple.com/us/app/id6758910777) | +| manicemu.webp | [Developer's App Store listing](https://apps.apple.com/us/app/id6743335790) | +| picpic.webp | [Developer's Google Play listing](https://play.google.com/store/apps/details?id=com.sa_moo_rai.picpic) | +| x360mobile.webp | [Project site logo](https://x360mobile.com/logo.png) | +| nx.webp | Local category wordmark: Helvetica Bold “NX”, #ef4444. NX is a collective category, not an upstream project identity. | + +Processing: use foreground layers where available, remove transparent padding, preserve aspect ratio, fit inside 460 × 460 with 20 px transparent margins, encode WebP at quality 90 with alpha quality 100 and strip metadata. Bitmap sources are conventionally resampled; no AI upscaling is used. The WatermelonDS vector was converted directly from Android paths. ARMSX3's “3” and Fin's fin were manually masked from their original artwork. White/dark launcher backgrounds were removed from HakuX, BannerHub, touchHLE and Astris; app tiles were removed from Manic EMU, aX360e, idTech4A++ and PicPic. Manic EMU and aX360e use their original brand colors for the isolated marks. Pixel art uses nearest-neighbor scaling. The Fritchii submission used Lichii's artwork; `lichii.webp` is shared. diff --git a/public/assets/emulators/aethersx2.png b/public/assets/emulators/aethersx2.png deleted file mode 100644 index 893acc85d..000000000 Binary files a/public/assets/emulators/aethersx2.png and /dev/null differ diff --git a/public/assets/emulators/aethersx2.webp b/public/assets/emulators/aethersx2.webp new file mode 100644 index 000000000..2f548bfef Binary files /dev/null and b/public/assets/emulators/aethersx2.webp differ diff --git a/public/assets/emulators/aps3e.png b/public/assets/emulators/aps3e.png deleted file mode 100644 index 2416f7875..000000000 Binary files a/public/assets/emulators/aps3e.png and /dev/null differ diff --git a/public/assets/emulators/aps3e.webp b/public/assets/emulators/aps3e.webp new file mode 100644 index 000000000..d93ce0369 Binary files /dev/null and b/public/assets/emulators/aps3e.webp differ diff --git a/public/assets/emulators/armsx2.png b/public/assets/emulators/armsx2.png deleted file mode 100644 index bfaf2b1a3..000000000 Binary files a/public/assets/emulators/armsx2.png and /dev/null differ diff --git a/public/assets/emulators/armsx2.webp b/public/assets/emulators/armsx2.webp new file mode 100644 index 000000000..a695a5e01 Binary files /dev/null and b/public/assets/emulators/armsx2.webp differ diff --git a/public/assets/emulators/armsx3.webp b/public/assets/emulators/armsx3.webp new file mode 100644 index 000000000..0d2587352 Binary files /dev/null and b/public/assets/emulators/armsx3.webp differ diff --git a/public/assets/emulators/astris.webp b/public/assets/emulators/astris.webp new file mode 100644 index 000000000..5789eed32 Binary files /dev/null and b/public/assets/emulators/astris.webp differ diff --git a/public/assets/emulators/ax360e.webp b/public/assets/emulators/ax360e.webp new file mode 100644 index 000000000..4644dbbb3 Binary files /dev/null and b/public/assets/emulators/ax360e.webp differ diff --git a/public/assets/emulators/azahar.png b/public/assets/emulators/azahar.png deleted file mode 100644 index 8a86a6bfa..000000000 Binary files a/public/assets/emulators/azahar.png and /dev/null differ diff --git a/public/assets/emulators/azahar.webp b/public/assets/emulators/azahar.webp new file mode 100644 index 000000000..29ab3e3ad Binary files /dev/null and b/public/assets/emulators/azahar.webp differ diff --git a/public/assets/emulators/bannerhub.webp b/public/assets/emulators/bannerhub.webp new file mode 100644 index 000000000..34268fd6b Binary files /dev/null and b/public/assets/emulators/bannerhub.webp differ diff --git a/public/assets/emulators/bizhawk.webp b/public/assets/emulators/bizhawk.webp new file mode 100644 index 000000000..8da9c53e7 Binary files /dev/null and b/public/assets/emulators/bizhawk.webp differ diff --git a/public/assets/emulators/cemu.png b/public/assets/emulators/cemu.png deleted file mode 100644 index 7e8623beb..000000000 Binary files a/public/assets/emulators/cemu.png and /dev/null differ diff --git a/public/assets/emulators/cemu.webp b/public/assets/emulators/cemu.webp new file mode 100644 index 000000000..5af4ce29c Binary files /dev/null and b/public/assets/emulators/cemu.webp differ diff --git a/public/assets/emulators/citra.png b/public/assets/emulators/citra.png deleted file mode 100644 index 6879b69f2..000000000 Binary files a/public/assets/emulators/citra.png and /dev/null differ diff --git a/public/assets/emulators/citra.webp b/public/assets/emulators/citra.webp new file mode 100644 index 000000000..fa7ace59d Binary files /dev/null and b/public/assets/emulators/citra.webp differ diff --git a/public/assets/emulators/citron.png b/public/assets/emulators/citron.png deleted file mode 100644 index 3f3ef59e6..000000000 Binary files a/public/assets/emulators/citron.png and /dev/null differ diff --git a/public/assets/emulators/citron.webp b/public/assets/emulators/citron.webp new file mode 100644 index 000000000..2031037bb Binary files /dev/null and b/public/assets/emulators/citron.webp differ diff --git a/public/assets/emulators/cxbx.png b/public/assets/emulators/cxbx.png deleted file mode 100644 index 96215e83f..000000000 Binary files a/public/assets/emulators/cxbx.png and /dev/null differ diff --git a/public/assets/emulators/cxbx.webp b/public/assets/emulators/cxbx.webp new file mode 100644 index 000000000..a9078c755 Binary files /dev/null and b/public/assets/emulators/cxbx.webp differ diff --git a/public/assets/emulators/default.png b/public/assets/emulators/default.png deleted file mode 100644 index d858ec1d1..000000000 Binary files a/public/assets/emulators/default.png and /dev/null differ diff --git a/public/assets/emulators/default.webp b/public/assets/emulators/default.webp new file mode 100644 index 000000000..b5b27dbe1 Binary files /dev/null and b/public/assets/emulators/default.webp differ diff --git a/public/assets/emulators/dolphin.png b/public/assets/emulators/dolphin.png deleted file mode 100644 index b9f1af033..000000000 Binary files a/public/assets/emulators/dolphin.png and /dev/null differ diff --git a/public/assets/emulators/dolphin.webp b/public/assets/emulators/dolphin.webp new file mode 100644 index 000000000..6e5c0f963 Binary files /dev/null and b/public/assets/emulators/dolphin.webp differ diff --git a/public/assets/emulators/dolphin_mmjr.png b/public/assets/emulators/dolphin_mmjr.png deleted file mode 100644 index ce69f2e63..000000000 Binary files a/public/assets/emulators/dolphin_mmjr.png and /dev/null differ diff --git a/public/assets/emulators/dolphin_mmjr.webp b/public/assets/emulators/dolphin_mmjr.webp new file mode 100644 index 000000000..55d57186b Binary files /dev/null and b/public/assets/emulators/dolphin_mmjr.webp differ diff --git a/public/assets/emulators/drastic.png b/public/assets/emulators/drastic.png deleted file mode 100644 index 1de963764..000000000 Binary files a/public/assets/emulators/drastic.png and /dev/null differ diff --git a/public/assets/emulators/drastic.webp b/public/assets/emulators/drastic.webp new file mode 100644 index 000000000..0a0f91d32 Binary files /dev/null and b/public/assets/emulators/drastic.webp differ diff --git a/public/assets/emulators/droiddeck.webp b/public/assets/emulators/droiddeck.webp new file mode 100644 index 000000000..9b001042c Binary files /dev/null and b/public/assets/emulators/droiddeck.webp differ diff --git a/public/assets/emulators/duckstation.png b/public/assets/emulators/duckstation.png deleted file mode 100644 index b0d61807e..000000000 Binary files a/public/assets/emulators/duckstation.png and /dev/null differ diff --git a/public/assets/emulators/duckstation.webp b/public/assets/emulators/duckstation.webp new file mode 100644 index 000000000..78a8c3eeb Binary files /dev/null and b/public/assets/emulators/duckstation.webp differ diff --git a/public/assets/emulators/eden.png b/public/assets/emulators/eden.png deleted file mode 100644 index 50a4b0acb..000000000 Binary files a/public/assets/emulators/eden.png and /dev/null differ diff --git a/public/assets/emulators/eden.webp b/public/assets/emulators/eden.webp new file mode 100644 index 000000000..f985398c3 Binary files /dev/null and b/public/assets/emulators/eden.webp differ diff --git a/public/assets/emulators/epsxe.webp b/public/assets/emulators/epsxe.webp new file mode 100644 index 000000000..5cb1539cf Binary files /dev/null and b/public/assets/emulators/epsxe.webp differ diff --git a/public/assets/emulators/exagear.png b/public/assets/emulators/exagear.png deleted file mode 100644 index 5878f9a07..000000000 Binary files a/public/assets/emulators/exagear.png and /dev/null differ diff --git a/public/assets/emulators/exagear.webp b/public/assets/emulators/exagear.webp new file mode 100644 index 000000000..e3ce4c982 Binary files /dev/null and b/public/assets/emulators/exagear.webp differ diff --git a/public/assets/emulators/fin.webp b/public/assets/emulators/fin.webp new file mode 100644 index 000000000..d19c5e1dc Binary files /dev/null and b/public/assets/emulators/fin.webp differ diff --git a/public/assets/emulators/flycast.png b/public/assets/emulators/flycast.png deleted file mode 100644 index 0bb299449..000000000 Binary files a/public/assets/emulators/flycast.png and /dev/null differ diff --git a/public/assets/emulators/flycast.webp b/public/assets/emulators/flycast.webp new file mode 100644 index 000000000..d6269d04f Binary files /dev/null and b/public/assets/emulators/flycast.webp differ diff --git a/public/assets/emulators/gamefusion.png b/public/assets/emulators/gamefusion.png deleted file mode 100644 index 6db08fdf8..000000000 Binary files a/public/assets/emulators/gamefusion.png and /dev/null differ diff --git a/public/assets/emulators/gamefusion.webp b/public/assets/emulators/gamefusion.webp new file mode 100644 index 000000000..ce48ec4d6 Binary files /dev/null and b/public/assets/emulators/gamefusion.webp differ diff --git a/public/assets/emulators/gamehub.png b/public/assets/emulators/gamehub.png deleted file mode 100644 index 4c4bda270..000000000 Binary files a/public/assets/emulators/gamehub.png and /dev/null differ diff --git a/public/assets/emulators/gamehub.webp b/public/assets/emulators/gamehub.webp new file mode 100644 index 000000000..1dd3b05d3 Binary files /dev/null and b/public/assets/emulators/gamehub.webp differ diff --git a/public/assets/emulators/gamenative.png b/public/assets/emulators/gamenative.png deleted file mode 100644 index 0cf2ca576..000000000 Binary files a/public/assets/emulators/gamenative.png and /dev/null differ diff --git a/public/assets/emulators/gamenative.webp b/public/assets/emulators/gamenative.webp new file mode 100644 index 000000000..2fbe3ac39 Binary files /dev/null and b/public/assets/emulators/gamenative.webp differ diff --git a/public/assets/emulators/hakux.webp b/public/assets/emulators/hakux.webp new file mode 100644 index 000000000..9d0cc9a90 Binary files /dev/null and b/public/assets/emulators/hakux.webp differ diff --git a/public/assets/emulators/horizon.png b/public/assets/emulators/horizon.png deleted file mode 100644 index 683bacf28..000000000 Binary files a/public/assets/emulators/horizon.png and /dev/null differ diff --git a/public/assets/emulators/horizon.webp b/public/assets/emulators/horizon.webp new file mode 100644 index 000000000..ce849760d Binary files /dev/null and b/public/assets/emulators/horizon.webp differ diff --git a/public/assets/emulators/idtech4amm.webp b/public/assets/emulators/idtech4amm.webp new file mode 100644 index 000000000..934a41c1e Binary files /dev/null and b/public/assets/emulators/idtech4amm.webp differ diff --git a/public/assets/emulators/joiplay.webp b/public/assets/emulators/joiplay.webp new file mode 100644 index 000000000..9a391e887 Binary files /dev/null and b/public/assets/emulators/joiplay.webp differ diff --git a/public/assets/emulators/kenjinx.png b/public/assets/emulators/kenjinx.png deleted file mode 100644 index a73c3cbb1..000000000 Binary files a/public/assets/emulators/kenjinx.png and /dev/null differ diff --git a/public/assets/emulators/kenjinx.webp b/public/assets/emulators/kenjinx.webp new file mode 100644 index 000000000..5b00ada06 Binary files /dev/null and b/public/assets/emulators/kenjinx.webp differ diff --git a/public/assets/emulators/lemon.webp b/public/assets/emulators/lemon.webp new file mode 100644 index 000000000..32410d995 Binary files /dev/null and b/public/assets/emulators/lemon.webp differ diff --git a/public/assets/emulators/lemuroid.png b/public/assets/emulators/lemuroid.png deleted file mode 100644 index c24032bc6..000000000 Binary files a/public/assets/emulators/lemuroid.png and /dev/null differ diff --git a/public/assets/emulators/lemuroid.webp b/public/assets/emulators/lemuroid.webp new file mode 100644 index 000000000..1b4c80ba3 Binary files /dev/null and b/public/assets/emulators/lemuroid.webp differ diff --git a/public/assets/emulators/lichii.webp b/public/assets/emulators/lichii.webp new file mode 100644 index 000000000..4d0087398 Binary files /dev/null and b/public/assets/emulators/lichii.webp differ diff --git a/public/assets/emulators/lime3ds.png b/public/assets/emulators/lime3ds.png deleted file mode 100644 index 7300c3a12..000000000 Binary files a/public/assets/emulators/lime3ds.png and /dev/null differ diff --git a/public/assets/emulators/lime3ds.webp b/public/assets/emulators/lime3ds.webp new file mode 100644 index 000000000..24e6561c4 Binary files /dev/null and b/public/assets/emulators/lime3ds.webp differ diff --git a/public/assets/emulators/mame.png b/public/assets/emulators/mame.png deleted file mode 100644 index 920f964bd..000000000 Binary files a/public/assets/emulators/mame.png and /dev/null differ diff --git a/public/assets/emulators/mame.webp b/public/assets/emulators/mame.webp new file mode 100644 index 000000000..4d6b66d8e Binary files /dev/null and b/public/assets/emulators/mame.webp differ diff --git a/public/assets/emulators/manicemu.webp b/public/assets/emulators/manicemu.webp new file mode 100644 index 000000000..c56a9ebd9 Binary files /dev/null and b/public/assets/emulators/manicemu.webp differ diff --git a/public/assets/emulators/melonds.png b/public/assets/emulators/melonds.png deleted file mode 100644 index 4332641b7..000000000 Binary files a/public/assets/emulators/melonds.png and /dev/null differ diff --git a/public/assets/emulators/melonds.webp b/public/assets/emulators/melonds.webp new file mode 100644 index 000000000..ea5f77338 Binary files /dev/null and b/public/assets/emulators/melonds.webp differ diff --git a/public/assets/emulators/melondualds.webp b/public/assets/emulators/melondualds.webp new file mode 100644 index 000000000..90d21d4da Binary files /dev/null and b/public/assets/emulators/melondualds.webp differ diff --git a/public/assets/emulators/melonx.png b/public/assets/emulators/melonx.png deleted file mode 100644 index eaec1bb62..000000000 Binary files a/public/assets/emulators/melonx.png and /dev/null differ diff --git a/public/assets/emulators/melonx.webp b/public/assets/emulators/melonx.webp new file mode 100644 index 000000000..88f1c8df0 Binary files /dev/null and b/public/assets/emulators/melonx.webp differ diff --git a/public/assets/emulators/micewine.png b/public/assets/emulators/micewine.png deleted file mode 100644 index 82cb87778..000000000 Binary files a/public/assets/emulators/micewine.png and /dev/null differ diff --git a/public/assets/emulators/micewine.webp b/public/assets/emulators/micewine.webp new file mode 100644 index 000000000..2c3a6a9e8 Binary files /dev/null and b/public/assets/emulators/micewine.webp differ diff --git a/public/assets/emulators/mobox.png b/public/assets/emulators/mobox.png deleted file mode 100644 index 527c92859..000000000 Binary files a/public/assets/emulators/mobox.png and /dev/null differ diff --git a/public/assets/emulators/mobox.webp b/public/assets/emulators/mobox.webp new file mode 100644 index 000000000..28483eac0 Binary files /dev/null and b/public/assets/emulators/mobox.webp differ diff --git a/public/assets/emulators/nethersx2.png b/public/assets/emulators/nethersx2.png deleted file mode 100644 index eb29a2f5c..000000000 Binary files a/public/assets/emulators/nethersx2.png and /dev/null differ diff --git a/public/assets/emulators/nethersx2.webp b/public/assets/emulators/nethersx2.webp new file mode 100644 index 000000000..8daecae04 Binary files /dev/null and b/public/assets/emulators/nethersx2.webp differ diff --git a/public/assets/emulators/nx.webp b/public/assets/emulators/nx.webp new file mode 100644 index 000000000..26e182bf3 Binary files /dev/null and b/public/assets/emulators/nx.webp differ diff --git a/public/assets/emulators/openmw-ds.webp b/public/assets/emulators/openmw-ds.webp new file mode 100644 index 000000000..d49bbb869 Binary files /dev/null and b/public/assets/emulators/openmw-ds.webp differ diff --git a/public/assets/emulators/pcsx2.png b/public/assets/emulators/pcsx2.png deleted file mode 100644 index b965a6909..000000000 Binary files a/public/assets/emulators/pcsx2.png and /dev/null differ diff --git a/public/assets/emulators/pcsx2.webp b/public/assets/emulators/pcsx2.webp new file mode 100644 index 000000000..27b050cc1 Binary files /dev/null and b/public/assets/emulators/pcsx2.webp differ diff --git a/public/assets/emulators/picpic.webp b/public/assets/emulators/picpic.webp new file mode 100644 index 000000000..cc405424f Binary files /dev/null and b/public/assets/emulators/picpic.webp differ diff --git a/public/assets/emulators/pluvia.png b/public/assets/emulators/pluvia.png deleted file mode 100644 index fb7fb5691..000000000 Binary files a/public/assets/emulators/pluvia.png and /dev/null differ diff --git a/public/assets/emulators/pluvia.webp b/public/assets/emulators/pluvia.webp new file mode 100644 index 000000000..5f8815627 Binary files /dev/null and b/public/assets/emulators/pluvia.webp differ diff --git a/public/assets/emulators/ppsspp.png b/public/assets/emulators/ppsspp.png deleted file mode 100644 index 445477d00..000000000 Binary files a/public/assets/emulators/ppsspp.png and /dev/null differ diff --git a/public/assets/emulators/ppsspp.webp b/public/assets/emulators/ppsspp.webp new file mode 100644 index 000000000..de607a61e Binary files /dev/null and b/public/assets/emulators/ppsspp.webp differ diff --git a/public/assets/emulators/redream.png b/public/assets/emulators/redream.png deleted file mode 100644 index 6193c1048..000000000 Binary files a/public/assets/emulators/redream.png and /dev/null differ diff --git a/public/assets/emulators/redream.webp b/public/assets/emulators/redream.webp new file mode 100644 index 000000000..a87da0be6 Binary files /dev/null and b/public/assets/emulators/redream.webp differ diff --git a/public/assets/emulators/retroarch.png b/public/assets/emulators/retroarch.png deleted file mode 100644 index c246a6087..000000000 Binary files a/public/assets/emulators/retroarch.png and /dev/null differ diff --git a/public/assets/emulators/retroarch.webp b/public/assets/emulators/retroarch.webp new file mode 100644 index 000000000..5d1d8f376 Binary files /dev/null and b/public/assets/emulators/retroarch.webp differ diff --git a/public/assets/emulators/rmg.webp b/public/assets/emulators/rmg.webp new file mode 100644 index 000000000..48894b083 Binary files /dev/null and b/public/assets/emulators/rmg.webp differ diff --git a/public/assets/emulators/rpcs3.png b/public/assets/emulators/rpcs3.png deleted file mode 100644 index c12d13fa5..000000000 Binary files a/public/assets/emulators/rpcs3.png and /dev/null differ diff --git a/public/assets/emulators/rpcs3.webp b/public/assets/emulators/rpcs3.webp new file mode 100644 index 000000000..702b80a7d Binary files /dev/null and b/public/assets/emulators/rpcs3.webp differ diff --git a/public/assets/emulators/rpcsx.png b/public/assets/emulators/rpcsx.png deleted file mode 100644 index e4042ae80..000000000 Binary files a/public/assets/emulators/rpcsx.png and /dev/null differ diff --git a/public/assets/emulators/rpcsx.webp b/public/assets/emulators/rpcsx.webp new file mode 100644 index 000000000..d100987a0 Binary files /dev/null and b/public/assets/emulators/rpcsx.webp differ diff --git a/public/assets/emulators/ryujinx.png b/public/assets/emulators/ryujinx.png deleted file mode 100644 index 376be0082..000000000 Binary files a/public/assets/emulators/ryujinx.png and /dev/null differ diff --git a/public/assets/emulators/ryujinx.webp b/public/assets/emulators/ryujinx.webp new file mode 100644 index 000000000..5b39825b7 Binary files /dev/null and b/public/assets/emulators/ryujinx.webp differ diff --git a/public/assets/emulators/shadps4.png b/public/assets/emulators/shadps4.png deleted file mode 100644 index 9e494b660..000000000 Binary files a/public/assets/emulators/shadps4.png and /dev/null differ diff --git a/public/assets/emulators/shadps4.webp b/public/assets/emulators/shadps4.webp new file mode 100644 index 000000000..cd6b481a9 Binary files /dev/null and b/public/assets/emulators/shadps4.webp differ diff --git a/public/assets/emulators/skyemu.png b/public/assets/emulators/skyemu.png deleted file mode 100644 index a755bdfaa..000000000 Binary files a/public/assets/emulators/skyemu.png and /dev/null differ diff --git a/public/assets/emulators/skyemu.webp b/public/assets/emulators/skyemu.webp new file mode 100644 index 000000000..aee928232 Binary files /dev/null and b/public/assets/emulators/skyemu.webp differ diff --git a/public/assets/emulators/skyline.png b/public/assets/emulators/skyline.png deleted file mode 100644 index 90538eb34..000000000 Binary files a/public/assets/emulators/skyline.png and /dev/null differ diff --git a/public/assets/emulators/skyline.webp b/public/assets/emulators/skyline.webp new file mode 100644 index 000000000..68e22d7b2 Binary files /dev/null and b/public/assets/emulators/skyline.webp differ diff --git a/public/assets/emulators/sudachi.png b/public/assets/emulators/sudachi.png deleted file mode 100644 index d6a3a4f06..000000000 Binary files a/public/assets/emulators/sudachi.png and /dev/null differ diff --git a/public/assets/emulators/sudachi.webp b/public/assets/emulators/sudachi.webp new file mode 100644 index 000000000..cd54dab8d Binary files /dev/null and b/public/assets/emulators/sudachi.webp differ diff --git a/public/assets/emulators/sumi.png b/public/assets/emulators/sumi.png deleted file mode 100644 index 9760f800c..000000000 Binary files a/public/assets/emulators/sumi.png and /dev/null differ diff --git a/public/assets/emulators/sumi.webp b/public/assets/emulators/sumi.webp new file mode 100644 index 000000000..5dbd057b0 Binary files /dev/null and b/public/assets/emulators/sumi.webp differ diff --git a/public/assets/emulators/torzu.png b/public/assets/emulators/torzu.png deleted file mode 100644 index d7f74974e..000000000 Binary files a/public/assets/emulators/torzu.png and /dev/null differ diff --git a/public/assets/emulators/torzu.webp b/public/assets/emulators/torzu.webp new file mode 100644 index 000000000..385c2e317 Binary files /dev/null and b/public/assets/emulators/torzu.webp differ diff --git a/public/assets/emulators/touchhle.webp b/public/assets/emulators/touchhle.webp new file mode 100644 index 000000000..d1f30e80d Binary files /dev/null and b/public/assets/emulators/touchhle.webp differ diff --git a/public/assets/emulators/utm.png b/public/assets/emulators/utm.png deleted file mode 100644 index bac5f18b5..000000000 Binary files a/public/assets/emulators/utm.png and /dev/null differ diff --git a/public/assets/emulators/utm.webp b/public/assets/emulators/utm.webp new file mode 100644 index 000000000..fa9ecad69 Binary files /dev/null and b/public/assets/emulators/utm.webp differ diff --git a/public/assets/emulators/vita3k-plus.webp b/public/assets/emulators/vita3k-plus.webp new file mode 100644 index 000000000..6fbee210e Binary files /dev/null and b/public/assets/emulators/vita3k-plus.webp differ diff --git a/public/assets/emulators/vita3k.png b/public/assets/emulators/vita3k.png deleted file mode 100644 index 749e797cb..000000000 Binary files a/public/assets/emulators/vita3k.png and /dev/null differ diff --git a/public/assets/emulators/vita3k.webp b/public/assets/emulators/vita3k.webp new file mode 100644 index 000000000..9c9a423bf Binary files /dev/null and b/public/assets/emulators/vita3k.webp differ diff --git a/public/assets/emulators/watermelonds.webp b/public/assets/emulators/watermelonds.webp new file mode 100644 index 000000000..c3377081a Binary files /dev/null and b/public/assets/emulators/watermelonds.webp differ diff --git a/public/assets/emulators/winlator.png b/public/assets/emulators/winlator.png deleted file mode 100644 index 156221af1..000000000 Binary files a/public/assets/emulators/winlator.png and /dev/null differ diff --git a/public/assets/emulators/winlator.webp b/public/assets/emulators/winlator.webp new file mode 100644 index 000000000..303c10229 Binary files /dev/null and b/public/assets/emulators/winlator.webp differ diff --git a/public/assets/emulators/winnative.webp b/public/assets/emulators/winnative.webp new file mode 100644 index 000000000..34dfb8982 Binary files /dev/null and b/public/assets/emulators/winnative.webp differ diff --git a/public/assets/emulators/wpr.webp b/public/assets/emulators/wpr.webp new file mode 100644 index 000000000..a85b7aae5 Binary files /dev/null and b/public/assets/emulators/wpr.webp differ diff --git a/public/assets/emulators/x1-box.webp b/public/assets/emulators/x1-box.webp new file mode 100644 index 000000000..14d9b6bc7 Binary files /dev/null and b/public/assets/emulators/x1-box.webp differ diff --git a/public/assets/emulators/x360mobile.webp b/public/assets/emulators/x360mobile.webp new file mode 100644 index 000000000..0e2c52774 Binary files /dev/null and b/public/assets/emulators/x360mobile.webp differ diff --git a/public/assets/emulators/xbsx2.png b/public/assets/emulators/xbsx2.png deleted file mode 100644 index 931add8e1..000000000 Binary files a/public/assets/emulators/xbsx2.png and /dev/null differ diff --git a/public/assets/emulators/xbsx2.webp b/public/assets/emulators/xbsx2.webp new file mode 100644 index 000000000..4c21004ff Binary files /dev/null and b/public/assets/emulators/xbsx2.webp differ diff --git a/public/assets/emulators/xemu.png b/public/assets/emulators/xemu.png deleted file mode 100644 index fca7d8be4..000000000 Binary files a/public/assets/emulators/xemu.png and /dev/null differ diff --git a/public/assets/emulators/xemu.webp b/public/assets/emulators/xemu.webp new file mode 100644 index 000000000..8de40760f Binary files /dev/null and b/public/assets/emulators/xemu.webp differ diff --git a/public/assets/emulators/xendroid-plus.webp b/public/assets/emulators/xendroid-plus.webp new file mode 100644 index 000000000..cc819b04d Binary files /dev/null and b/public/assets/emulators/xendroid-plus.webp differ diff --git a/public/assets/emulators/xendroid.webp b/public/assets/emulators/xendroid.webp new file mode 100644 index 000000000..43717bf29 Binary files /dev/null and b/public/assets/emulators/xendroid.webp differ diff --git a/public/assets/emulators/xenia.png b/public/assets/emulators/xenia.png deleted file mode 100644 index 37eb795f2..000000000 Binary files a/public/assets/emulators/xenia.png and /dev/null differ diff --git a/public/assets/emulators/xenia.webp b/public/assets/emulators/xenia.webp new file mode 100644 index 000000000..9cfd0b074 Binary files /dev/null and b/public/assets/emulators/xenia.webp differ diff --git a/public/assets/emulators/yaba.png b/public/assets/emulators/yaba.png deleted file mode 100644 index dd212eab6..000000000 Binary files a/public/assets/emulators/yaba.png and /dev/null differ diff --git a/public/assets/emulators/yaba.webp b/public/assets/emulators/yaba.webp new file mode 100644 index 000000000..d35b8a327 Binary files /dev/null and b/public/assets/emulators/yaba.webp differ diff --git a/public/assets/emulators/yuzu.png b/public/assets/emulators/yuzu.png deleted file mode 100644 index c0cdeb85f..000000000 Binary files a/public/assets/emulators/yuzu.png and /dev/null differ diff --git a/public/assets/emulators/yuzu.webp b/public/assets/emulators/yuzu.webp new file mode 100644 index 000000000..396cbbbf8 Binary files /dev/null and b/public/assets/emulators/yuzu.webp differ diff --git a/public/assets/emulators/zomdroid.webp b/public/assets/emulators/zomdroid.webp new file mode 100644 index 000000000..359c32008 Binary files /dev/null and b/public/assets/emulators/zomdroid.webp differ diff --git a/src/app/admin/api-access/components/AdminCreateKeyForm.tsx b/src/app/admin/api-access/components/AdminCreateKeyForm.tsx index bda7c1f87..b1bc288df 100644 --- a/src/app/admin/api-access/components/AdminCreateKeyForm.tsx +++ b/src/app/admin/api-access/components/AdminCreateKeyForm.tsx @@ -1,4 +1,4 @@ -import { type FormEvent, useEffect, useMemo, useState } from 'react' +import { type FormEvent, useMemo, useState } from 'react' import { Button, Input, RoleBadge, Toggle } from '@/components/ui' import { Autocomplete } from '@/components/ui/form/Autocomplete' import { API_KEY_LIMITS } from '@/data/constants' @@ -65,11 +65,6 @@ export function AdminCreateKeyForm(props: Props) { const [savedQuotas, setSavedQuotas] = useState(DEFAULT_SAVED_QUOTAS) const [dateError, setDateError] = useState(null) - useEffect(() => { - if (!selectedUser) return - setFormState((prev) => ({ ...prev, userId: selectedUser.id })) - }, [selectedUser]) - const isSystemKey = formState.isSystemKey const quotaDisplayState = useMemo(() => { if (!isSystemKey) return formState @@ -264,6 +259,7 @@ export function AdminCreateKeyForm(props: Props) {
[] isSubmitting: boolean onClose: () => void onSubmit: (state: QuotaFormState) => Promise } export function AdminQuotaDialog(props: Props) { - const key = props.keys.find((item) => item.id === props.keyId) ?? null + const apiKey = props.keys.find((item) => item.id === props.keyId) + if (!apiKey) return null + return ( + + ) +} + +type FormProps = Omit & { apiKey: Props['keys'][number] } + +function QuotaForm(props: FormProps) { const [state, setState] = useState(() => ({ - id: props.keyId, - monthlyQuota: key?.monthlyQuota ?? API_KEY_LIMITS.DEFAULT_MONTHLY, - weeklyQuota: key?.weeklyQuota ?? API_KEY_LIMITS.DEFAULT_WEEKLY, - burstQuota: key?.burstQuota ?? API_KEY_LIMITS.DEFAULT_BURST_PER_MINUTE, - expiresAt: key?.expiresAt ? new Date(key.expiresAt).toISOString().slice(0, 16) : null, + id: props.apiKey.id, + monthlyQuota: props.apiKey.monthlyQuota, + weeklyQuota: props.apiKey.weeklyQuota, + burstQuota: props.apiKey.burstQuota, + expiresAt: props.apiKey.expiresAt + ? format(new Date(props.apiKey.expiresAt), "yyyy-MM-dd'T'HH:mm") + : null, })) const [dateError, setDateError] = useState(null) - useEffect(() => { - if (!key) return - setState({ - id: key.id, - monthlyQuota: key.monthlyQuota, - weeklyQuota: key.weeklyQuota, - burstQuota: key.burstQuota, - expiresAt: key.expiresAt ? new Date(key.expiresAt).toISOString().slice(0, 16) : null, - }) - setDateError(null) - }, [key]) - return ( - (!open ? props.onClose() : null)}> + (!open ? props.onClose() : null)}> Update quotas - Adjust request ceilings and optional expiry for{' '} - {key?.name ?? 'this key'}. + Adjust request ceilings and optional expiry for {props.apiKey.name}.
diff --git a/src/app/admin/approvals/page.tsx b/src/app/admin/approvals/page.tsx index 6388c066b..f6526169d 100644 --- a/src/app/admin/approvals/page.tsx +++ b/src/app/admin/approvals/page.tsx @@ -53,6 +53,7 @@ import analytics from '@/lib/analytics' import { api } from '@/lib/api' import { logger } from '@/lib/logger' import toast from '@/lib/toast' +import { BooleanPreferenceSchema } from '@/schemas/preferences' import { type RouterOutput, type RouterInput } from '@/types/trpc' import getErrorMessage from '@/utils/getErrorMessage' import { hasPermission, PERMISSIONS } from '@/utils/permission-system' @@ -102,6 +103,7 @@ function AdminApprovalsPage() { const [showSystemIcons, setShowSystemIcons, isSystemIconsHydrated] = useLocalStorage( storageKeys.showSystemIcons, true, + BooleanPreferenceSchema, ) const emulatorLogos = useEmulatorLogos() diff --git a/src/app/admin/badges/components/BadgeModal.tsx b/src/app/admin/badges/components/BadgeModal.tsx index c14fcc01c..bd7766794 100644 --- a/src/app/admin/badges/components/BadgeModal.tsx +++ b/src/app/admin/badges/components/BadgeModal.tsx @@ -2,7 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useState, useEffect } from 'react' -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' import { z } from 'zod' import { Dialog, @@ -53,6 +53,8 @@ export default function BadgeModal({ isOpen, onClose, badge, onSuccess }: Props) }, }) + const isActive = useWatch({ control: form.control, name: 'isActive' }) + // Update form values when badge prop changes useEffect(() => { if (badge) { @@ -187,15 +189,10 @@ export default function BadgeModal({ isOpen, onClose, badge, onSuccess }: Props)
form.setValue('isActive', checked)} /> -
diff --git a/src/app/admin/brands/components/BrandModal.tsx b/src/app/admin/brands/components/BrandModal.tsx index 11298b9ef..86ba9187c 100644 --- a/src/app/admin/brands/components/BrandModal.tsx +++ b/src/app/admin/brands/components/BrandModal.tsx @@ -1,8 +1,9 @@ 'use client' -import { useState, type FormEvent, useEffect } from 'react' +import { useEffect, useRef, useState, type FormEvent } from 'react' import { Button, Input, Modal } from '@/components/ui' import { api } from '@/lib/api' +import toast from '@/lib/toast' import { type RouterInput } from '@/types/trpc' import getErrorMessage from '@/utils/getErrorMessage' @@ -17,101 +18,112 @@ interface Props { function BrandModal(props: Props) { const createBrand = api.deviceBrands.create.useMutation() const updateBrand = api.deviceBrands.update.useMutation() - const [name, setName] = useState('') - const [error, setError] = useState('') - const [success, setSuccess] = useState('') + const isPending = createBrand.isPending || updateBrand.isPending + function closeModal() { + if (!isPending) props.onClose() + } + return ( + + + + ) +} - // Update form fields when props change or modal opens - useEffect(() => { - if (props.isOpen) { - setName(props.brandName || '') - setError('') - setSuccess('') - } - }, [props.isOpen, props.brandName]) +type FormProps = Omit & { + createBrand: ReturnType + updateBrand: ReturnType +} - // Reset form when modal closes +function BrandForm(props: FormProps) { + const { createBrand, updateBrand } = props + const active = useRef(false) useEffect(() => { - if (!props.isOpen) { - setName('') - setError('') - setSuccess('') + active.current = true + return () => { + active.current = false } - }, [props.isOpen]) + }, []) + const [name, setName] = useState(props.brandName) + const [error, setError] = useState('') const handleSubmit = async (ev: FormEvent) => { ev.preventDefault() + if (createBrand.isPending || updateBrand.isPending) return setError('') - setSuccess('') try { if (props.editId) { await updateBrand.mutateAsync({ id: props.editId, name, } satisfies RouterInput['deviceBrands']['update']) - setSuccess('Brand updated!') } else { await createBrand.mutateAsync({ name, } satisfies RouterInput['deviceBrands']['create']) - setSuccess('Brand created!') } + toast.success(props.editId ? 'Brand updated!' : 'Brand created!') props.onSuccess() + if (active.current) props.onClose() } catch (err) { - setError(getErrorMessage(err, 'Failed to save brand.')) + if (active.current) setError(getErrorMessage(err, 'Failed to save brand.')) } } return ( - -
-
- - setName(e.target.value)} - required - className="w-full" - placeholder="Enter brand name" - /> -
- - {error && ( -
- {error} -
- )} + +
+ + setName(e.target.value)} + required + className="w-full" + placeholder="Enter brand name" + /> +
- {success && ( -
- {success} -
- )} + {error && ( +
{error}
+ )} -
- - -
-
-
+
+ + +
+ ) } diff --git a/src/app/admin/brands/page.tsx b/src/app/admin/brands/page.tsx index c04e92d8b..4dd9e50eb 100644 --- a/src/app/admin/brands/page.tsx +++ b/src/app/admin/brands/page.tsx @@ -86,7 +86,6 @@ function AdminBrandsPage() { const handleModalSuccess = () => { invalidateBrandQueries() - closeModal() } const handleDelete = async (id: string) => { diff --git a/src/app/admin/components/catalog-form-sessions.test.tsx b/src/app/admin/components/catalog-form-sessions.test.tsx new file mode 100644 index 000000000..d24544ad8 --- /dev/null +++ b/src/app/admin/components/catalog-form-sessions.test.tsx @@ -0,0 +1,121 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import BrandModal from '../brands/components/BrandModal' +import PerformanceScaleModal from '../performance/components/PerformanceScaleModal' +import SocModal from '../socs/components/SocModal' +import SystemModal from '../systems/components/SystemModal' + +vi.mock('@/lib/api', () => { + const mutation = () => ({ mutateAsync: vi.fn(), isPending: false }) + return { + api: Object.fromEntries( + ['deviceBrands', 'performanceScales', 'systems', 'socs'].map((name) => [ + name, + { create: { useMutation: mutation }, update: { useMutation: mutation } }, + ]), + ), + } +}) + +type Session = { isOpen: boolean; secondRecord?: boolean } + +const cases = [ + { + name: 'SoC', + field: 'Name *', + first: 'Snapdragon 8 Gen 3', + second: 'Dimensity 9400', + render: (session: Session) => ( + + ), + }, + { + name: 'brand', + field: 'Brand Name', + first: 'Retroid', + second: 'AYN', + render: (session: Session) => ( + + ), + }, + { + name: 'performance scale', + field: 'Performance Level', + first: 'Perfect', + second: 'Great', + render: (session: Session) => ( + + ), + }, + { + name: 'system', + field: 'System Name', + first: 'Nintendo Switch', + second: 'Nintendo Switch 2', + render: (session: Session) => ( + + ), + }, +] + +describe.each(cases)('$name form session', (entry) => { + it('starts with the original values after closing and reopening', () => { + const view = render(entry.render({ isOpen: true })) + expect(screen.getByLabelText(entry.field)).toHaveValue(entry.first) + fireEvent.change(screen.getByLabelText(entry.field), { target: { value: 'Unsaved draft' } }) + + view.rerender(entry.render({ isOpen: false })) + view.rerender(entry.render({ isOpen: true })) + + expect(screen.getByLabelText(entry.field)).toHaveValue(entry.first) + }) + + it('does not carry a draft into a different record', () => { + const view = render(entry.render({ isOpen: true })) + fireEvent.change(screen.getByLabelText(entry.field), { target: { value: 'Unsaved draft' } }) + + view.rerender(entry.render({ isOpen: true, secondRecord: true })) + + expect(screen.getByLabelText(entry.field)).toHaveValue(entry.second) + }) +}) diff --git a/src/app/admin/components/moderation-form-sessions.test.tsx b/src/app/admin/components/moderation-form-sessions.test.tsx new file mode 100644 index 000000000..c9868429d --- /dev/null +++ b/src/app/admin/components/moderation-form-sessions.test.tsx @@ -0,0 +1,209 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PERMISSIONS } from '@/utils/permission-system' +import { Role, type User } from '@orm' +import { AdminQuotaDialog } from '../api-access/components/AdminQuotaDialog' +import CreateBanModal from '../user-bans/components/CreateBanModal' +import EditVerifiedDeveloperModal from '../verified-developers/components/EditVerifiedDeveloperModal' + +const mocks = vi.hoisted( + (): { + preselectedUser: Pick | undefined + createBan: ReturnType + } => ({ preselectedUser: undefined, createBan: vi.fn() }), +) +vi.mock('@clerk/nextjs', () => ({ useUser: () => ({ user: { id: 'clerk-1' } }) })) +vi.mock('@/lib/toast', () => ({ default: { error: vi.fn(), success: vi.fn() } })) +vi.mock('@/lib/api', () => ({ + api: { + users: { + me: { + useQuery: () => ({ + isSuccess: true, + data: { role: Role.SUPER_ADMIN, permissions: [PERMISSIONS.MANAGE_USER_BANS] }, + }), + }, + searchUsers: { useQuery: () => ({ data: [], isFetching: false }) }, + getUserById: { useQuery: () => ({ data: mocks.preselectedUser }) }, + }, + userBans: { create: { useMutation: () => ({ mutate: mocks.createBan, isPending: false }) } }, + emulators: { + get: { + useQuery: () => ({ data: { emulators: [{ id: 'emulator-1', name: 'Emulator One' }] } }), + }, + }, + verifiedDevelopers: { + updateVerifiedDeveloper: { useMutation: () => ({ mutateAsync: vi.fn(), isPending: false }) }, + }, + }, +})) + +beforeEach(() => { + mocks.preselectedUser = undefined + vi.clearAllMocks() +}) + +describe('quota form sessions', () => { + it('keeps an unchanged expiry at the same instant in a non-UTC timezone', async () => { + const previous = process.env.TZ + const clock = vi.spyOn(Date, 'now').mockReturnValue(new Date('2026-11-01T12:00:00Z').getTime()) + process.env.TZ = 'America/New_York' + try { + const onSubmit = vi.fn().mockResolvedValue(undefined) + const onClose = vi.fn() + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })) + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ expiresAt: '2027-01-15T15:30:00.000Z' }), + ), + ) + } finally { + clock.mockRestore() + if (previous === undefined) delete process.env.TZ + else process.env.TZ = previous + } + }) + + const key = { + id: 'key-1', + name: 'First key', + monthlyQuota: 100, + weeklyQuota: 20, + burstQuota: 5, + expiresAt: null, + } + const callbacks = { onClose: vi.fn(), onSubmit: vi.fn(), isSubmitting: false } + + it('keeps a draft during a refetch and resets it after closing', () => { + const view = render() + fireEvent.change(screen.getByDisplayValue('100'), { target: { value: '333' } }) + const refreshed = { ...key, monthlyQuota: 200 } + view.rerender() + expect(screen.getByDisplayValue('333')).toBeInTheDocument() + view.rerender() + view.rerender() + expect(screen.getByDisplayValue('200')).toBeInTheDocument() + }) + + it('starts with the selected key’s quotas when switching records', () => { + const second = { ...key, id: 'key-2', monthlyQuota: 400 } + const view = render() + fireEvent.change(screen.getByDisplayValue('100'), { target: { value: '333' } }) + view.rerender() + expect(screen.getByDisplayValue('400')).toBeInTheDocument() + expect(screen.queryByDisplayValue('333')).not.toBeInTheDocument() + }) +}) + +describe('verified developer sessions', () => { + const record = { + id: 'verification-1', + emulatorId: 'emulator-1', + notes: 'Original notes', + user: { name: 'Developer', email: 'developer@example.com' }, + } + const callbacks = { onClose: vi.fn(), onSuccess: vi.fn() } + + it('keeps edited notes across a refetch and resets them on reopening', () => { + const view = render( + , + ) + fireEvent.change(screen.getByLabelText('Notes (Optional)'), { + target: { value: 'Unsaved draft' }, + }) + view.rerender( + , + ) + expect(screen.getByLabelText('Notes (Optional)')).toHaveValue('Unsaved draft') + view.rerender( + , + ) + view.rerender() + expect(screen.getByLabelText('Notes (Optional)')).toHaveValue('Original notes') + }) + + it('clears a draft when selecting a different verification', () => { + const view = render( + , + ) + fireEvent.change(screen.getByLabelText('Notes (Optional)'), { + target: { value: 'Unsaved draft' }, + }) + view.rerender( + , + ) + expect(screen.getByLabelText('Notes (Optional)')).toHaveValue('Second record') + }) +}) + +describe('preselected ban targets', () => { + it('does not select an unnamed preselected user', () => { + mocks.preselectedUser = { + id: 'user-1', + name: null, + role: Role.USER, + profileImage: null, + trustScore: 10, + } + render() + fireEvent.change( + screen.getByPlaceholderText('Provide a clear reason for banning this user...'), + { target: { value: 'Spam' } }, + ) + expect(screen.getByRole('button', { name: 'Ban User Permanently' })).toBeDisabled() + }) + + const callbacks = { onClose: vi.fn(), onSuccess: vi.fn() } + + it('shows an asynchronously loaded target without copying it into form state', () => { + const view = render() + expect(screen.getByPlaceholderText('Search users by name or email...')).toHaveValue('') + mocks.preselectedUser = { + id: 'user-1', + name: 'Target User', + role: Role.USER, + profileImage: null, + trustScore: 10, + } + view.rerender() + expect(screen.getByPlaceholderText('Search users by name or email...')).toHaveValue( + 'Target User', + ) + }) + + it('does not retain the previous user while a different target is loading', () => { + mocks.preselectedUser = { + id: 'user-1', + name: 'First User', + role: Role.USER, + profileImage: null, + trustScore: 10, + } + const view = render() + view.rerender() + expect(screen.getByPlaceholderText('Search users by name or email...')).toHaveValue('') + expect(screen.getByRole('button', { name: 'Ban User Permanently' })).toBeDisabled() + }) +}) diff --git a/src/app/admin/components/pending-form-submissions.test.tsx b/src/app/admin/components/pending-form-submissions.test.tsx new file mode 100644 index 000000000..631b0d29c --- /dev/null +++ b/src/app/admin/components/pending-form-submissions.test.tsx @@ -0,0 +1,227 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { useState, type ComponentProps } from 'react' +import { beforeEach, expect, it, vi } from 'vitest' +import { PERMISSIONS } from '@/utils/permission-system' +import { Role } from '@orm' +import BrandModal from '../brands/components/BrandModal' +import CategoryFormModal from '../emulators/[emulatorId]/custom-fields/components/CategoryFormModal' +import PerformanceScaleModal from '../performance/components/PerformanceScaleModal' +import SocModal from '../socs/components/SocModal' +import SystemModal from '../systems/components/SystemModal' +import CreateBanModal from '../user-bans/components/CreateBanModal' +import EditVerifiedDeveloperModal from '../verified-developers/components/EditVerifiedDeveloperModal' +import VerifyDeveloperModal from '../verified-developers/components/VerifyDeveloperModal' +import type * as UI from '@/components/ui' +import type * as Form from '@/components/ui/form' + +const mocks = vi.hoisted(() => ({ submitted: vi.fn(), completion: Promise.withResolvers() })) +function useDeferredMutation() { + const [isPending, setPending] = useState(false) + async function mutateAsync(input: unknown) { + mocks.submitted(input) + setPending(true) + try { + await mocks.completion.promise + } finally { + setPending(false) + } + } + return { mutateAsync, isPending } +} +vi.mock('@clerk/nextjs', () => ({ useUser: () => ({ user: { id: 'clerk-user' } }) })) +vi.mock('@/lib/toast', () => ({ default: { success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/api', () => ({ + api: { + useUtils: () => ({ + users: { searchUsers: { fetch: vi.fn().mockResolvedValue([]) } }, + customFieldCategories: { + getByEmulator: { invalidate: vi.fn().mockResolvedValue(undefined) }, + }, + }), + users: { + me: { + useQuery: () => ({ + isSuccess: true, + data: { role: Role.SUPER_ADMIN, permissions: [PERMISSIONS.MANAGE_USER_BANS] }, + }), + }, + getUserById: { + useQuery: () => ({ + data: { + id: 'user-1', + name: 'Target User', + role: Role.USER, + profileImage: null, + trustScore: 10, + }, + }), + }, + searchUsers: { useQuery: () => ({ data: [], isFetching: false }) }, + }, + deviceBrands: { + create: { useMutation: useDeferredMutation }, + update: { useMutation: useDeferredMutation }, + }, + systems: { + create: { useMutation: useDeferredMutation }, + update: { useMutation: useDeferredMutation }, + }, + socs: { + create: { useMutation: useDeferredMutation }, + update: { useMutation: useDeferredMutation }, + }, + customFieldCategories: { + create: { useMutation: useDeferredMutation }, + update: { useMutation: useDeferredMutation }, + }, + userBans: { create: { useMutation: useDeferredMutation } }, + emulators: { + get: { + useQuery: () => ({ data: { emulators: [{ id: 'emulator-1', name: 'Emulator One' }] } }), + }, + }, + performanceScales: { + create: { useMutation: useDeferredMutation }, + update: { useMutation: useDeferredMutation }, + }, + verifiedDevelopers: { + verifyDeveloper: { useMutation: useDeferredMutation }, + updateVerifiedDeveloper: { useMutation: useDeferredMutation }, + }, + }, +})) +vi.mock('@/components/ui/form', async (importOriginal) => ({ + ...(await importOriginal()), + Autocomplete: (props: Pick, 'onChange'>) => ( + + ), +})) +beforeEach(() => { + vi.clearAllMocks() + mocks.completion = Promise.withResolvers() +}) + +const cases = [ + 'performance', + 'developer', + 'brand', + 'system', + 'soc', + 'category', + 'ban', + 'editDeveloper', +] as const +it.each( + cases.flatMap((kind) => [ + { kind, reopen: false }, + { kind, reopen: true }, + ]), +)('keeps $kind submission state across a reopen=$reopen session', async ({ kind, reopen }) => { + const onClose = vi.fn() + const onSuccess = vi.fn() + function view(isOpen: boolean) { + const callbacks = { isOpen, onClose, onSuccess } + switch (kind) { + case 'performance': + return ( + + ) + case 'developer': + return + case 'brand': + return + case 'system': + return ( + + ) + case 'soc': + return ( + + ) + case 'category': + return + case 'ban': + return + case 'editDeveloper': + return ( + + ) + } + } + const rendered = render(view(true)) + if (kind === 'developer') { + fireEvent.click(screen.getByRole('button', { name: 'Choose user' })) + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'emulator-1' } }) + } + if (kind === 'category') + fireEvent.change(screen.getByLabelText('Category Name'), { target: { value: 'Graphics' } }) + if (kind === 'ban') + fireEvent.change( + screen.getByPlaceholderText('Provide a clear reason for banning this user...'), + { target: { value: 'Spam' } }, + ) + const submitLabel = { + performance: 'Save', + developer: 'Verify Developer', + brand: 'Save', + system: 'Save', + soc: 'Save', + category: 'Create', + ban: 'Ban User Permanently', + editDeveloper: 'Update', + }[kind] + fireEvent.click(screen.getByRole('button', { name: submitLabel })) + await waitFor(() => expect(mocks.submitted).toHaveBeenCalledOnce()) + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onClose).not.toHaveBeenCalled() + if (reopen) { + rendered.rerender(view(false)) + rendered.rerender(view(true)) + } + if (reopen && kind === 'ban') + fireEvent.change( + screen.getByPlaceholderText('Provide a clear reason for banning this user...'), + { target: { value: 'New reason' } }, + ) + expect(screen.getByRole('button', { name: 'Loading...' })).toBeDisabled() + await act(async () => { + mocks.completion.resolve() + }) + expect(onSuccess).toHaveBeenCalledOnce() + expect(onClose).toHaveBeenCalledTimes(reopen ? 0 : 1) + expect(screen.getByRole('button', { name: submitLabel })).toBeEnabled() +}) diff --git a/src/app/admin/components/processed-reports/ProcessedReportsAdminPage.tsx b/src/app/admin/components/processed-reports/ProcessedReportsAdminPage.tsx index a978e312e..5ccfc7465 100644 --- a/src/app/admin/components/processed-reports/ProcessedReportsAdminPage.tsx +++ b/src/app/admin/components/processed-reports/ProcessedReportsAdminPage.tsx @@ -23,6 +23,7 @@ import { useLocalStorage, type ColumnDefinition, } from '@/hooks' +import { BooleanPreferenceSchema } from '@/schemas/preferences' import { hasPermission, PERMISSIONS } from '@/utils/permission-system' import { hasRolePermission } from '@/utils/permissions' import { ApprovalStatus, Role } from '@orm' @@ -64,6 +65,7 @@ export function ProcessedReportsAdminPage( const [showSystemIcons, setShowSystemIcons, isSystemIconsHydrated] = useLocalStorage( storageKeys.showSystemIcons, true, + BooleanPreferenceSchema, ) const emulatorLogos = useEmulatorLogos() const [showOverrideModal, setShowOverrideModal] = useState(false) diff --git a/src/app/admin/custom-field-templates/components/CustomFieldTemplateFormModal.test.tsx b/src/app/admin/custom-field-templates/components/CustomFieldTemplateFormModal.test.tsx new file mode 100644 index 000000000..9c529ee65 --- /dev/null +++ b/src/app/admin/custom-field-templates/components/CustomFieldTemplateFormModal.test.tsx @@ -0,0 +1,413 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { useState } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { type RouterOutput } from '@/types/trpc' +import { CustomFieldType } from '@orm' +import CustomFieldTemplateFormModal from './CustomFieldTemplateFormModal' + +type Template = RouterOutput['customFieldTemplates']['byId'] +let template: Template | undefined +let error: Error | null +let fetching = false +const refetch = vi.fn() +const saving = vi.hoisted(() => ({ completion: Promise.withResolvers(), submit: vi.fn() })) +function useSaveMutation() { + const [isPending, setPending] = useState(false) + return { + isPending, + mutateAsync: async (input: unknown) => { + saving.submit(input) + setPending(true) + try { + await saving.completion.promise + } finally { + setPending(false) + } + }, + } +} +vi.mock('@sentry/nextjs', () => ({ captureMessage: vi.fn() })) +vi.mock('@/lib/api', () => ({ + api: { + useUtils: () => ({ + customFieldTemplates: { + get: { invalidate: vi.fn().mockResolvedValue(undefined) }, + byId: { invalidate: vi.fn().mockResolvedValue(undefined) }, + }, + }), + customFieldTemplates: { + byId: { + useQuery: () => ({ + data: template, + error, + isError: Boolean(error), + isFetching: fetching, + refetch, + }), + }, + create: { useMutation: useSaveMutation }, + update: { useMutation: useSaveMutation }, + }, + }, +})) + +function record(id: string, name: string): Template { + const date = new Date('2026-01-01') + return { + id, + name, + description: 'Template description', + createdAt: date, + fields: [ + { + id: 'field-1', + name: 'accuracy', + label: 'Accuracy', + type: CustomFieldType.SELECT, + options: [{ value: 'quality', label: 'Quality' }], + defaultValue: 'quality', + placeholder: null, + rangeMin: null, + rangeMax: null, + rangeUnit: null, + rangeDecimals: null, + isRequired: false, + categoryName: 'Graphics', + categoryOrder: 0, + displayOrder: 0, + }, + ], + } +} + +function rangeRecord(defaultValue: number | string): Template { + const result = record('template-1', 'Graphics') + const field = result.fields[0] + if (!field) throw new Error('Missing fixture field') + result.fields = [ + { + ...field, + type: CustomFieldType.RANGE, + options: null, + defaultValue, + rangeMin: -100, + rangeMax: 100, + rangeDecimals: 2, + }, + ] + return result +} + +const onClose = vi.fn() +beforeEach(() => { + template = undefined + saving.completion = Promise.withResolvers() + error = null + fetching = false + vi.clearAllMocks() +}) + +describe('template form sessions', () => { + it.each([ + { bound: 'minimum', intermediate: '-1', final: '-1000', defaultValue: -50 }, + { bound: 'maximum', intermediate: '1', final: '1000', defaultValue: 50 }, + ])('preserves a range default during intermediate $bound edits', async (testCase) => { + template = rangeRecord(testCase.defaultValue) + render() + const bound = screen.getByPlaceholderText(testCase.bound === 'minimum' ? '0' : '100') + const defaultInput = screen.getByRole('spinbutton', { name: 'Accuracy default value' }) + fireEvent.change(bound, { target: { value: testCase.intermediate } }) + expect(defaultInput).toHaveValue(testCase.defaultValue) + fireEvent.change(bound, { target: { value: testCase.final } }) + fireEvent.click(screen.getByRole('button', { name: 'Update Template' })) + await waitFor(() => + expect(saving.submit).toHaveBeenCalledWith( + expect.objectContaining({ + fields: [expect.objectContaining({ defaultValue: testCase.defaultValue })], + }), + ), + ) + await act(async () => saving.completion.resolve()) + }) + + it.each([ + { value: '', expected: null }, + { value: '5', expected: 5 }, + ])('allows repairing an excluded range default with $value', async (testCase) => { + template = rangeRecord(50) + render() + fireEvent.change(screen.getByPlaceholderText('100'), { target: { value: '10' } }) + fireEvent.click(screen.getByRole('button', { name: 'Update Template' })) + expect(screen.getByRole('alert')).toHaveTextContent( + 'Default value must be a number within the range', + ) + expect(saving.submit).not.toHaveBeenCalled() + fireEvent.change(screen.getByRole('spinbutton', { name: 'Accuracy default value' }), { + target: { value: testCase.value }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Update Template' })) + await waitFor(() => + expect(saving.submit).toHaveBeenCalledWith( + expect.objectContaining({ + fields: [expect.objectContaining({ defaultValue: testCase.expected })], + }), + ), + ) + await act(async () => saving.completion.resolve()) + }) + + it('loads a historical range string as an editable numeric default', async () => { + template = rangeRecord('1.5') + render() + expect(screen.getByRole('spinbutton', { name: 'Accuracy default value' })).toHaveValue(1.5) + fireEvent.click(screen.getByRole('button', { name: 'Update Template' })) + await waitFor(() => + expect(saving.submit).toHaveBeenCalledWith( + expect.objectContaining({ fields: [expect.objectContaining({ defaultValue: 1.5 })] }), + ), + ) + await act(async () => saving.completion.resolve()) + }) + + it.each(['edit', 'remove'] as const)( + 'clears a dropdown default when its selected option is changed by %s', + async (operation) => { + template = record('template-1', 'Graphics') + const field = template.fields[0] + if (!field) throw new Error('Missing fixture field') + field.options = [ + { value: 'quality', label: 'Quality' }, + { value: 'fast', label: 'Fast' }, + ] + render( + , + ) + expect(screen.getByRole('combobox', { name: 'Accuracy default value' })).toHaveValue( + 'quality', + ) + if (operation === 'edit') + fireEvent.change(screen.getAllByPlaceholderText('Value')[0], { + target: { value: 'balanced' }, + }) + else fireEvent.click(screen.getAllByRole('button', { name: 'Remove option' })[0]) + expect(screen.getByRole('combobox', { name: 'Accuracy default value' })).toHaveValue('') + fireEvent.click(screen.getByRole('button', { name: 'Update Template' })) + await waitFor(() => + expect(saving.submit).toHaveBeenCalledWith( + expect.objectContaining({ fields: [expect.objectContaining({ defaultValue: null })] }), + ), + ) + await act(async () => saving.completion.resolve()) + }, + ) + + it('creates a range with a zero maximum without replacing it with a fallback', async () => { + render() + fireEvent.change(screen.getByLabelText(/^Template Name/), { target: { value: 'Cycles' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add Field' })) + fireEvent.change(screen.getByPlaceholderText('e.g., driver_version'), { + target: { value: 'cycles' }, + }) + fireEvent.change(screen.getByRole('combobox'), { target: { value: CustomFieldType.RANGE } }) + fireEvent.change(screen.getByPlaceholderText('0'), { target: { value: '-3' } }) + fireEvent.change(screen.getByPlaceholderText('100'), { target: { value: '0' } }) + fireEvent.click(screen.getByRole('button', { name: 'Create Template' })) + await waitFor(() => + expect(saving.submit).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Cycles', + fields: [expect.objectContaining({ rangeMin: -3, rangeMax: 0, defaultValue: null })], + }), + ), + ) + await act(async () => saving.completion.resolve()) + }) + + it('submits numeric defaults, ranges, placeholders and categories unchanged', async () => { + template = record('template-1', 'Graphics') + const field = template.fields[0] + if (!field) throw new Error('Missing fixture field') + template.fields = [ + { + ...field, + type: CustomFieldType.RANGE, + options: null, + defaultValue: 1.5, + rangeMin: 0.25, + rangeMax: 4, + rangeUnit: 'x', + rangeDecimals: 2, + categoryOrder: 3, + }, + { + ...field, + id: 'driver', + name: 'driver', + label: 'Driver', + type: CustomFieldType.TEXT, + options: null, + defaultValue: 'custom', + placeholder: 'Enter a driver', + categoryOrder: 4, + }, + ] + render() + fireEvent.change(screen.getByLabelText(/^Description/), { target: { value: '' } }) + fireEvent.click(screen.getByRole('button', { name: 'Update Template' })) + await waitFor(() => + expect(saving.submit).toHaveBeenCalledWith( + expect.objectContaining({ + description: null, + fields: [ + expect.objectContaining({ + defaultValue: 1.5, + rangeMin: 0.25, + rangeMax: 4, + rangeUnit: 'x', + rangeDecimals: 2, + categoryName: 'Graphics', + categoryOrder: 3, + }), + expect.objectContaining({ + placeholder: 'Enter a driver', + categoryName: 'Graphics', + categoryOrder: 4, + }), + ], + }), + ), + ) + await act(async () => saving.completion.resolve()) + }) + + it('requires repairing a missing range bound instead of inventing one when editing', () => { + template = record('template-1', 'Incomplete range') + const field = template.fields[0] + if (!field) throw new Error('Missing fixture field') + template.fields = [ + { + ...field, + type: CustomFieldType.RANGE, + options: null, + defaultValue: null, + rangeMin: null, + rangeMax: 4, + }, + ] + render() + expect(screen.getByPlaceholderText('0')).toHaveValue(null) + fireEvent.click(screen.getByRole('button', { name: 'Update Template' })) + expect(screen.getByText('Minimum value must be less than maximum value')).toBeInTheDocument() + expect(saving.submit).not.toHaveBeenCalled() + }) + + it('preserves a pending save and does not close a reopened session on its completion', async () => { + template = record('template-1', 'Template') + const props = { templateIdToEdit: 'template-1', onClose } + const view = render() + fireEvent.click(screen.getByRole('button', { name: 'Update Template' })) + await waitFor(() => expect(saving.submit).toHaveBeenCalledOnce()) + view.rerender() + view.rerender() + expect(screen.getByRole('button', { name: /Saving/ })).toBeDisabled() + await act(async () => { + saving.completion.resolve() + }) + expect(onClose).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: 'Update Template' })).toBeEnabled() + }) + + it('shows a not-found result when the requested template was deleted', () => { + error = new Error('Custom field template not found') + render() + expect(screen.getByText('Custom field template not found')).toBeInTheDocument() + expect(screen.queryByText('Loading template...')).not.toBeInTheDocument() + }) + + it('waits for a stale cached record to refresh before starting a draft', () => { + template = record('template-1', 'Stale cached template') + fetching = true + const props = { isOpen: true, templateIdToEdit: 'template-1', onClose } + const view = render() + expect(screen.queryByLabelText(/^Template Name/)).not.toBeInTheDocument() + fetching = false + template = record('template-1', 'Refreshed template') + view.rerender() + expect(screen.getByLabelText(/^Template Name/)).toHaveValue('Refreshed template') + fireEvent.change(screen.getByLabelText(/^Template Name/), { target: { value: 'My draft' } }) + fetching = true + view.rerender() + expect(screen.getByLabelText(/^Template Name/)).toHaveValue('My draft') + }) + + it('initializes an edit form after its record loads', () => { + const view = render( + , + ) + expect(screen.getByText('Loading template...')).toBeInTheDocument() + template = record('template-1', 'Original template') + view.rerender( + , + ) + expect(screen.getByLabelText(/^Template Name/)).toHaveValue('Original template') + expect(screen.getAllByDisplayValue('Quality').length).toBeGreaterThan(0) + }) + + it('preserves a draft during refetch and resets it when reopening', () => { + template = record('template-1', 'Original template') + const view = render( + , + ) + fireEvent.change(screen.getByLabelText(/^Template Name/), { + target: { value: 'Unsaved draft' }, + }) + template = record('template-1', 'Refreshed template') + view.rerender( + , + ) + expect(screen.getByLabelText(/^Template Name/)).toHaveValue('Unsaved draft') + view.rerender( + , + ) + view.rerender( + , + ) + expect(screen.getByLabelText(/^Template Name/)).toHaveValue('Refreshed template') + }) + + it('does not initialize another record from stale query data', () => { + template = record('template-1', 'First template') + const view = render( + , + ) + view.rerender( + , + ) + expect(screen.getByText('Loading template...')).toBeInTheDocument() + expect(screen.queryByDisplayValue('First template')).not.toBeInTheDocument() + template = record('template-2', 'Second template') + view.rerender( + , + ) + expect(screen.getByLabelText(/^Template Name/)).toHaveValue('Second template') + }) + + it('starts a new template with empty values even if edit data was cached', () => { + template = record('template-1', 'Cached edit') + render() + expect(screen.getByLabelText(/^Template Name/)).toHaveValue('') + }) + + it('shows a retry action instead of an editable empty form on load failure', () => { + error = new Error('Template unavailable') + render() + expect(screen.getByText('Template unavailable')).toBeInTheDocument() + expect(screen.queryByLabelText(/^Template Name/)).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Please try again' })) + expect(refetch).toHaveBeenCalledOnce() + }) +}) diff --git a/src/app/admin/custom-field-templates/components/CustomFieldTemplateFormModal.tsx b/src/app/admin/custom-field-templates/components/CustomFieldTemplateFormModal.tsx index c3a5e5caf..174b15f66 100644 --- a/src/app/admin/custom-field-templates/components/CustomFieldTemplateFormModal.tsx +++ b/src/app/admin/custom-field-templates/components/CustomFieldTemplateFormModal.tsx @@ -18,30 +18,24 @@ import { } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { Plus, Trash2, GripVertical } from 'lucide-react' -import { useState, useEffect, type FormEvent } from 'react' +import { useEffect, useRef, useState, type FormEvent } from 'react' import { toSnakeCase } from 'remeda' -import { Button, Input, Modal } from '@/components/ui' +import { type z } from 'zod' +import { Button, ErrorFallback, Input, LoadingSpinner, Modal } from '@/components/ui' +import { type TemplateFieldSchema } from '@/features/emulators/custom-field-templates/shared/template.schemas' +import { + CustomFieldAttributesSchema, + normalizeRangeDefault, + supportsPlaceholder, +} from '@/features/emulators/shared/custom-field-attributes.schemas' import { api } from '@/lib/api' -import { type FilterValue } from '@/schemas/common' +import toast from '@/lib/toast' +import { FilterValueSchema, type FilterValue } from '@/schemas/common' +import { type RouterOutput } from '@/types/trpc' +import getErrorMessage from '@/utils/getErrorMessage' import { CustomFieldType } from '@orm' -interface TemplateField { - name: string - label: string - type: CustomFieldType - categoryName?: string - categoryOrder?: number - options: FilterValue[] - defaultValue?: string | boolean | number | null - placeholder?: string - isRequired: boolean - displayOrder: number - // Range-specific fields - rangeMin?: number - rangeMax?: number - rangeUnit?: string - rangeDecimals?: number - // Track if user has manually edited fields +type TemplateField = z.output & { userEditedName?: boolean userEditedLabel?: boolean } @@ -162,6 +156,7 @@ function SortableTemplateOptionItem({ type="button" variant="destructive" size="sm" + aria-label="Remove option" onClick={() => onRemove(fieldIndex, optionIndex)} > @@ -170,17 +165,125 @@ function SortableTemplateOptionItem({ ) } +type TemplateRecord = RouterOutput['customFieldTemplates']['byId'] +type Mutations = { + createMutation: ReturnType + updateMutation: ReturnType +} +type FormProps = Omit & Mutations & { template: TemplateRecord | null } + +function initialFields(template: TemplateRecord | null): TemplateField[] { + return ( + template?.fields.map((field) => { + const options = FilterValueSchema.array().safeParse(field.options) + const value = field.defaultValue + const defaultValue = + typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number' + ? value + : null + return { + name: field.name, + label: field.label, + type: field.type, + categoryName: field.categoryName ?? '', + categoryOrder: field.categoryOrder, + options: options.success ? options.data : [], + defaultValue: + field.type === CustomFieldType.RANGE ? normalizeRangeDefault(defaultValue) : defaultValue, + placeholder: field.placeholder ?? '', + isRequired: field.isRequired, + displayOrder: field.displayOrder, + rangeMin: field.rangeMin, + rangeMax: field.rangeMax, + rangeUnit: field.rangeUnit, + rangeDecimals: field.rangeDecimals, + } + }) ?? [] + ) +} + function CustomFieldTemplateFormModal(props: Props) { - const [templateName, setTemplateName] = useState('') - const [templateDescription, setTemplateDescription] = useState('') - const [fields, setFields] = useState([]) - const [errors, setErrors] = useState<{ [key: string]: string }>({}) - const [isSubmitting, setIsSubmitting] = useState(false) + const createMutation = api.customFieldTemplates.create.useMutation() + const updateMutation = api.customFieldTemplates.update.useMutation() + const isPending = createMutation.isPending || updateMutation.isPending + function closeModal() { + if (!isPending) props.onClose() + } + return ( + + {props.isOpen && ( + + )} + + ) +} - const customFieldTemplateQuery = api.customFieldTemplates.byId.useQuery( - { id: props.templateIdToEdit! }, - { enabled: !!props.templateIdToEdit }, +function TemplateSession(props: Omit & Mutations) { + const query = api.customFieldTemplates.byId.useQuery( + { id: props.templateIdToEdit ?? '' }, + { enabled: Boolean(props.templateIdToEdit) }, + ) + const [template, setTemplate] = useState(null) + if ( + !template && + !query.isFetching && + !query.isError && + query.data && + query.data.id === props.templateIdToEdit + ) + setTemplate(query.data) + if (props.templateIdToEdit && !template) { + if (query.isError) { + return ( + { + void query.refetch() + }} + /> + ) + } + return + } + return ( + ) +} + +function TemplateForm(props: FormProps) { + const { createMutation, updateMutation } = props + const active = useRef(false) + useEffect(() => { + active.current = true + return () => { + active.current = false + } + }, []) + const [templateName, setTemplateName] = useState(props.template?.name ?? '') + const [templateDescription, setTemplateDescription] = useState(props.template?.description ?? '') + const [fields, setFields] = useState(() => initialFields(props.template)) + const [errors, setErrors] = useState<{ [key: string]: string }>({}) + const isSubmitting = createMutation.isPending || updateMutation.isPending const utils = api.useUtils() @@ -191,77 +294,18 @@ function CustomFieldTemplateFormModal(props: Props) { }), ) - const createMutation = api.customFieldTemplates.create.useMutation({ - onSuccess: () => { - utils.customFieldTemplates.get.invalidate().catch(console.error) - utils.customFieldTemplates.byId - .invalidate({ id: props.templateIdToEdit! }) - .catch(console.error) - resetForm() - props.onClose() - }, - onError: (error) => { - setErrors({ general: error.message }) - setIsSubmitting(false) - }, - }) - - const updateMutation = api.customFieldTemplates.update.useMutation({ - onSuccess: () => { - utils.customFieldTemplates.get.invalidate().catch(console.error) + function handleSaved() { + utils.customFieldTemplates.get.invalidate().catch(console.error) + if (props.templateIdToEdit) { utils.customFieldTemplates.byId - .invalidate({ id: props.templateIdToEdit! }) + .invalidate({ id: props.templateIdToEdit }) .catch(console.error) - resetForm() - props.onClose() - }, - onError: (error) => { - setErrors({ general: error.message }) - setIsSubmitting(false) - }, - }) - - function resetForm() { - setTemplateName('') - setTemplateDescription('') - setFields([]) - setErrors({}) - setIsSubmitting(false) - } - - useEffect(() => { - if (props.isOpen && !props.templateIdToEdit) { - resetForm() } - }, [props.isOpen, props.templateIdToEdit]) - - useEffect(() => { - if (!customFieldTemplateQuery.data) return setErrors({}) - setTemplateName(customFieldTemplateQuery.data.name) - setTemplateDescription(customFieldTemplateQuery.data.description ?? '') - setFields( - customFieldTemplateQuery.data.fields.map((field) => ({ - name: field.name, - label: field.label, - type: field.type, - categoryName: field.categoryName ?? '', - categoryOrder: field.categoryOrder ?? 0, - options: (field.options as { value: string; label: string }[]) || [], - defaultValue: - field.defaultValue === undefined - ? null - : (field.defaultValue as string | boolean | number | null), - placeholder: field.placeholder ?? '', - isRequired: field.isRequired, - displayOrder: field.displayOrder, - rangeMin: field.rangeMin ?? 0, - rangeMax: field.rangeMax ?? 100, - rangeUnit: field.rangeUnit ?? '', - rangeDecimals: field.rangeDecimals ?? 0, - })), + toast.success( + props.templateIdToEdit ? 'Template updated successfully!' : 'Template created successfully!', ) - setErrors({}) - }, [customFieldTemplateQuery.data]) + if (active.current) props.onClose() + } function validateForm() { const newErrors: { [key: string]: string } = {} @@ -319,19 +363,27 @@ function CustomFieldTemplateFormModal(props: Props) { const min = field.rangeMin ?? 0 const max = field.rangeMax ?? 100 - if (min >= max) { + if (field.rangeMin === null || field.rangeMax === null || min >= max) { newErrors[`field-${index}-rangeMin`] = 'Minimum value must be less than maximum value' newErrors[`field-${index}-rangeMax`] = 'Maximum value must be greater than minimum value' } + if ( + field.defaultValue !== null && + (typeof field.defaultValue !== 'number' || + !Number.isFinite(field.defaultValue) || + field.defaultValue < min || + field.defaultValue > max) + ) { + newErrors[`field-${index}-defaultValue`] = + 'Default value must be a number within the range' + } + if (field.rangeUnit && field.rangeUnit.length > 10) { newErrors[`field-${index}-rangeUnit`] = 'Unit must be 10 characters or less' } - if ( - field.rangeDecimals !== undefined && - (field.rangeDecimals < 0 || field.rangeDecimals > 5) - ) { + if (field.rangeDecimals !== null && (field.rangeDecimals < 0 || field.rangeDecimals > 5)) { newErrors[`field-${index}-rangeDecimals`] = 'Decimal places must be between 0 and 5' } } @@ -364,38 +416,28 @@ function CustomFieldTemplateFormModal(props: Props) { return Object.keys(newErrors).length === 0 } - function handleSubmit(ev: FormEvent) { + async function handleSubmit(ev: FormEvent) { ev.preventDefault() if (!validateForm() || isSubmitting) return - setIsSubmitting(true) setErrors({}) const templateData = { name: templateName.trim(), - description: templateDescription.trim() || undefined, + description: templateDescription.trim() || null, fields: fields.map((field, index) => ({ name: field.name.trim().toLowerCase(), label: field.label.trim(), type: field.type, categoryName: field.categoryName?.trim() || undefined, - categoryOrder: field.categoryOrder ?? 0, + categoryOrder: field.categoryOrder, options: field.type === CustomFieldType.SELECT ? field.options.filter((opt) => opt.value.trim() && opt.label.trim()) : undefined, - defaultValue: - field.defaultValue === null - ? undefined - : typeof field.defaultValue === 'number' - ? String(field.defaultValue) - : field.defaultValue, - placeholder: ( - [CustomFieldType.TEXT, CustomFieldType.TEXTAREA, CustomFieldType.URL] as CustomFieldType[] - ).includes(field.type) - ? field.placeholder || undefined - : undefined, + defaultValue: field.defaultValue, + placeholder: supportsPlaceholder(field.type) ? field.placeholder || undefined : undefined, isRequired: field.isRequired, displayOrder: index, // Range-specific fields @@ -406,13 +448,18 @@ function CustomFieldTemplateFormModal(props: Props) { })), } - if (props.templateIdToEdit) { - updateMutation.mutate({ - id: props.templateIdToEdit, - ...templateData, - }) - } else { - createMutation.mutate(templateData) + try { + if (props.templateIdToEdit) { + await updateMutation.mutateAsync({ + id: props.templateIdToEdit, + ...templateData, + }) + } else { + await createMutation.mutateAsync(templateData) + } + handleSaved() + } catch (error) { + if (active.current) setErrors({ general: getErrorMessage(error) }) } } @@ -456,21 +503,26 @@ function CustomFieldTemplateFormModal(props: Props) { } function updateField(index: number, updates: Partial) { - setFields( - fields.map((field, i) => - i === index - ? { - ...field, - ...updates, - options: - updates.type === CustomFieldType.SELECT - ? (updates.options ?? field.options) - : updates.type - ? [] - : (updates.options ?? field.options), - } - : field, - ), + setFields((currentFields) => + currentFields.map((field, i) => { + if (i !== index) return field + const updated = { + ...field, + ...updates, + options: + updates.type === CustomFieldType.SELECT + ? (updates.options ?? field.options) + : updates.type + ? [] + : (updates.options ?? field.options), + } + if ( + updated.type === CustomFieldType.SELECT && + !updated.options.some((option) => option.value === updated.defaultValue) + ) + updated.defaultValue = null + return updated + }), ) if (updates.type && updates.type !== CustomFieldType.SELECT) { @@ -529,576 +581,586 @@ function CustomFieldTemplateFormModal(props: Props) { updateField(fieldIndex, { options: newOptions }) } - if (!props.isOpen) return null - return ( - - {props.templateIdToEdit && customFieldTemplateQuery.isPending ? ( -
-
+
+ {errors.general && ( +
+ {errors.general}
- ) : ( - - {errors.general && ( -
- {errors.general} -
- )} - -
- - setTemplateName(ev.target.value)} - placeholder="e.g., X86 Emulator Fields" - maxLength={100} - className={errors.templateName ? 'border-red-300 dark:border-red-600' : ''} - aria-invalid={!!errors.templateName} - aria-describedby={errors.templateName ? 'templateName-error' : undefined} - /> - {errors.templateName && ( - - )} -
+ )} + +
+ + setTemplateName(ev.target.value)} + placeholder="e.g., X86 Emulator Fields" + maxLength={100} + className={errors.templateName ? 'border-red-300 dark:border-red-600' : ''} + aria-invalid={!!errors.templateName} + aria-describedby={errors.templateName ? 'templateName-error' : undefined} + /> + {errors.templateName && ( + + )} +
+ +
+ + setTemplateDescription(ev.target.value)} + placeholder="Describe what this template is for…" + rows={3} + maxLength={500} + className={errors.templateDescription ? 'border-red-300 dark:border-red-600' : ''} + aria-invalid={!!errors.templateDescription} + aria-describedby={errors.templateDescription ? 'templateDescription-error' : undefined} + /> + {errors.templateDescription && ( + + )} +
-
- - setTemplateDescription(ev.target.value)} - placeholder="Describe what this template is for…" - rows={3} - maxLength={500} - className={errors.templateDescription ? 'border-red-300 dark:border-red-600' : ''} - aria-invalid={!!errors.templateDescription} - aria-describedby={ - errors.templateDescription ? 'templateDescription-error' : undefined - } - /> - {errors.templateDescription && ( - - )} +
+
+

+ Template Fields * +

+ +
+ + {errors.fields && ( +
+ {errors.fields}
+ )} -
-
-

- Template Fields * -

- -
+
+ {fields.map((field, fieldIndex) => ( +
+
+
+ + Field {fieldIndex + 1} +
+
+ +
+
+ + { + const newName = ev.target.value + // Auto-generate label if user hasn't manually edited it + if (!field.userEditedLabel && newName) { + const words = newName + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + updateField(fieldIndex, { + name: newName, + label: words.join(' '), + userEditedName: true, // Mark name as user-edited + }) + } else { + updateField(fieldIndex, { + name: newName, + userEditedName: true, // Mark name as user-edited + }) + } + }} + placeholder="e.g., driver_version" + maxLength={50} + className={ + errors[`field-${fieldIndex}-name`] ? 'border-red-300 dark:border-red-600' : '' + } + /> + {errors[`field-${fieldIndex}-name`] && ( +

+ {errors[`field-${fieldIndex}-name`]} +

+ )} +
+
+ + { + const newLabel = ev.target.value + // Auto-generate name only if user hasn't manually edited it + if (!field.userEditedName && newLabel) { + updateField(fieldIndex, { + label: newLabel, + name: toSnakeCase(newLabel), + userEditedLabel: true, // Mark label as user-edited + }) + } else { + updateField(fieldIndex, { + label: newLabel, + userEditedLabel: true, // Mark label as user-edited + }) + } + }} + placeholder="e.g., Driver Version" + maxLength={100} + className={ + errors[`field-${fieldIndex}-label`] + ? 'border-red-300 dark:border-red-600' + : '' + } + /> + {errors[`field-${fieldIndex}-label`] && ( +

+ {errors[`field-${fieldIndex}-label`]} +

+ )} +
+
- {errors.fields && ( -
- {errors.fields} +
+ + updateField(fieldIndex, { categoryName: ev.target.value })} + placeholder="e.g., Graphics, Performance, Audio" + maxLength={100} + /> +

+ Suggest a category name to organize this field +

- )} - -
- {fields.map((field, fieldIndex) => ( -
-
-
- - Field {fieldIndex + 1} -
-
-
+ {/* Placeholder for TEXT, TEXTAREA, and URL fields */} + {(field.type === CustomFieldType.TEXT || + field.type === CustomFieldType.TEXTAREA || + field.type === CustomFieldType.URL) && ( +
+ + + updateField(fieldIndex, { + placeholder: ev.target.value, + }) + } + placeholder={`e.g., Enter ${field.label.toLowerCase()}...`} + maxLength={200} + className={ + errors[`field-${fieldIndex}-placeholder`] + ? 'border-red-300 dark:border-red-600' + : '' + } + /> + {errors[`field-${fieldIndex}-placeholder`] && ( +

+ {errors[`field-${fieldIndex}-placeholder`]} +

+ )} +
+ )} + + {/* Default Value Section */} + {(field.type === CustomFieldType.TEXT || + field.type === CustomFieldType.BOOLEAN || + field.type === CustomFieldType.SELECT) && ( +
+ + {field.type === CustomFieldType.TEXT ? ( + + updateField(fieldIndex, { + defaultValue: ev.target.value === '' ? null : ev.target.value, + }) + } + placeholder={`e.g., Default ${field.label.toLowerCase()}`} + maxLength={500} + /> + ) : field.type === CustomFieldType.BOOLEAN ? ( + + updateField(fieldIndex, { + defaultValue: ev.target.value === '' ? null : ev.target.value === 'true', + }) + } + > + + + + + ) : field.type === CustomFieldType.SELECT && field.options.length > 0 ? ( + + updateField(fieldIndex, { + defaultValue: ev.target.value === '' ? null : ev.target.value, + }) + } + > + + {field.options + .filter((opt) => opt.value.trim() && opt.label.trim()) + .map((option, optIndex) => ( + + ))} + + ) : field.type === CustomFieldType.SELECT ? ( +

+ Add options first to set a default value +

+ ) : null} +
+ )} + + {/* Enhanced Range Configuration */} + {field.type === CustomFieldType.RANGE && ( +
+

+ Range Configuration +

+
+ + + updateField(fieldIndex, { + defaultValue: ev.target.value === '' ? null : Number(ev.target.value), + }) + } + className={ + errors[`field-${fieldIndex}-defaultValue`] + ? 'border-red-300 dark:border-red-600' + : '' + } + /> + {errors[`field-${fieldIndex}-defaultValue`] && ( + + )} +
+
-
-
- -
- - updateField(fieldIndex, { categoryName: ev.target.value })} - placeholder="e.g., Graphics, Performance, Audio" - maxLength={100} - /> -

- Suggest a category name to organize this field -

-
- -
+
- - - updateField(fieldIndex, { - type: ev.target.value as CustomFieldType, - defaultValue: null, // Reset default value when type changes - placeholder: '', // Reset placeholder when type changes - }) - } - > - {FIELD_TYPES.map((type) => ( - - ))} - -
-
- -
-
- - {/* Placeholder for TEXT, TEXTAREA, and URL fields */} - {(field.type === CustomFieldType.TEXT || - field.type === CustomFieldType.TEXTAREA || - field.type === CustomFieldType.URL) && ( -
-
- )} - - {/* Default Value Section */} - {(field.type === CustomFieldType.TEXT || - field.type === CustomFieldType.BOOLEAN || - field.type === CustomFieldType.SELECT) && ( -
-
- {/* Enhanced Range Configuration */} - {field.type === CustomFieldType.RANGE && ( -
-

- Range Configuration -

-
-
- - - updateField(fieldIndex, { - rangeMin: parseFloat(ev.target.value) || 0, - }) - } - placeholder="0" - className={ - errors[`field-${fieldIndex}-rangeMin`] - ? 'border-red-300 dark:border-red-600' - : '' - } - /> - {errors[`field-${fieldIndex}-rangeMin`] && ( -

- {errors[`field-${fieldIndex}-rangeMin`]} -

- )} -
-
- - - updateField(fieldIndex, { - rangeMax: parseFloat(ev.target.value) || 100, - }) - } - placeholder="100" - className={ - errors[`field-${fieldIndex}-rangeMax`] - ? 'border-red-300 dark:border-red-600' - : '' - } - /> - {errors[`field-${fieldIndex}-rangeMax`] && ( -

- {errors[`field-${fieldIndex}-rangeMax`]} -

- )} -
-
-
-
- - - updateField(fieldIndex, { - rangeUnit: ev.target.value, - }) - } - placeholder="e.g., %, GB, MB" - maxLength={10} - className={ - errors[`field-${fieldIndex}-rangeUnit`] - ? 'border-red-300 dark:border-red-600' - : '' - } - /> - {errors[`field-${fieldIndex}-rangeUnit`] && ( -

- {errors[`field-${fieldIndex}-rangeUnit`]} -

- )} -
-
- - - updateField(fieldIndex, { - rangeDecimals: parseInt(ev.target.value) || 0, - }) - } - > - - - - - - {errors[`field-${fieldIndex}-rangeDecimals`] && ( -

- {errors[`field-${fieldIndex}-rangeDecimals`]} -

- )} -
-
+ {/* Range Preview */} +
+

+ Preview +

+
+ + Min: {field.rangeMin ?? 0} + {field.rangeUnit} + + + Range: {field.rangeMin ?? 0} - {field.rangeMax ?? 100} + {field.rangeUnit} + + + Max: {field.rangeMax ?? 100} + {field.rangeUnit} + +
+
+ Step:{' '} + {field.rangeDecimals && field.rangeDecimals > 0 + ? Math.pow(10, -field.rangeDecimals).toFixed(field.rangeDecimals) + : '1'} +
+
+
+ )} + + {field.type === CustomFieldType.SELECT && ( +
+
+ + +
- {/* Range Preview */} -
-

- Preview -

-
- - Min: {field.rangeMin ?? 0} - {field.rangeUnit} - - - Range: {field.rangeMin ?? 0} - {field.rangeMax ?? 100} - {field.rangeUnit} - - - Max: {field.rangeMax ?? 100} - {field.rangeUnit} - -
-
- Step:{' '} - {field.rangeDecimals && field.rangeDecimals > 0 - ? Math.pow(10, -field.rangeDecimals).toFixed(field.rangeDecimals) - : '1'} -
-
+ {errors[`field-${fieldIndex}-options`] && ( +
+ {errors[`field-${fieldIndex}-options`]}
)} - {field.type === CustomFieldType.SELECT && ( -
-
- - -
- - {errors[`field-${fieldIndex}-options`] && ( -
- {errors[`field-${fieldIndex}-options`]} -
+ { + const { active, over } = event + + if (over && active.id !== over.id) { + const oldIndex = parseInt( + String(active.id).replace(`field-${fieldIndex}-option-`, ''), + ) + const newIndex = parseInt( + String(over.id).replace(`field-${fieldIndex}-option-`, ''), + ) + + const newOptions = arrayMove(field.options, oldIndex, newIndex) + reorderFieldOptions(fieldIndex, newOptions) + } + }} + > + `field-${fieldIndex}-option-${optionIndex}`, )} - - { - const { active, over } = event - - if (over && active.id !== over.id) { - const oldIndex = parseInt( - String(active.id).replace(`field-${fieldIndex}-option-`, ''), - ) - const newIndex = parseInt( - String(over.id).replace(`field-${fieldIndex}-option-`, ''), - ) - - const newOptions = arrayMove(field.options, oldIndex, newIndex) - reorderFieldOptions(fieldIndex, newOptions) - } - }} - > - `field-${fieldIndex}-option-${optionIndex}`, - )} - strategy={verticalListSortingStrategy} - > -
- {field.options.map((option, optionIndex) => ( - - ))} -
-
-
-
- )} + strategy={verticalListSortingStrategy} + > +
+ {field.options.map((option, optionIndex) => ( + + ))} +
+ +
- ))} + )}
-
+ ))} +
+
-
- - -
- - )} - +
+ + +
+ ) } diff --git a/src/app/admin/custom-field-templates/components/CustomFieldTemplateList.tsx b/src/app/admin/custom-field-templates/components/CustomFieldTemplateList.tsx index f1887d831..4dd915f6d 100644 --- a/src/app/admin/custom-field-templates/components/CustomFieldTemplateList.tsx +++ b/src/app/admin/custom-field-templates/components/CustomFieldTemplateList.tsx @@ -5,24 +5,11 @@ import { useState } from 'react' import { Button, Card, useConfirmDialog, LocalizedDate } from '@/components/ui' import { api } from '@/lib/api' import toast from '@/lib/toast' -import { CustomFieldType, type Prisma } from '@orm' +import { FilterValueSchema } from '@/schemas/common' +import { type RouterOutput } from '@/types/trpc' +import { CustomFieldType } from '@orm' -interface CustomFieldTemplateWithFields { - id: string - name: string - description: string | null - createdAt: Date - updatedAt: Date - fields: { - id: string - name: string - label: string - type: CustomFieldType - options: Prisma.JsonValue - isRequired: boolean - displayOrder: number - }[] -} +type CustomFieldTemplateWithFields = RouterOutput['customFieldTemplates']['get'][number] interface Props { templates: CustomFieldTemplateWithFields[] @@ -100,15 +87,9 @@ function CustomFieldTemplateList(props: Props) { return typeMap[type] || type } - function getOptionsDisplay(options: Prisma.JsonValue): string { - if (!options || !Array.isArray(options)) return '' - return options - .filter( - (opt): opt is { label: string } => - typeof opt === 'object' && opt !== null && 'label' in opt, - ) - .map((opt) => opt.label) - .join(', ') + function getOptionsDisplay(options: unknown): string { + const result = FilterValueSchema.array().safeParse(options) + return result.success ? result.data.map((option) => option.label).join(', ') : '' } return ( @@ -189,11 +170,12 @@ function CustomFieldTemplateList(props: Props) { {field.name} • {getFieldTypeDisplayName(field.type)} {field.isRequired && ' • Required'}
- {field.type === CustomFieldType.SELECT && field.options && ( -
- Options: {getOptionsDisplay(field.options)} -
- )} + {field.type === CustomFieldType.SELECT && + Array.isArray(field.options) && ( +
+ Options: {getOptionsDisplay(field.options)} +
+ )}
))} diff --git a/src/app/admin/dashboard/AdminDashboard.test.tsx b/src/app/admin/dashboard/AdminDashboard.test.tsx new file mode 100644 index 000000000..a7ecd846c --- /dev/null +++ b/src/app/admin/dashboard/AdminDashboard.test.tsx @@ -0,0 +1,160 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { TRPCClientError, type TRPCLink } from '@trpc/client' +import { observable } from '@trpc/server/observable' +import { type PropsWithChildren } from 'react' +import { beforeEach, describe, expect, it } from 'vitest' +import { api } from '@/lib/api' +import { DashboardQuerySchema } from '@/schemas/activity' +import { type AppRouter, type RouterInput, type RouterOutput } from '@/types/trpc' +import { Role } from '@orm' +import { AdminDashboard } from './AdminDashboard' + +type DashboardData = RouterOutput['activity']['dashboard'] +const dashboardData: DashboardData = { + criticalActions: [], + recentUsers: [], + recentListings: [], + recentComments: [], + recentReports: [], + recentBans: [], + permissions: { canSeeUsers: true, canSeeReports: true, canSeeBans: true }, + platformStats: { + newUsersCount: 0, + newUsersChange: 0, + newListingsCount: 0, + newListingsChange: 0, + newPcListingsCount: 0, + newPcListingsChange: 0, + activeReportsCount: 0, + pendingApprovalsCount: 0, + pendingGamesCount: 0, + pendingListingsCount: 0, + pendingPcListingsCount: 0, + }, +} +const requests: RouterInput['activity']['dashboard'][] = [] +const pending: ReturnType>[] = [] +let holdRequests = false + +function wrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 60_000 } }, + }) + const link: TRPCLink = + () => + ({ op }) => + observable((observer) => { + requests.push(DashboardQuerySchema.parse(op.input)) + const response = Promise.withResolvers() + if (holdRequests) pending.push(response) + else response.resolve(dashboardData) + let active = true + response.promise.then( + (data) => { + if (!active) return + observer.next({ result: { data } }) + observer.complete() + }, + (error: unknown) => { + if (!active) return + const failure = + error instanceof Error + ? error + : new Error('Dashboard request failed', { cause: error }) + observer.error(TRPCClientError.from(failure)) + }, + ) + return () => { + active = false + } + }) + const client = api.createClient({ links: [link] }) + return function Wrapper(props: PropsWithChildren) { + return ( + + {props.children} + + ) + } +} + +function refreshButtons() { + return screen.getAllByRole('button', { name: /^Refresh(?: stats)?$/ }) +} + +async function expectIdle() { + await screen.findByRole('heading', { name: 'Platform Stats' }) + await waitFor(() => { + expect(refreshButtons()).toHaveLength(6) + for (const button of refreshButtons()) expect(button).toBeEnabled() + }) +} + +beforeEach(() => { + requests.length = 0 + pending.length = 0 + holdRequests = false +}) + +describe('dashboard request state', () => { + it('disables initial refresh actions until the shared request completes', async () => { + holdRequests = true + render(, { wrapper: wrapper() }) + for (const button of refreshButtons()) expect(button).toBeDisabled() + await waitFor(() => expect(pending).toHaveLength(1)) + await act(async () => pending[0].resolve(dashboardData)) + await expectIdle() + }) + + it('keeps refresh available when the selected range is clicked again', async () => { + render(, { wrapper: wrapper() }) + await expectIdle() + fireEvent.click(screen.getAllByRole('button', { name: '24h' })[0]) + await expectIdle() + expect(requests).toHaveLength(1) + }) + + it('keeps refresh available when returning to a fresh cached range', async () => { + render(, { wrapper: wrapper() }) + await expectIdle() + fireEvent.click(screen.getAllByRole('button', { name: '48h' })[0]) + await waitFor(() => expect(requests).toHaveLength(2)) + await expectIdle() + expect(requests[1]).toMatchObject({ usersTimeRange: '48h', listingsTimeRange: '24h' }) + + fireEvent.click(screen.getAllByRole('button', { name: '24h' })[0]) + await expectIdle() + expect(requests).toHaveLength(2) + }) + + it.each(['Refresh', 'Refresh stats'])( + 'reflects the shared pending request after %s is clicked', + async (label) => { + render(, { wrapper: wrapper() }) + await expectIdle() + holdRequests = true + fireEvent.click(screen.getAllByRole('button', { name: label })[0]) + await waitFor(() => expect(pending).toHaveLength(1)) + for (const button of refreshButtons()) expect(button).toBeDisabled() + expect(requests).toHaveLength(2) + expect(requests[1]).toEqual(requests[0]) + await act(async () => pending[0].resolve(dashboardData)) + await expectIdle() + }, + ) + + it('recovers the shared refresh actions after a failed request is retried', async () => { + render(, { wrapper: wrapper() }) + await expectIdle() + holdRequests = true + fireEvent.click(screen.getAllByRole('button', { name: 'Refresh' })[0]) + await waitFor(() => expect(pending).toHaveLength(1)) + await act(async () => pending[0].reject(new Error('Dashboard temporarily unavailable'))) + await screen.findByRole('heading', { name: 'Failed to load dashboard data' }) + holdRequests = false + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + await expectIdle() + expect(requests).toHaveLength(3) + }) +}) diff --git a/src/app/admin/dashboard/AdminDashboard.tsx b/src/app/admin/dashboard/AdminDashboard.tsx index 08561bcca..38067747c 100644 --- a/src/app/admin/dashboard/AdminDashboard.tsx +++ b/src/app/admin/dashboard/AdminDashboard.tsx @@ -1,7 +1,7 @@ 'use client' import { Users, FileText, MessageSquare, AlertTriangle, Ban } from 'lucide-react' -import { useCallback, useEffect, useState } from 'react' +import { useState } from 'react' import { AdminQuickNavigation } from '@/app/admin/components/AdminQuickNavigation' import { ErrorBoundary } from '@/app/admin/components/ErrorBoundary' import { ADMIN_ROUTES } from '@/app/admin/config/routes' @@ -20,17 +20,6 @@ import { import { CriticalActions } from './components/CriticalActions' import { PlatformStats } from './components/PlatformStats' -type DashboardSection = 'users' | 'listings' | 'comments' | 'reports' | 'bans' | 'stats' - -const createRefreshState = (): Record => ({ - users: false, - listings: false, - comments: false, - reports: false, - bans: false, - stats: false, -}) - interface Props { userRole: Role navItems: AdminNavItem[] @@ -45,9 +34,6 @@ export function AdminDashboard(props: Props) { const [bansTimeRange, setBansTimeRange] = useState('24h') const [statsTimeRange] = useState('24h') - const [refreshingSections, setRefreshingSections] = - useState>(createRefreshState) - const dashboardQuery = api.activity.dashboard.useQuery( { usersTimeRange, @@ -68,34 +54,9 @@ export function AdminDashboard(props: Props) { const showReports = dashboardQuery.data?.permissions.canSeeReports ?? false const showBans = dashboardQuery.data?.permissions.canSeeBans ?? false - const markSectionRefreshing = useCallback((section: DashboardSection) => { - setRefreshingSections((prev) => { - if (prev[section]) return prev - return { ...prev, [section]: true } - }) - }, []) - - const handleRefresh = useCallback( - (section: DashboardSection) => { - markSectionRefreshing(section) - void dashboardQuery.refetch({ throwOnError: false }) - }, - [dashboardQuery, markSectionRefreshing], - ) - - const handleTimeRangeChange = useCallback( - (section: DashboardSection, setter: (range: TimeRange) => void) => (range: TimeRange) => { - setter(range) - markSectionRefreshing(section) - }, - [markSectionRefreshing], - ) - - useEffect(() => { - if (!dashboardQuery.isFetching) { - setRefreshingSections(createRefreshState()) - } - }, [dashboardQuery.isFetching]) + function handleRefresh() { + void dashboardQuery.refetch({ throwOnError: false }) + } // Handle error state - show navigation but with error message for data const hasError = !!dashboardQuery.error @@ -121,7 +82,7 @@ export function AdminDashboard(props: Props) { {dashboardQuery.error.message || 'An unexpected error occurred'}

{canApplyTemplates && (
diff --git a/src/app/admin/emulators/[emulatorId]/custom-fields/components/CategoryFormModal.tsx b/src/app/admin/emulators/[emulatorId]/custom-fields/components/CategoryFormModal.tsx index 9f1e526fc..f4775379c 100644 --- a/src/app/admin/emulators/[emulatorId]/custom-fields/components/CategoryFormModal.tsx +++ b/src/app/admin/emulators/[emulatorId]/custom-fields/components/CategoryFormModal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect } from 'react' +import { useEffect, useRef, useState } from 'react' import { Button, Input, Modal } from '@/components/ui' import { api } from '@/lib/api' import toast from '@/lib/toast' @@ -16,95 +16,115 @@ interface Props { } export default function CategoryFormModal(props: Props) { + const createMutation = api.customFieldCategories.create.useMutation() + const updateMutation = api.customFieldCategories.update.useMutation() + const isPending = createMutation.isPending || updateMutation.isPending + function closeModal() { + if (!isPending) props.onClose() + } + return ( + + + + ) +} + +type FormProps = Omit & { + createMutation: ReturnType + updateMutation: ReturnType +} + +function CategoryForm(props: FormProps) { + const { createMutation, updateMutation } = props + const active = useRef(false) + useEffect(() => { + active.current = true + return () => { + active.current = false + } + }, []) const [name, setName] = useState(props.categoryToEdit?.name || '') const [error, setError] = useState(null) const utils = api.useUtils() - const createMutation = api.customFieldCategories.create.useMutation({ - onSuccess: () => { - utils.customFieldCategories.getByEmulator - .invalidate({ emulatorId: props.emulatorId }) - .catch(console.error) - toast.success('Category created successfully!') - props.onSuccess() - props.onClose() - }, - onError: (err) => { - setError(getErrorMessage(err)) - }, - }) - - const updateMutation = api.customFieldCategories.update.useMutation({ - onSuccess: () => { - utils.customFieldCategories.getByEmulator - .invalidate({ emulatorId: props.emulatorId }) - .catch(console.error) - toast.success('Category updated successfully!') - props.onSuccess() - props.onClose() - }, - onError: (err) => { - setError(getErrorMessage(err)) - }, - }) - - useEffect(() => { - setName(props.categoryToEdit?.name || '') - setError(null) - }, [props.categoryToEdit, props.isOpen]) - - function handleSubmit() { + async function handleSubmit() { + if (createMutation.isPending || updateMutation.isPending) return if (!name.trim()) { setError('Category name is required') return } - if (props.categoryToEdit) { - updateMutation.mutate({ - id: props.categoryToEdit.id, - name: name.trim(), - }) - } else { - createMutation.mutate({ - emulatorId: props.emulatorId, - name: name.trim(), - }) + try { + if (props.categoryToEdit) { + await updateMutation.mutateAsync({ + id: props.categoryToEdit.id, + name: name.trim(), + }) + } else { + await createMutation.mutateAsync({ + emulatorId: props.emulatorId, + name: name.trim(), + }) + } + utils.customFieldCategories.getByEmulator + .invalidate({ emulatorId: props.emulatorId }) + .catch(console.error) + toast.success( + props.categoryToEdit ? 'Category updated successfully!' : 'Category created successfully!', + ) + props.onSuccess() + if (active.current) props.onClose() + } catch (error) { + if (active.current) setError(getErrorMessage(error)) } } const isLoading = createMutation.isPending || updateMutation.isPending return ( - -
-
- - setName(e.target.value)} - placeholder="e.g., Graphics, Audio, Performance" - className={error ? 'border-red-500' : ''} - disabled={isLoading} - /> - {error &&

{error}

} -
+
+
+ + setName(e.target.value)} + placeholder="e.g., Graphics, Audio, Performance" + className={error ? 'border-red-500' : ''} + disabled={isLoading} + /> + {error &&

{error}

} +
-
- - -
+
+ +
- +
) } diff --git a/src/app/admin/emulators/[emulatorId]/custom-fields/components/CustomFieldFormModal.test.tsx b/src/app/admin/emulators/[emulatorId]/custom-fields/components/CustomFieldFormModal.test.tsx new file mode 100644 index 000000000..76287b3a4 --- /dev/null +++ b/src/app/admin/emulators/[emulatorId]/custom-fields/components/CustomFieldFormModal.test.tsx @@ -0,0 +1,60 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { expect, it, vi } from 'vitest' +import { type RouterOutput } from '@/types/trpc' +import { CustomFieldType } from '@orm' +import CustomFieldFormModal from './CustomFieldFormModal' + +const update = vi.hoisted(() => vi.fn()) +const field: RouterOutput['customFieldDefinitions']['byId'] = { + id: 'field-1', + emulatorId: 'emulator-1', + name: 'resolution', + label: 'Resolution', + type: CustomFieldType.RANGE, + options: null, + defaultValue: '50', + placeholder: null, + isRequired: false, + displayOrder: 0, + categoryId: null, + categoryOrder: 0, + rangeMin: 0, + rangeMax: 100, + rangeUnit: '%', + rangeDecimals: 0, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), +} +vi.mock('@/lib/api', () => ({ + api: { + useUtils: () => ({ + customFieldDefinitions: { + getByEmulator: { invalidate: vi.fn().mockResolvedValue(undefined) }, + }, + }), + customFieldDefinitions: { + byId: { useQuery: () => ({ data: field }) }, + create: { useMutation: () => ({ mutate: vi.fn() }) }, + update: { useMutation: () => ({ mutate: update }) }, + }, + customFieldCategories: { getByEmulator: { useQuery: () => ({ data: [] }) } }, + }, +})) + +it('displays a stored numeric-string range default and preserves it on save', async () => { + render( + , + ) + expect(screen.getByDisplayValue('50')).toHaveValue(50) + fireEvent.click(screen.getByRole('button', { name: 'Update Field' })) + await waitFor(() => + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ id: 'field-1', defaultValue: '50' }), + ), + ) +}) diff --git a/src/app/admin/emulators/[emulatorId]/custom-fields/components/CustomFieldFormModal.tsx b/src/app/admin/emulators/[emulatorId]/custom-fields/components/CustomFieldFormModal.tsx index 97dfb2a0e..916943a7e 100644 --- a/src/app/admin/emulators/[emulatorId]/custom-fields/components/CustomFieldFormModal.tsx +++ b/src/app/admin/emulators/[emulatorId]/custom-fields/components/CustomFieldFormModal.tsx @@ -2,7 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useEffect, useRef } from 'react' -import { useForm, Controller, useFieldArray } from 'react-hook-form' +import { useForm, Controller, useFieldArray, useWatch } from 'react-hook-form' import { toSnakeCase } from 'remeda' import { z } from 'zod' import { @@ -70,7 +70,7 @@ function CustomFieldFormModal(props: Props) { { enabled: !!props.emulatorId }, ) - const { control, register, handleSubmit, reset, watch, setValue, formState } = + const { control, register, handleSubmit, reset, setValue, formState } = useForm({ resolver: zodResolver(customFieldFormSchema), defaultValues: { @@ -90,8 +90,26 @@ function CustomFieldFormModal(props: Props) { }, }) - const selectedFieldType = watch('type') - const watchedLabel = watch('label') + const [ + selectedFieldType, + watchedLabel, + rangeMin, + rangeMax, + rangeUnit, + rangeDecimals, + defaultValue, + ] = useWatch({ + control, + name: [ + 'type', + 'label', + 'rangeMin', + 'rangeMax', + 'rangeUnit', + 'rangeDecimals', + 'defaultValue', + ] as const, + }) const { fields, append, remove, update } = useFieldArray({ control, name: 'options' }) // Autopopulate field name from label @@ -447,11 +465,15 @@ function CustomFieldFormModal(props: Props) { {/* Use shared RangeFieldConfig for RANGE type */} {selectedFieldType === CustomFieldType.RANGE && ( diff --git a/src/app/admin/emulators/[emulatorId]/custom-fields/components/CustomFieldList.test.tsx b/src/app/admin/emulators/[emulatorId]/custom-fields/components/CustomFieldList.test.tsx new file mode 100644 index 000000000..e34b6a43b --- /dev/null +++ b/src/app/admin/emulators/[emulatorId]/custom-fields/components/CustomFieldList.test.tsx @@ -0,0 +1,169 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { createElement, type ComponentProps } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CustomFieldType } from '@orm' +import CustomFieldList from './CustomFieldList' +import type * as DndCore from '@dnd-kit/core' + +interface MutationOptions { + onSuccess: () => Promise + onError: (error: Error) => void +} +const recorded = vi.hoisted(() => ({ + invalidate: vi.fn<() => Promise>(), + context: vi.fn<(props: ComponentProps) => void>(), + update: vi.fn<(options: MutationOptions) => void>(), + mutate: vi.fn<(payload: { id: string; displayOrder: number }[]) => void>(), +})) +vi.mock('@dnd-kit/core', async (importOriginal) => { + const actual = await importOriginal() + function RecordedContext(props: ComponentProps) { + recorded.context(props) + return createElement(actual.DndContext, props) + } + return { ...actual, DndContext: RecordedContext } +}) +vi.mock('@/lib/toast', () => ({ default: { success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/api', () => ({ + api: { + useUtils: () => ({ + customFieldDefinitions: { + getByEmulator: { invalidate: recorded.invalidate }, + }, + }), + customFieldDefinitions: { + updateOrder: { + useMutation: (options: MutationOptions) => { + recorded.update(options) + return { mutate: recorded.mutate, isPending: false } + }, + }, + delete: { useMutation: () => ({ mutate: vi.fn(), isPending: false }) }, + }, + }, +})) + +function field( + id: string, + label: string, + displayOrder: number, +): ComponentProps['customFields'][number] { + return { + id, + emulatorId: 'emulator-1', + categoryId: null, + category: null, + name: id, + label, + type: CustomFieldType.TEXT, + options: null, + defaultValue: null, + placeholder: null, + rangeMin: null, + rangeMax: null, + rangeUnit: null, + rangeDecimals: null, + isRequired: false, + displayOrder, + categoryOrder: 0, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + } +} + +function drag(from: string, to: string) { + const onDragEnd = recorded.context.mock.lastCall?.[0].onDragEnd + if (!onDragEnd) throw new Error('Drag handler was not registered') + const event: DndCore.DragEndEvent = { + active: { + id: from, + data: { current: {} }, + rect: { current: { initial: null, translated: null } }, + }, + over: { id: to, data: { current: {} }, rect: new DOMRect(), disabled: false }, + activatorEvent: new Event('pointerup'), + collisions: [], + delta: { x: 0, y: 0 }, + } + act(() => onDragEnd(event)) +} +function labels() { + return screen.getAllByText(/^Field [AB]$/).map((element) => element.textContent) +} + +beforeEach(() => { + vi.clearAllMocks() + recorded.invalidate.mockResolvedValue(undefined) +}) + +describe('custom field ordering sessions', () => { + const first = field('field-a', 'Field A', 0) + const second = field('field-b', 'Field B', 1) + const callbacks = { onEdit: vi.fn(), onDeleteSuccess: vi.fn(), emulatorId: 'emulator-1' } + + it('keeps the submitted draft until cache invalidation finishes', async () => { + const pending = Promise.withResolvers() + recorded.invalidate.mockReturnValue(pending.promise) + const view = render() + fireEvent.click(screen.getByRole('button', { name: 'Reorder Fields' })) + drag(first.id, second.id) + const saved = recorded.update.mock.lastCall?.[0].onSuccess + if (!saved) throw new Error('Save callback was not registered') + let saving: Promise | undefined + act(() => { + saving = saved() + }) + expect(screen.getByRole('button', { name: 'Cancel Changes' })).toBeInTheDocument() + expect(labels()).toEqual(['Field B', 'Field A']) + view.rerender( + , + ) + await act(async () => { + pending.resolve() + await saving + }) + fireEvent.click(screen.getByRole('button', { name: 'Finish Reordering' })) + fireEvent.click(screen.getByRole('button', { name: 'Reorder Fields' })) + expect(labels()).toEqual(['Field B', 'Field A']) + }) + + it('shows refreshed server ordering when no reorder session is active', () => { + const view = render() + expect(labels()).toEqual(['Field A', 'Field B']) + view.rerender( + , + ) + expect(labels()).toEqual(['Field B', 'Field A']) + }) + + it('keeps a reorder draft on refetch and restores its initial order on cancel', () => { + const view = render() + fireEvent.click(screen.getByRole('button', { name: 'Reorder Fields' })) + drag(first.id, second.id) + expect(labels()).toEqual(['Field B', 'Field A']) + view.rerender() + expect(labels()).toEqual(['Field B', 'Field A']) + fireEvent.click(screen.getByRole('button', { name: 'Cancel Changes' })) + expect(labels()).toEqual(['Field A', 'Field B']) + }) + + it('ignores a drag whose target no longer exists', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Reorder Fields' })) + drag(first.id, 'removed-field') + expect(labels()).toEqual(['Field A', 'Field B']) + expect(screen.queryByRole('button', { name: 'Cancel Changes' })).not.toBeInTheDocument() + }) +}) diff --git a/src/app/admin/emulators/[emulatorId]/custom-fields/components/CustomFieldList.tsx b/src/app/admin/emulators/[emulatorId]/custom-fields/components/CustomFieldList.tsx index 116150f50..95863cd0c 100644 --- a/src/app/admin/emulators/[emulatorId]/custom-fields/components/CustomFieldList.tsx +++ b/src/app/admin/emulators/[emulatorId]/custom-fields/components/CustomFieldList.tsx @@ -16,7 +16,7 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable' import { GripVertical, X, Check, Undo } from 'lucide-react' -import { useState, useEffect, type ReactNode } from 'react' +import { useState, useMemo, type ReactNode } from 'react' import { Button, Badge, useConfirmDialog } from '@/components/ui' import { api } from '@/lib/api' import toast from '@/lib/toast' @@ -34,44 +34,30 @@ interface CustomFieldOptionUI { label: string } -interface CustomFieldListProps { +interface Props { customFields: CustomFieldWithCategory[] onEdit: (fieldId: string) => void onDeleteSuccess: () => void emulatorId: string } -function CustomFieldList(props: CustomFieldListProps) { +function CustomFieldList(props: Props) { const utils = api.useUtils() const confirm = useConfirmDialog() const [isReorderMode, setIsReorderMode] = useState(false) - const [orderedFields, setOrderedFields] = useState(() => { - const fields = props.customFields ?? [] - return [...fields].sort((a, b) => { - if (a.categoryId === b.categoryId) { - return a.categoryOrder - b.categoryOrder || a.displayOrder - b.displayOrder - } - return (a.categoryId || '').localeCompare(b.categoryId || '') - }) - }) + const sortedFields = useMemo( + () => + [...props.customFields].sort((left, right) => { + if (left.categoryId === right.categoryId) + return left.categoryOrder - right.categoryOrder || left.displayOrder - right.displayOrder + return (left.categoryId ?? '').localeCompare(right.categoryId ?? '') + }), + [props.customFields], + ) + const [draftOrder, setDraftOrder] = useState([]) const [previousOrder, setPreviousOrder] = useState([]) const [isDirty, setIsDirty] = useState(false) - - useEffect(() => { - // Keep local state in sync if initialCustomFields prop changes and not in reorder mode or dirty - if (!isReorderMode && !isDirty) { - const fields = props.customFields ?? [] - const sortedInitialFields = [...fields].sort((a, b) => { - if (a.categoryId === b.categoryId) { - return a.categoryOrder - b.categoryOrder || a.displayOrder - b.displayOrder - } - return (a.categoryId || '').localeCompare(b.categoryId || '') - }) - setOrderedFields(sortedInitialFields) - // Ensure previousOrder is also in sync when not dirty and not in reorder mode - setPreviousOrder(sortedInitialFields) - } - }, [props.customFields, isReorderMode, isDirty]) + const orderedFields = isReorderMode || isDirty ? draftOrder : sortedFields const sensors = useSensors( useSensor(PointerSensor), @@ -81,36 +67,26 @@ function CustomFieldList(props: CustomFieldListProps) { ) const updateOrderMutation = api.customFieldDefinitions.updateOrder.useMutation({ - onSuccess: () => { - utils.customFieldDefinitions.getByEmulator - .invalidate({ emulatorId: props.emulatorId }) - .then(() => toast.success('Order updated successfully!')) - - setIsDirty(false) // Reset dirty state on successful save + onSuccess: async () => { + await utils.customFieldDefinitions.getByEmulator.invalidate({ emulatorId: props.emulatorId }) + setIsDirty(false) + toast.success('Order updated successfully!') }, onError: (error) => { console.error('Error updating order:', error) toast.error(`Error updating order: ${getErrorMessage(error)}`) - setOrderedFields(previousOrder) + setDraftOrder(previousOrder) }, }) const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event - if (active.id !== over?.id && over) { - // Ensure over is not null - setOrderedFields((items) => { - // Store current order before changing if not already dirty (i.e., first drag) - if (!isDirty) { - setPreviousOrder([...items]) - } - const oldIndex = items.findIndex((item) => item.id === active.id) - const newIndex = items.findIndex((item) => item.id === over.id) - const newItems = arrayMove(items, oldIndex, newIndex) - setIsDirty(true) // Mark as dirty, needs save or cancel - return newItems - }) - } + if (updateOrderMutation.isPending || !event.over || event.active.id === event.over.id) return + const oldIndex = orderedFields.findIndex((field) => field.id === event.active.id) + const newIndex = orderedFields.findIndex((field) => field.id === event.over?.id) + if (oldIndex < 0 || newIndex < 0) return + if (!isDirty) setPreviousOrder(orderedFields) + setDraftOrder(arrayMove(orderedFields, oldIndex, newIndex)) + setIsDirty(true) } const handleConfirmReorder = () => { @@ -123,25 +99,17 @@ function CustomFieldList(props: CustomFieldListProps) { } const handleCancelReorder = () => { - setOrderedFields(previousOrder) // Revert to the order before drag started + setDraftOrder(previousOrder) // Revert to the order before drag started setIsDirty(false) } const toggleReorderMode = () => { - if (isReorderMode && isDirty) { - // If exiting reorder mode with unsaved changes, revert them - // and reset dirty state. - const fields = props.customFields ?? [] - const sortedInitialFields = [...fields].sort((a, b) => a.displayOrder - b.displayOrder) - setOrderedFields(sortedInitialFields) - setPreviousOrder(sortedInitialFields) // also reset previousOrder to initial - setIsDirty(false) - } - setIsReorderMode(!isReorderMode) - // If entering reorder mode, ensure previousOrder is set to current display order if (!isReorderMode) { - setPreviousOrder([...orderedFields]) + setDraftOrder(sortedFields) + setPreviousOrder(sortedFields) } + setIsDirty(false) + setIsReorderMode(!isReorderMode) } const deleteCustomField = api.customFieldDefinitions.delete.useMutation({ @@ -176,8 +144,8 @@ function CustomFieldList(props: CustomFieldListProps) { const validOptions: CustomFieldOptionUI[] = [] for (const item of optionsAsJson) { if (typeof item === 'object' && item !== null && 'value' in item && 'label' in item) { - const val = (item as { value?: unknown }).value - const lbl = (item as { label?: unknown }).label + const val = item.value + const lbl = item.label if (typeof val === 'string' && typeof lbl === 'string') { validOptions.push({ value: val, label: lbl }) } @@ -192,7 +160,7 @@ function CustomFieldList(props: CustomFieldListProps) {
{validOptions.slice(0, 3).map((opt, index) => ( - {opt.label ?? opt.value ?? 'Invalid Option'} + {opt.label} ))} {validOptions.length > 3 && ( @@ -202,7 +170,7 @@ function CustomFieldList(props: CustomFieldListProps) { ) } - if (!props.customFields || props.customFields.length === 0) { + if (props.customFields.length === 0) { return

No custom fields defined for this emulator yet.

} @@ -250,7 +218,13 @@ function CustomFieldList(props: CustomFieldListProps) {
{isReorderMode && isDirty && ( <> - -
-
- -
-
- custom-fields.json -
-
- - {jsonString} - -
-
-
-
- ) -} diff --git a/src/app/admin/emulators/page.test.tsx b/src/app/admin/emulators/page.test.tsx new file mode 100644 index 000000000..6cce39113 --- /dev/null +++ b/src/app/admin/emulators/page.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PERMISSIONS } from '@/utils/permission-system' +import { Role } from '@orm' +import AdminEmulatorsPage from './page' +import type * as UI from '@/components/ui' + +const mocks = vi.hoisted(() => { + const permissions: string[] = [] + return { permissions, statsQuery: vi.fn() } +}) + +vi.mock('@/lib/api', () => ({ + api: { + useUtils: () => ({}), + users: { + me: { + useQuery: () => ({ + data: { id: 'admin', role: Role.ADMIN, permissions: mocks.permissions }, + }), + }, + }, + emulators: { + stats: { useQuery: mocks.statsQuery }, + getForAdmin: { useQuery: () => ({ data: { emulators: [] }, isPending: false }) }, + delete: { useMutation: () => ({ isPending: false }) }, + }, + }, +})) +vi.mock('@/hooks', () => ({ + useColumnVisibility: () => ({}), + useEmulatorLogos: () => ({ isHydrated: true, showEmulatorLogos: false }), +})) +vi.mock('@/hooks/admin', () => ({ + useAdminTable: () => ({ search: '', page: 1, limit: 20, sortField: null, sortDirection: null }), +})) +vi.mock('@/components/ui', async (importOriginal) => ({ + ...(await importOriginal()), + ColumnVisibilityControl: () => null, + DisplayToggleButton: () => null, + useConfirmDialog: () => vi.fn(), +})) +vi.mock('@/app/admin/emulators/components/EmulatorModal', () => ({ default: () => null })) + +describe('emulator statistics visibility', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.permissions = [PERMISSIONS.VIEW_STATISTICS] + mocks.statsQuery.mockReturnValue({ + data: { total: 99, withListings: 41, withSystems: 17 }, + isPending: false, + }) + }) + + it('hides cached statistics when the user loses permission', () => { + const view = render() + expect(screen.getByText('Total Emulators')).toBeVisible() + + mocks.permissions = [] + view.rerender() + + expect(screen.queryByText('Total Emulators')).not.toBeInTheDocument() + expect(mocks.statsQuery).toHaveBeenLastCalledWith(undefined, { enabled: false }) + }) +}) diff --git a/src/app/admin/emulators/page.tsx b/src/app/admin/emulators/page.tsx index a6b71c224..fe6a4da4f 100644 --- a/src/app/admin/emulators/page.tsx +++ b/src/app/admin/emulators/page.tsx @@ -60,11 +60,7 @@ function AdminEmulatorsPage() { storageKey: storageKeys.columnVisibility.adminEmulators, }) - // Get current user to check permissions const userQuery = api.users.me.useQuery() - { - /* TODO: validate this permission*/ - } const currentUserRole = userQuery.data?.role const currentUserId = userQuery.data?.id const userPermissions = userQuery.data?.permissions @@ -76,8 +72,11 @@ function AdminEmulatorsPage() { PERMISSIONS.MANAGE_CUSTOM_FIELDS, ) const canDeleteEmulators = isAdminOrHigher && hasManageEmulatorsPermission + const canViewStatistics = hasPermission(userPermissions, PERMISSIONS.VIEW_STATISTICS) - const emulatorsStatsQuery = api.emulators.stats.useQuery() + const emulatorsStatsQuery = api.emulators.stats.useQuery(undefined, { + enabled: canViewStatistics, + }) const emulatorsQuery = api.emulators.getForAdmin.useQuery({ search: table.search ?? undefined, sortField: table.sortField ?? undefined, @@ -160,7 +159,7 @@ function AdminEmulatorsPage() { } > - {emulatorsStatsQuery.data && ( + {canViewStatistics && emulatorsStatsQuery.data && ( = 2 && !selectedUser }, ) - useEffect(() => { - if (searchQuery.data && query.length >= 2 && !selectedUser) setShowDropdown(true) - }, [searchQuery.data, query, selectedUser]) - useEffect(() => { const handler = (event: MouseEvent) => { if (!dropdownRef.current || dropdownRef.current.contains(event.target as Node)) return @@ -70,7 +66,7 @@ export default function GrantEntitlementModal(props: Props) { setQuery('') setSelectedUser(null) setShowDropdown(false) - setSource('MANUAL') + setSource(EntitlementSource.MANUAL) setReferenceId('') setNotes('') props.onClose() @@ -86,12 +82,13 @@ export default function GrantEntitlementModal(props: Props) { value={selectedUser ? selectedUser.name || selectedUser.email : query} onChange={(e) => { setSelectedUser(null) - setQuery((e.target as HTMLInputElement).value) + setQuery(e.target.value) + setShowDropdown(true) }} placeholder="Search users by name or email…" leftIcon={} /> - {showDropdown && ( + {showDropdown && query.length >= 2 && !selectedUser && (
{searchQuery.isPending ? (
Searching…
diff --git a/src/app/admin/games/[id]/components/GameEditForm.tsx b/src/app/admin/games/[id]/components/GameEditForm.tsx index 90a0dc2c1..e57b58db0 100644 --- a/src/app/admin/games/[id]/components/GameEditForm.tsx +++ b/src/app/admin/games/[id]/components/GameEditForm.tsx @@ -3,7 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useRouter } from 'next/navigation' import { useState } from 'react' -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' import { Button, Input, Autocomplete } from '@/components/ui' import { AdminImageSelectorSwitcher } from '@/components/ui/image-selectors' import { api } from '@/lib/api' @@ -48,7 +48,7 @@ export function GameEditForm(props: Props) { }, }) - const { register, handleSubmit, formState, setValue, watch } = useForm< + const { register, handleSubmit, formState, setValue, control } = useForm< UpdateGameFormInput, unknown, UpdateGameInput @@ -65,6 +65,12 @@ export function GameEditForm(props: Props) { mode: 'onChange', // Enable validation on change to track form state }) + const [systemId, gameTitle, imageUrl, boxartUrl, bannerUrl] = useWatch({ + control, + name: ['systemId', 'title', 'imageUrl', 'boxartUrl', 'bannerUrl'] as const, + }) + const selectedSystem = systemsQuery.data?.find((system) => system.id === systemId) + const onSubmit = (data: UpdateGameInput) => { setIsSubmitting(true) updateGame.mutate({ id: props.game.id, ...data }) @@ -95,7 +101,7 @@ export function GameEditForm(props: Props) { System setValue('systemId', value ?? '')} items={systemsQuery.data ?? []} optionToValue={(system) => system.id} @@ -113,13 +119,10 @@ export function GameEditForm(props: Props) {
system.id === watch('systemId'))?.name} - tgdbPlatformId={ - systemsQuery.data?.find((system) => system.id === watch('systemId'))?.tgdbPlatformId || - undefined - } - selectedImageUrl={watch('imageUrl')} + gameTitle={gameTitle} + systemName={selectedSystem?.name} + tgdbPlatformId={selectedSystem?.tgdbPlatformId || undefined} + selectedImageUrl={imageUrl} onImageSelect={(url) => setValue('imageUrl', url, { shouldValidate: true })} onError={(error) => console.error('Image selection error:', error)} placeholder="https://example.com/game-image.jpg" @@ -134,13 +137,10 @@ export function GameEditForm(props: Props) {
system.id === watch('systemId'))?.name} - tgdbPlatformId={ - systemsQuery.data?.find((system) => system.id === watch('systemId'))?.tgdbPlatformId || - undefined - } - selectedImageUrl={watch('boxartUrl')} + gameTitle={gameTitle} + systemName={selectedSystem?.name} + tgdbPlatformId={selectedSystem?.tgdbPlatformId || undefined} + selectedImageUrl={boxartUrl} onImageSelect={(url) => setValue('boxartUrl', url, { shouldValidate: true })} onError={(error) => console.error('Boxart selection error:', error)} placeholder="https://example.com/boxart-image.jpg" @@ -155,13 +155,10 @@ export function GameEditForm(props: Props) {
system.id === watch('systemId'))?.name} - tgdbPlatformId={ - systemsQuery.data?.find((system) => system.id === watch('systemId'))?.tgdbPlatformId || - undefined - } - selectedImageUrl={watch('bannerUrl')} + gameTitle={gameTitle} + systemName={selectedSystem?.name} + tgdbPlatformId={selectedSystem?.tgdbPlatformId || undefined} + selectedImageUrl={bannerUrl} onImageSelect={(url) => setValue('bannerUrl', url, { shouldValidate: true })} onError={(error) => console.error('Banner selection error:', error)} placeholder="https://example.com/banner-image.jpg" diff --git a/src/app/admin/games/approvals/page.tsx b/src/app/admin/games/approvals/page.tsx index 2fd13e109..98289809f 100644 --- a/src/app/admin/games/approvals/page.tsx +++ b/src/app/admin/games/approvals/page.tsx @@ -37,6 +37,7 @@ import { useAdminTable } from '@/hooks/admin' import analytics from '@/lib/analytics' import { api } from '@/lib/api' import toast from '@/lib/toast' +import { BooleanPreferenceSchema } from '@/schemas/preferences' import { type RouterOutput } from '@/types/trpc' import { type Nullable } from '@/types/utils' import getErrorMessage from '@/utils/getErrorMessage' @@ -100,6 +101,7 @@ function GameApprovalsPage() { const [showSystemIcons, setShowSystemIcons, isSystemIconsHydrated] = useLocalStorage( storageKeys.showSystemIcons, true, + BooleanPreferenceSchema, ) const pendingGamesQuery = api.games.getPendingGames.useQuery({ diff --git a/src/app/admin/listings/page.tsx b/src/app/admin/listings/page.tsx index be82c45c9..7f8fabb43 100644 --- a/src/app/admin/listings/page.tsx +++ b/src/app/admin/listings/page.tsx @@ -37,6 +37,7 @@ import { import { useAdminTable, useAdminFilters } from '@/hooks/admin' import analytics from '@/lib/analytics' import { api } from '@/lib/api' +import { BooleanPreferenceSchema } from '@/schemas/preferences' import { type RouterInput, type RouterOutput } from '@/types/trpc' import getGameImageUrl from '@/utils/images/getGameImageUrl' import { hasPermission, PERMISSIONS } from '@/utils/permission-system' @@ -87,6 +88,7 @@ function AdminListingsPage() { const [showSystemIcons, setShowSystemIcons, isSystemIconsHydrated] = useLocalStorage( storageKeys.showSystemIcons, true, + BooleanPreferenceSchema, ) const emulatorLogos = useEmulatorLogos() diff --git a/src/app/admin/pc-listing-approvals/page.tsx b/src/app/admin/pc-listing-approvals/page.tsx index e8abc319d..176f93725 100644 --- a/src/app/admin/pc-listing-approvals/page.tsx +++ b/src/app/admin/pc-listing-approvals/page.tsx @@ -57,6 +57,7 @@ import analytics from '@/lib/analytics' import { api } from '@/lib/api' import { logger } from '@/lib/logger' import toast from '@/lib/toast' +import { BooleanPreferenceSchema } from '@/schemas/preferences' import { type RouterOutput, type RouterInput } from '@/types/trpc' import getErrorMessage from '@/utils/getErrorMessage' import getImageUrl from '@/utils/getImageUrl' @@ -110,6 +111,7 @@ function PcListingApprovalsPage() { const [showSystemIcons, setShowSystemIcons, isSystemIconsHydrated] = useLocalStorage( storageKeys.showSystemIcons, true, + BooleanPreferenceSchema, ) const emulatorLogos = useEmulatorLogos() diff --git a/src/app/admin/performance/components/PerformanceScaleModal.tsx b/src/app/admin/performance/components/PerformanceScaleModal.tsx index bb3db9a40..efafb3ec6 100644 --- a/src/app/admin/performance/components/PerformanceScaleModal.tsx +++ b/src/app/admin/performance/components/PerformanceScaleModal.tsx @@ -1,8 +1,9 @@ 'use client' -import { useState, type FormEvent, type ChangeEvent, useEffect } from 'react' +import { useEffect, useRef, useState, type FormEvent, type ChangeEvent } from 'react' import { Button, Input, Modal } from '@/components/ui' import { api } from '@/lib/api' +import toast from '@/lib/toast' import { type RouterInput } from '@/types/trpc' import getErrorMessage from '@/utils/getErrorMessage' import { type PerformanceScale } from '../types' @@ -18,38 +19,56 @@ interface Props { function PerformanceScaleModal(props: Props) { const createPerformanceScale = api.performanceScales.create.useMutation() const updatePerformanceScale = api.performanceScales.update.useMutation() - const [label, setLabel] = useState('') - const [description, setDescription] = useState('') - const [rank, setRank] = useState('') - const [error, setError] = useState('') - const [success, setSuccess] = useState('') + const isPending = createPerformanceScale.isPending || updatePerformanceScale.isPending + function closeModal() { + if (!isPending) props.onClose() + } + return ( + + + + ) +} - // Update form fields when props change or modal opens - useEffect(() => { - if (props.isOpen) { - setLabel(props.scale?.label || '') - setDescription(props.scale?.description || '') - setRank(props.scale?.rank || '') - setError('') - setSuccess('') - } - }, [props.isOpen, props.scale]) +type FormProps = Omit & { + createPerformanceScale: ReturnType + updatePerformanceScale: ReturnType +} - // Reset form when modal closes +function PerformanceScaleForm(props: FormProps) { + const { createPerformanceScale, updatePerformanceScale } = props + const active = useRef(false) useEffect(() => { - if (!props.isOpen) { - setLabel('') - setDescription('') - setRank('') - setError('') - setSuccess('') + active.current = true + return () => { + active.current = false } - }, [props.isOpen]) + }, []) + const [label, setLabel] = useState(props.scale?.label ?? '') + const [description, setDescription] = useState(props.scale?.description ?? '') + const [rank, setRank] = useState(props.scale?.rank ?? '') + const [error, setError] = useState('') const handleSubmit = async (ev: FormEvent) => { ev.preventDefault() + if (createPerformanceScale.isPending || updatePerformanceScale.isPending) return setError('') - setSuccess('') const rankNumber = Number(rank) if (isNaN(rankNumber) || rankNumber < 1) { @@ -65,113 +84,99 @@ function PerformanceScaleModal(props: Props) { description: description.trim() || undefined, rank: rankNumber, } satisfies RouterInput['performanceScales']['update']) - setSuccess('Performance scale updated!') } else { await createPerformanceScale.mutateAsync({ label, description: description.trim() || undefined, rank: rankNumber, } satisfies RouterInput['performanceScales']['create']) - setSuccess('Performance scale created!') } + toast.success(props.editId ? 'Performance scale updated!' : 'Performance scale created!') props.onSuccess() + if (active.current) props.onClose() } catch (err) { - setError(getErrorMessage(err, 'Failed to save performance scale.')) + if (active.current) setError(getErrorMessage(err, 'Failed to save performance scale.')) } } return ( - -
-
- - setLabel(e.target.value)} - required - className="w-full" - placeholder="e.g., Perfect, Playable, Slow, Unplayable" - /> -
- -
- - setRank(e.target.value)} - required - min="1" - className="w-full" - placeholder="Higher numbers = better performance" - /> -

- Higher rank numbers indicate better performance (e.g., Perfect = 5, Unplayable = 1) -

-
- -
- - ) => - setDescription(e.target.value) - } - className="w-full" - rows={3} - placeholder="Describe what this performance level means (optional)" - /> -
- - {error && ( -
- {error} -
- )} - - {success && ( -
- {success} -
- )} - -
- - -
-
-
+
+
+ + setLabel(e.target.value)} + required + className="w-full" + placeholder="e.g., Perfect, Playable, Slow, Unplayable" + /> +
+ +
+ + setRank(e.target.value)} + required + min="1" + className="w-full" + placeholder="Higher numbers = better performance" + /> +

+ Higher rank numbers indicate better performance (e.g., Perfect = 5, Unplayable = 1) +

+
+ +
+ + ) => + setDescription(e.target.value) + } + className="w-full" + rows={3} + placeholder="Describe what this performance level means (optional)" + /> +
+ + {error && ( +
{error}
+ )} + +
+ + +
+
) } diff --git a/src/app/admin/performance/components/ReplacementSelectionModal.test.tsx b/src/app/admin/performance/components/ReplacementSelectionModal.test.tsx new file mode 100644 index 000000000..39eadb136 --- /dev/null +++ b/src/app/admin/performance/components/ReplacementSelectionModal.test.tsx @@ -0,0 +1,134 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ReplacementSelectionModal from './ReplacementSelectionModal' +import type { RouterInput } from '@/types/trpc' + +const mocks = vi.hoisted(() => ({ deleteScale: vi.fn() })) + +vi.mock('@/lib/api', () => ({ + api: { + performanceScales: { + get: { + useQuery: () => ({ + data: [ + { id: 1, label: 'Perfect', rank: 1 }, + { id: 2, label: 'Great', rank: 2 }, + { id: 3, label: 'Playable', rank: 3 }, + ], + }), + }, + delete: { + useMutation: (options: { onSuccess: () => void }) => ({ + isPending: false, + mutateAsync: async (input: RouterInput['performanceScales']['delete']) => { + await mocks.deleteScale(input) + options.onSuccess() + }, + }), + }, + }, + }, +})) + +const initialProps = { + isOpen: true, + onClose: vi.fn(), + onSuccess: vi.fn(), + scaleToDelete: { id: 1, label: 'Perfect', listingsCount: 2 }, +} + +function selectReplacement(id: number) { + fireEvent.change(screen.getByLabelText('Replacement Performance Scale'), { + target: { value: String(id) }, + }) +} + +describe('performance replacement session', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.deleteScale.mockResolvedValue(undefined) + }) + + it('clears selection when canceled, including reopening the same target', () => { + const view = render() + selectReplacement(2) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(initialProps.onClose).toHaveBeenCalledOnce() + expect(screen.getByLabelText('Replacement Performance Scale')).toHaveValue('') + + view.rerender() + view.rerender() + + expect(screen.getByRole('button', { name: 'Delete and Replace' })).toBeDisabled() + expect(mocks.deleteScale).not.toHaveBeenCalled() + }) + + it.each(['Cancel', 'target change'] as const)( + 'clears a failed deletion error after %s', + async (action) => { + mocks.deleteScale.mockRejectedValue(new Error('Replacement failed')) + const view = render() + selectReplacement(2) + fireEvent.click(screen.getByRole('button', { name: 'Delete and Replace' })) + await screen.findByText('Replacement failed') + + if (action === 'Cancel') { + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + view.rerender() + view.rerender() + } else { + view.rerender( + , + ) + } + + expect(screen.queryByText('Replacement failed')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Delete and Replace' })).toBeDisabled() + }, + ) + + it('clears selection when the target changes while open', () => { + const view = render() + selectReplacement(2) + + view.rerender( + , + ) + + expect(screen.getByLabelText('Replacement Performance Scale')).toHaveValue('') + expect(screen.getByRole('button', { name: 'Delete and Replace' })).toBeDisabled() + }) + + it('clears selection when externally closed without clearing the target', () => { + const view = render() + selectReplacement(2) + view.rerender() + view.rerender() + + expect(screen.getByRole('button', { name: 'Delete and Replace' })).toBeDisabled() + }) + + it('submits only a selection made for the current target and clears it after success', async () => { + const view = render() + selectReplacement(2) + view.rerender( + , + ) + selectReplacement(1) + fireEvent.click(screen.getByRole('button', { name: 'Delete and Replace' })) + + await waitFor(() => expect(initialProps.onSuccess).toHaveBeenCalledOnce()) + expect(mocks.deleteScale).toHaveBeenCalledExactlyOnceWith({ id: 3, replacementId: 1 }) + expect(screen.getByLabelText('Replacement Performance Scale')).toHaveValue('') + expect(screen.getByRole('button', { name: 'Delete and Replace' })).toBeDisabled() + }) +}) diff --git a/src/app/admin/performance/components/ReplacementSelectionModal.tsx b/src/app/admin/performance/components/ReplacementSelectionModal.tsx index d6aea305a..2435ef75f 100644 --- a/src/app/admin/performance/components/ReplacementSelectionModal.tsx +++ b/src/app/admin/performance/components/ReplacementSelectionModal.tsx @@ -15,12 +15,22 @@ interface Props { } function ReplacementSelectionModal(props: Props) { + const activeScaleId = props.isOpen ? (props.scaleToDelete?.id ?? null) : null + const [previousScaleId, setPreviousScaleId] = useState(activeScaleId) const [selectedReplacementId, setSelectedReplacementId] = useState(null) const [error, setError] = useState('') + if (previousScaleId !== activeScaleId) { + setPreviousScaleId(activeScaleId) + setSelectedReplacementId(null) + setError('') + } + const performanceScalesQuery = api.performanceScales.get.useQuery({}) const deletePerformanceScale = api.performanceScales.delete.useMutation({ onSuccess: () => { + setSelectedReplacementId(null) + setError('') props.onSuccess() }, onError: (err: unknown) => { @@ -32,6 +42,12 @@ function ReplacementSelectionModal(props: Props) { (scale) => scale.id !== props.scaleToDelete?.id, ) + const handleClose = () => { + setSelectedReplacementId(null) + setError('') + props.onClose() + } + const handleDelete = async () => { if (!props.scaleToDelete || selectedReplacementId === null) return @@ -52,7 +68,7 @@ function ReplacementSelectionModal(props: Props) { return ( -
- + - -
- - +
+ + +
+ ) } diff --git a/src/app/admin/socs/page.tsx b/src/app/admin/socs/page.tsx index 9f1adf246..7ad0a65c6 100644 --- a/src/app/admin/socs/page.tsx +++ b/src/app/admin/socs/page.tsx @@ -106,7 +106,6 @@ function AdminSoCsPage() { const handleModalSuccess = () => { invalidateSocQueries() - closeModal() } const handleDelete = async (id: string, name: string) => { diff --git a/src/app/admin/systems/components/SystemModal.tsx b/src/app/admin/systems/components/SystemModal.tsx index c8e03c91d..49cb7166a 100644 --- a/src/app/admin/systems/components/SystemModal.tsx +++ b/src/app/admin/systems/components/SystemModal.tsx @@ -1,8 +1,9 @@ 'use client' -import { useState, type FormEvent, useEffect } from 'react' +import { useEffect, useRef, useState, type FormEvent } from 'react' import { Button, Input, Modal } from '@/components/ui' import { api } from '@/lib/api' +import toast from '@/lib/toast' import { type RouterInput } from '@/types/trpc' import getErrorMessage from '@/utils/getErrorMessage' @@ -18,33 +19,56 @@ interface Props { function SystemModal(props: Props) { const createSystem = api.systems.create.useMutation() const updateSystem = api.systems.update.useMutation() - const [name, setName] = useState(props.systemName) - const [key, setKey] = useState(props.systemKey) - const [error, setError] = useState('') - const [success, setSuccess] = useState('') + const isPending = createSystem.isPending || updateSystem.isPending + function closeModal() { + if (!isPending) props.onClose() + } + return ( + + + + ) +} - useEffect(() => { - if (props.isOpen) { - setName(props.systemName || '') - setKey(props.systemKey || '') - setError('') - setSuccess('') - } - }, [props.isOpen, props.systemName, props.systemKey]) +type FormProps = Omit & { + createSystem: ReturnType + updateSystem: ReturnType +} +function SystemForm(props: FormProps) { + const { createSystem, updateSystem } = props + const active = useRef(false) useEffect(() => { - if (!props.isOpen) { - setName('') - setKey('') - setError('') - setSuccess('') + active.current = true + return () => { + active.current = false } - }, [props.isOpen]) + }, []) + const [name, setName] = useState(props.systemName) + const [key, setKey] = useState(props.systemKey) + const [error, setError] = useState('') const handleSubmit = async (ev: FormEvent) => { ev.preventDefault() + if (createSystem.isPending || updateSystem.isPending) return setError('') - setSuccess('') try { if (props.editId) { await updateSystem.mutateAsync({ @@ -52,88 +76,74 @@ function SystemModal(props: Props) { name, key: key || null, } satisfies RouterInput['systems']['update']) - setSuccess('System updated!') } else { await createSystem.mutateAsync({ name, key: key || null, } satisfies RouterInput['systems']['create']) - setSuccess('System created!') } - setName('') - setKey('') + toast.success(props.editId ? 'System updated!' : 'System created!') props.onSuccess() + if (active.current) props.onClose() } catch (err) { - setError(getErrorMessage(err, 'Failed to save system.')) + if (active.current) setError(getErrorMessage(err, 'Failed to save system.')) } } return ( - -
-
- - setName(e.target.value)} - required - className="w-full" - placeholder="Enter system name" - /> -
- -
- - setKey(e.target.value)} - className="w-full font-mono" - placeholder="e.g., nintendo_switch, sony_playstation_5" - /> -

- Used for API integrations (IGDB, TGDB, RAWG) -

-
+ +
+ + setName(e.target.value)} + required + className="w-full" + placeholder="Enter system name" + /> +
- {error && ( -
- {error} -
- )} +
+ + setKey(e.target.value)} + className="w-full font-mono" + placeholder="e.g., nintendo_switch, sony_playstation_5" + /> +

+ Used for API integrations (IGDB, TGDB, RAWG) +

+
- {success && ( -
- {success} -
- )} + {error && ( +
{error}
+ )} -
- - -
-
-
+
+ + +
+ ) } diff --git a/src/app/admin/systems/page.tsx b/src/app/admin/systems/page.tsx index 6db3f806b..e3a7c9ea5 100644 --- a/src/app/admin/systems/page.tsx +++ b/src/app/admin/systems/page.tsx @@ -82,7 +82,6 @@ function AdminSystemsPage() { const handleModalSuccess = () => { utils.systems.get.invalidate().catch(console.error) utils.systems.stats.invalidate().catch(console.error) - closeModal() } const handleDelete = async (id: string) => { diff --git a/src/app/admin/title-id-tools/TitleIdTool.tsx b/src/app/admin/title-id-tools/TitleIdTool.tsx index 8c41a12da..48805fe3b 100644 --- a/src/app/admin/title-id-tools/TitleIdTool.tsx +++ b/src/app/admin/title-id-tools/TitleIdTool.tsx @@ -1,6 +1,6 @@ 'use client' -import { type FormEvent, useEffect, useMemo, useState } from 'react' +import { type FormEvent, useMemo, useState } from 'react' import { AdminTableNoResults } from '@/components/admin' import { Button } from '@/components/ui/Button' import { Card } from '@/components/ui/Card' @@ -23,26 +23,15 @@ const EMPTY_PROVIDERS: TitleIdProviderInfo[] = [] function TitleIdTool() { const providersQuery = api.titleIdTools.providers.useQuery() - const [selectedPlatformId, setSelectedPlatformId] = useState(null) + const [preferredPlatformId, setPreferredPlatformId] = useState(null) const [gameName, setGameName] = useState('') const [maxResults, setMaxResults] = useState(5) const providers = providersQuery.data?.providers ?? EMPTY_PROVIDERS - useEffect(() => { - if (providers.length === 0) return - if (selectedPlatformId && providers.some((provider) => provider.id === selectedPlatformId)) { - return - } - - const defaultProvider = providers[0] - if (defaultProvider) setSelectedPlatformId(defaultProvider.id) - }, [providers, selectedPlatformId]) - - const selectedProvider = useMemo(() => { - if (!selectedPlatformId) return null - return providers.find((provider) => provider.id === selectedPlatformId) ?? null - }, [providers, selectedPlatformId]) + const selectedProvider = + providers.find((provider) => provider.id === preferredPlatformId) ?? providers[0] ?? null + const selectedPlatformId = selectedProvider?.id ?? null const statsQueryEnabled = Boolean(selectedProvider?.supportsStats) @@ -75,9 +64,8 @@ function TitleIdTool() { }, [selectedPlatformId, gameName]) function handlePlatformChange(value: string) { - if (TITLE_ID_PLATFORM_IDS.includes(value as TitleIdPlatformId)) { - return setSelectedPlatformId(value as TitleIdPlatformId) - } + const platformId = TITLE_ID_PLATFORM_IDS.find((id) => id === value) + if (platformId) return setPreferredPlatformId(platformId) toast.error('Unsupported title ID provider selected') } diff --git a/src/app/admin/user-bans/components/CreateBanModal.tsx b/src/app/admin/user-bans/components/CreateBanModal.tsx index f97c563c7..ff637c84e 100644 --- a/src/app/admin/user-bans/components/CreateBanModal.tsx +++ b/src/app/admin/user-bans/components/CreateBanModal.tsx @@ -34,8 +34,32 @@ interface Props { type UserSearchResult = RouterOutput['users']['searchUsers'][number] function CreateBanModal(props: Props) { + const mutation = api.userBans.create.useMutation() + function closeModal() { + if (!mutation.isPending) props.onClose() + } + return ( + + ) +} + +type FormProps = Props & { mutation: ReturnType } + +function CreateBanForm(props: FormProps) { + const active = useRef(false) + useEffect(() => { + active.current = true + return () => { + active.current = false + } + }, []) const { user: clerkUser } = useUser() - const [selectedUser, setSelectedUser] = useState(null) + const [chosenUser, setChosenUser] = useState(null) const [userSearch, setUserSearch] = useState('') const [showUserDropdown, setShowUserDropdown] = useState(false) const [reason, setReason] = useState('') @@ -57,71 +81,32 @@ function CreateBanModal(props: Props) { PERMISSIONS.MANAGE_USER_BANS, ]) - // User search query - filters out users that current user cannot ban + const preselectedUserQuery = api.users.getUserById.useQuery( + { userId: props.userId ?? '' }, + { enabled: Boolean(props.userId) }, + ) + const user = preselectedUserQuery.data + const selectedUser: UserSearchResult | null = props.userId + ? user?.id === props.userId && user.name + ? { + id: user.id, + name: user.name, + email: '', + profileImage: user.profileImage ?? null, + role: user.role, + trustScore: user.trustScore ?? 0, + } + : null + : chosenUser const userSearchQuery = api.users.searchUsers.useQuery( { query: userSearch }, { enabled: userSearch.length >= 2 && !selectedUser && !props.userId }, ) - // Fetch user details if userId is provided - const preselectedUserQuery = api.users.getUserById.useQuery( - { userId: props.userId ?? '' }, - { enabled: !!props.userId }, - ) - - // Set pre-selected user when data is available - useEffect(() => { - if (props.userId && preselectedUserQuery.data) { - const user = preselectedUserQuery.data - // The getUserById returns a user profile object, we just need basic fields - // Only set the user if we have a valid id - if (!user || !user.id || !user.name) return - setSelectedUser({ - id: user.id, - name: user.name || null, - email: '', // getUserById doesn't return email for privacy - profileImage: user.profileImage || null, - role: user.role as Role, - trustScore: user.trustScore || 0, - }) - setUserSearch(user.name) - } - }, [props.userId, preselectedUserQuery.data]) - - // Show dropdown when search results are available - useEffect(() => { - if (userSearchQuery.data && userSearch.length >= 2 && !selectedUser) { - setShowUserDropdown(true) - } - }, [userSearchQuery.data, userSearch, selectedUser]) - - const createBanMutation = api.userBans.create.useMutation({ - onSuccess: () => { - toast.success('User banned successfully!') - props.onSuccess() - handleClose() - }, - onError: (err) => { - toast.error(`Failed to ban user: ${getErrorMessage(err)}`) - logger.error('Failed to create user ban:', err) - }, - }) - - const handleClose = () => { - setSelectedUser(null) - setUserSearch('') - setShowUserDropdown(false) - setReason('') - setNotes('') - setIsPermanent(true) - setExpirationDate('') - setExpirationTime('23:59') - setNullifyVotes(false) - props.onClose() - } + const createBanMutation = props.mutation const handleUserSelect = (user: UserSearchResult) => { - setSelectedUser(user) + setChosenUser(user) setUserSearch(user.name || user.email) setShowUserDropdown(false) } @@ -130,19 +115,16 @@ function CreateBanModal(props: Props) { if (props.userId) return setUserSearch(value) - if (!selectedUser && value.length >= 2) { - setShowUserDropdown(true) - } else if (value.length < 2) { - setShowUserDropdown(false) - } + setShowUserDropdown(value.length >= 2) if (selectedUser && value !== (selectedUser.name || selectedUser.email)) { - setSelectedUser(null) + setChosenUser(null) } } const handleSubmit = async (ev: FormEvent) => { ev.preventDefault() + if (createBanMutation.isPending) return if (!selectedUser) { toast.error('Please select a user to ban') @@ -176,13 +158,21 @@ function CreateBanModal(props: Props) { expiresAt = expirationDateTime } - createBanMutation.mutate({ - userId: selectedUser.id, - reason: reason.trim(), - notes: notes.trim() || undefined, - expiresAt: expiresAt || undefined, - nullifyVotes, - } satisfies RouterInput['userBans']['create']) + try { + await createBanMutation.mutateAsync({ + userId: selectedUser.id, + reason: reason.trim(), + notes: notes.trim() || undefined, + expiresAt: expiresAt || undefined, + nullifyVotes, + } satisfies RouterInput['userBans']['create']) + toast.success('User banned successfully!') + props.onSuccess() + if (active.current) props.onClose() + } catch (error) { + toast.error(`Failed to ban user: ${getErrorMessage(error)}`) + logger.error('Failed to create user ban:', error) + } } // Close dropdown when clicking outside @@ -227,7 +217,14 @@ function CreateBanModal(props: Props) { } return ( - +
{/* Warning */}
@@ -256,7 +253,7 @@ function CreateBanModal(props: Props) { handleSearchChange(ev.target.value)} disabled={!!props.userId} placeholder="Search users by name or email..." @@ -271,45 +268,48 @@ function CreateBanModal(props: Props) {
{/* User Dropdown */} - {showUserDropdown && userSearchQuery.data && userSearchQuery.data.length > 0 && ( -
- {userSearchQuery.data?.map((user) => { - const canBan = canBanUser(currentUserQuery.data?.role, user.role) - return ( -
- - ) - })} -
- )} + + ) + })} +
+ )} {/* Selected User Display */} {selectedUser && ( @@ -353,7 +353,7 @@ function CreateBanModal(props: Props) { size="sm" variant="outline" onClick={() => { - setSelectedUser(null) + setChosenUser(null) setUserSearch('') }} > @@ -539,7 +539,7 @@ function CreateBanModal(props: Props) { +
+ )} + +
+
+

+ Current Badges ({userBadges.length}) +

+ {userBadges.length === 0 ? ( +

+ No badges assigned yet +

+ ) : ( +
+ {userBadges.map((userBadge) => ( +
+
+
+ {userBadge.badge.icon || userBadge.badge.name.charAt(0).toUpperCase()} +
+
+
+ {userBadge.badge.name} +
+
+ Assigned +
+
+
+
+ Color: {userBadge.color} +
+
+
+ +
+ ))} +
+ )} +
+ + {availableBadges.length > 0 && (

- Current Badges ({userBadges.length}) + Assign New Badge

- {userBadges.length === 0 ? ( -

- No badges assigned yet -

- ) : ( -
- {userBadges.map((userBadge) => ( -
-
-
- {userBadge.badge.icon || userBadge.badge.name.charAt(0).toUpperCase()} +
+ setSelectedBadgeId(event.target.value || null)} + options={availableBadges} + /> + + {selectedBadge && ( +
+
+
+ {selectedBadge.icon || selectedBadge.name.charAt(0).toUpperCase()} +
+
+
+ {selectedBadge.name}
-
-
- {userBadge.badge.name} -
+ {selectedBadge.description && (
- Assigned -
-
-
-
- Color: {userBadge.color} + {selectedBadge.description}
-
+ )}
-
- ))} -
- )} -
+
+ )} - {availableBadges.length > 0 && ( -
-

- Assign New Badge -

-
- - - {selectedBadgeId && ( -
- {(() => { - const selectedBadge = badges.find((b) => b.id === selectedBadgeId) - if (!selectedBadge) return null - return ( -
-
- {selectedBadge.icon || selectedBadge.name.charAt(0).toUpperCase()} -
-
-
- {selectedBadge.name} -
- {selectedBadge.description && ( -
- {selectedBadge.description} -
- )} -
-
- ) - })()} -
- )} - - - - -
-
- )} + - {availableBadges.length === 0 && userBadges.length > 0 && ( -
-

- All available badges have been assigned to this user. -

+
- )} -
+
+ )} - - - - -
+ {availableBadges.length === 0 && userBadges.length > 0 && ( +
+

No unassigned badges on this page.

+
+ )} + + +
+ { + if (nextPage !== page) { + setKnownPages(badgesQuery.data?.pagination.pages ?? knownPages) + setPage(nextPage) + setSelectedBadgeId(null) + } + }} + /> +
+ + + + + ) } diff --git a/src/app/admin/verified-developers/components/EditVerifiedDeveloperModal.tsx b/src/app/admin/verified-developers/components/EditVerifiedDeveloperModal.tsx index b6bcbdc52..65e46dfd4 100644 --- a/src/app/admin/verified-developers/components/EditVerifiedDeveloperModal.tsx +++ b/src/app/admin/verified-developers/components/EditVerifiedDeveloperModal.tsx @@ -1,14 +1,17 @@ 'use client' -import { useState, useEffect, type FormEvent } from 'react' +import { useEffect, useRef, useState, type FormEvent } from 'react' import { Button, Input, Modal, SelectInput } from '@/components/ui' import { api } from '@/lib/api' import toast from '@/lib/toast' import { type RouterOutput } from '@/types/trpc' import getErrorMessage from '@/utils/getErrorMessage' -type VerifiedDeveloper = +type VerifiedDeveloperRecord = RouterOutput['verifiedDevelopers']['getVerifiedDevelopers']['verifiedDevelopers'][number] +type VerifiedDeveloper = Pick & { + user: Pick +} interface Props { isOpen: boolean @@ -18,8 +21,37 @@ interface Props { } function EditVerifiedDeveloperModal(props: Props) { - const [emulatorId, setEmulatorId] = useState('') - const [notes, setNotes] = useState('') + const mutation = api.verifiedDevelopers.updateVerifiedDeveloper.useMutation() + function closeModal() { + if (!mutation.isPending) props.onClose() + } + if (!props.verifiedDeveloper) return null + return ( + + ) +} + +type FormProps = Omit & { + verifiedDeveloper: VerifiedDeveloper + mutation: ReturnType +} + +function VerifiedDeveloperForm(props: FormProps) { + const active = useRef(false) + useEffect(() => { + active.current = true + return () => { + active.current = false + } + }, []) + const [emulatorId, setEmulatorId] = useState(props.verifiedDeveloper.emulatorId) + const [notes, setNotes] = useState(props.verifiedDeveloper.notes ?? '') const [error, setError] = useState('') const emulatorsQuery = api.emulators.get.useQuery({ @@ -28,23 +60,14 @@ function EditVerifiedDeveloperModal(props: Props) { sortDirection: 'asc', }) - const updateVerifiedDeveloperMutation = - api.verifiedDevelopers.updateVerifiedDeveloper.useMutation() - - // Reset form when modal opens or verified developer changes - useEffect(() => { - if (props.isOpen && props.verifiedDeveloper) { - setEmulatorId(props.verifiedDeveloper.emulatorId) - setNotes(props.verifiedDeveloper.notes || '') - setError('') - } - }, [props.isOpen, props.verifiedDeveloper]) + const updateVerifiedDeveloperMutation = props.mutation const handleSubmit = async (ev: FormEvent) => { ev.preventDefault() + if (updateVerifiedDeveloperMutation.isPending) return setError('') - if (!props.verifiedDeveloper || !emulatorId) { + if (!emulatorId) { setError('Please select an emulator') return } @@ -58,17 +81,14 @@ function EditVerifiedDeveloperModal(props: Props) { toast.success('Verified developer updated successfully!') props.onSuccess() + if (active.current) props.onClose() } catch (err) { const errorMessage = getErrorMessage(err) - setError(errorMessage) + if (active.current) setError(errorMessage) toast.error(`Failed to update verified developer: ${errorMessage}`) } } - if (!props.verifiedDeveloper) { - return null - } - const emulatorOptions = [ { id: '', name: 'Select an emulator' }, ...(emulatorsQuery.data?.emulators.map((emulator) => ({ @@ -81,6 +101,8 @@ function EditVerifiedDeveloperModal(props: Props) { + + + ) +} + +type FormProps = Omit & { + mutation: ReturnType +} + +function VerifyDeveloperForm(props: FormProps) { + const active = useRef(false) + useEffect(() => { + active.current = true + return () => { + active.current = false + } + }, []) const [userId, setUserId] = useState(null) const [emulatorId, setEmulatorId] = useState('') const [notes, setNotes] = useState('') @@ -37,17 +73,7 @@ function VerifyDeveloperModal(props: Props) { sortDirection: 'asc', }) - const verifyDeveloperMutation = api.verifiedDevelopers.verifyDeveloper.useMutation() - - // Reset form when modal opens - useEffect(() => { - if (props.isOpen) { - setUserId(null) - setEmulatorId('') - setNotes('') - setError('') - } - }, [props.isOpen]) + const verifyDeveloperMutation = props.mutation const loadUsers = async (query: string): Promise => { return await utils.users.searchUsers.fetch({ @@ -59,6 +85,7 @@ function VerifyDeveloperModal(props: Props) { const handleSubmit = async (ev: FormEvent) => { ev.preventDefault() + if (verifyDeveloperMutation.isPending) return setError('') if (!userId || !emulatorId) { @@ -75,9 +102,10 @@ function VerifyDeveloperModal(props: Props) { toast.success('Developer verified successfully!') props.onSuccess() + if (active.current) props.onClose() } catch (err) { const errorMessage = getErrorMessage(err) - setError(errorMessage) + if (active.current) setError(errorMessage) toast.error(`Failed to verify developer: ${errorMessage}`) } } @@ -91,93 +119,80 @@ function VerifyDeveloperModal(props: Props) { ] return ( - - -
- - label="User" - value={userId} - onChange={setUserId} - loadItems={loadUsers} - optionToValue={(user) => user.id} - optionToLabel={(user) => user.name || user.email} - customOptionRenderer={(user) => ( -
-
-
{user.name || user.email}
- {user.name && ( -
{user.email}
- )} -
-
- {user.role} -
+ +
+ + label="User" + value={userId} + onChange={setUserId} + loadItems={loadUsers} + optionToValue={(user) => user.id} + optionToLabel={(user) => user.name || user.email} + customOptionRenderer={(user) => ( +
+
+
{user.name || user.email}
+ {user.name && ( +
{user.email}
+ )}
- )} - placeholder="Search for a user with DEVELOPER role or higher..." - minCharsToTrigger={2} - debounceTime={300} - /> -
- -
- setEmulatorId(ev.target.value)} - options={emulatorOptions} - /> -
- -
- - setNotes(e.target.value)} - placeholder="Optional notes about this verification..." - rows={3} - className="w-full" - /> -
- - {error && ( -
- {error} -
- )} - -
- - -
- - +
+ {user.role} +
+
+ )} + placeholder="Search for a user with DEVELOPER role or higher..." + minCharsToTrigger={2} + debounceTime={300} + /> +
+ +
+ setEmulatorId(ev.target.value)} + options={emulatorOptions} + /> +
+ +
+ + setNotes(e.target.value)} + placeholder="Optional notes about this verification..." + rows={3} + className="w-full" + /> +
+ + {error && ( +
{error}
+ )} + +
+ + +
+ ) } diff --git a/src/app/admin/verified-developers/page.tsx b/src/app/admin/verified-developers/page.tsx index 0d38cda27..e524c0f58 100644 --- a/src/app/admin/verified-developers/page.tsx +++ b/src/app/admin/verified-developers/page.tsx @@ -114,10 +114,7 @@ function AdminVerifiedDevelopersPage() { } const handleModalSuccess = () => { - utils.verifiedDevelopers.getVerifiedDevelopers.invalidate() - setShowAddModal(false) - setShowEditModal(false) - setSelectedDeveloper(null) + utils.verifiedDevelopers.getVerifiedDevelopers.invalidate().catch(console.error) } const emulatorFilterOptions = [ diff --git a/src/app/admin/vote-investigation/components/VoteAuthorSection.tsx b/src/app/admin/vote-investigation/components/VoteAuthorSection.tsx index 8ae707d86..dd633d721 100644 --- a/src/app/admin/vote-investigation/components/VoteAuthorSection.tsx +++ b/src/app/admin/vote-investigation/components/VoteAuthorSection.tsx @@ -42,12 +42,6 @@ function VoteAuthorSection() { return () => document.removeEventListener('mousedown', handleClickOutside) }, []) - useEffect(() => { - if (authorSearchQuery.data && authorSearch.length >= 2 && !selectedAuthor) { - setShowDropdown(true) - } - }, [authorSearchQuery.data, authorSearch, selectedAuthor]) - return (

@@ -62,11 +56,7 @@ function VoteAuthorSection() { value={authorSearch} onChange={(ev) => { setAuthorSearch(ev.target.value) - if (!selectedAuthor && ev.target.value.length >= 2) { - setShowDropdown(true) - } else if (ev.target.value.length < 2) { - setShowDropdown(false) - } + setShowDropdown(ev.target.value.length >= 2) if ( selectedAuthor && ev.target.value !== (selectedAuthor.name || selectedAuthor.email) @@ -77,28 +67,31 @@ function VoteAuthorSection() { placeholder="Search for the targeted author..." leftIcon={} /> - {showDropdown && authorSearchQuery.data && authorSearchQuery.data.length > 0 && ( -
- {authorSearchQuery.data.map((user) => ( -
- - ))} -

- )} + + ))} +
+ )} {selectedAuthor && (
diff --git a/src/app/auth/patreon/callback/page.test.tsx b/src/app/auth/patreon/callback/page.test.tsx new file mode 100644 index 000000000..f37ca6db3 --- /dev/null +++ b/src/app/auth/patreon/callback/page.test.tsx @@ -0,0 +1,158 @@ +import { act, cleanup, render, screen } from '@testing-library/react' +import { StrictMode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { type RouterInput, type RouterOutput } from '@/types/trpc' +import PatreonCallbackPage from './page' + +type CallbackInput = RouterInput['entitlements']['linkPatreonCallback'] +type CallbackOutput = RouterOutput['entitlements']['linkPatreonCallback'] +const navigation = vi.hoisted(() => ({ replace: vi.fn() })) +const link = vi.hoisted(() => vi.fn<(input: CallbackInput) => Promise>()) +let query = new URLSearchParams() +vi.mock('next/navigation', () => ({ + useRouter: () => navigation, + useSearchParams: () => query, +})) +vi.mock('@/lib/api', () => ({ + api: { entitlements: { linkPatreonCallback: { useMutation: () => ({ mutateAsync: link }) } } }, +})) +const storage = new Map() +Object.defineProperty(window, 'sessionStorage', { + configurable: true, + value: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + }, +}) + +beforeEach(() => { + storage.clear() + query = new URLSearchParams('code=code-one&state=state-one') + navigation.replace.mockReset() + link.mockReset().mockResolvedValue({ ok: true }) + vi.useFakeTimers() +}) +afterEach(() => { + cleanup() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('Patreon callback attempts', () => { + it.each(['getter', 'write'] as const)( + 'links once in Strict Mode when session storage %s is blocked', + async (failure) => { + const descriptor = Object.getOwnPropertyDescriptor(window, 'sessionStorage') + if (failure === 'getter') { + Object.defineProperty(window, 'sessionStorage', { + configurable: true, + get() { + throw new DOMException('Storage blocked', 'SecurityError') + }, + }) + } else { + vi.spyOn(window.sessionStorage, 'setItem').mockImplementation(() => { + throw new DOMException('Storage blocked', 'QuotaExceededError') + }) + } + try { + await act(async () => { + render( + + + , + ) + }) + expect(link).toHaveBeenCalledExactlyOnceWith({ code: 'code-one', state: 'state-one' }) + expect( + screen.getByText('Patreon linked. You now have lifetime downloads!'), + ).toBeInTheDocument() + act(() => vi.advanceTimersByTime(2000)) + expect(navigation.replace).toHaveBeenCalledExactlyOnceWith('/profile?tab=downloads') + } finally { + if (descriptor) Object.defineProperty(window, 'sessionStorage', descriptor) + } + }, + ) + + it('shows a rejected callback when writing the storage marker fails', async () => { + vi.spyOn(window.sessionStorage, 'setItem').mockImplementation(() => { + throw new DOMException('Storage blocked', 'QuotaExceededError') + }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + link.mockRejectedValueOnce(new Error('Authorization failed')) + await act(async () => { + render() + }) + expect(link).toHaveBeenCalledOnce() + expect(screen.getByText('Authorization failed')).toBeInTheDocument() + act(() => vi.advanceTimersByTime(2000)) + expect(navigation.replace).not.toHaveBeenCalled() + }) + + it('displays missing parameters without starting an authorization request', () => { + query = new URLSearchParams() + render() + expect(screen.getByText('Missing code or state')).toBeInTheDocument() + expect(link).not.toHaveBeenCalled() + }) + + it('does not reuse an authorization code already attempted in this tab', () => { + storage.set('patreon_oauth_code:code-one', '1') + render() + expect(screen.getByText(/authorization code was already used/)).toBeInTheDocument() + expect(link).not.toHaveBeenCalled() + }) + + it('makes one attempt in Strict Mode and redirects after a successful link', async () => { + await act(async () => { + render( + + + , + ) + }) + expect(link).toHaveBeenCalledOnce() + expect(link).toHaveBeenCalledWith({ code: 'code-one', state: 'state-one' }) + expect(screen.getByText('Patreon linked. You now have lifetime downloads!')).toBeInTheDocument() + act(() => vi.advanceTimersByTime(2000)) + expect(navigation.replace).toHaveBeenCalledWith('/profile?tab=downloads') + }) + + it('cancels the delayed redirect when the callback page unmounts', async () => { + const view = render() + await act(async () => { + await Promise.resolve() + }) + expect(screen.getByText('Patreon linked. You now have lifetime downloads!')).toBeInTheDocument() + view.unmount() + act(() => vi.advanceTimersByTime(2000)) + expect(navigation.replace).not.toHaveBeenCalled() + }) + + it('starts a fresh attempt when the URL contains a different authorization code', async () => { + const view = render() + await act(async () => { + await Promise.resolve() + }) + query = new URLSearchParams('code=code-two&state=state-two') + await act(async () => { + view.rerender() + }) + expect(link).toHaveBeenCalledTimes(2) + expect(link).toHaveBeenLastCalledWith({ code: 'code-two', state: 'state-two' }) + }) + + it('shows a failed link without scheduling a success redirect', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + link.mockRejectedValueOnce(new Error('Authorization failed')) + await act(async () => { + render() + }) + expect(screen.getByText('Authorization failed')).toBeInTheDocument() + act(() => vi.advanceTimersByTime(2000)) + expect(navigation.replace).not.toHaveBeenCalled() + consoleError.mockRestore() + }) +}) diff --git a/src/app/auth/patreon/callback/page.tsx b/src/app/auth/patreon/callback/page.tsx index 9f74bafea..54d6a6dbb 100644 --- a/src/app/auth/patreon/callback/page.tsx +++ b/src/app/auth/patreon/callback/page.tsx @@ -3,73 +3,94 @@ import { useSearchParams, useRouter } from 'next/navigation' import { Suspense, useEffect, useRef, useState } from 'react' import { LoadingSpinner, Card, Button } from '@/components/ui' +import useMounted from '@/hooks/useMounted' import { api } from '@/lib/api' +import getErrorMessage from '@/utils/getErrorMessage' import { ms } from '@/utils/time' function PatreonCallbackClient() { const search = useSearchParams() - const router = useRouter() const code = search.get('code') const state = search.get('state') + return +} + +interface Props { + code: string | null + state: string | null +} + +function PatreonAttempt(props: Props) { + const router = useRouter() + const isMounted = useMounted() + const [wasCodeUsed] = useState(() => { + if (!props.code || typeof window === 'undefined') return false + try { + return Boolean(window.sessionStorage.getItem(`patreon_oauth_code:${props.code}`)) + } catch { + return false + } + }) const [status, setStatus] = useState<'pending' | 'success' | 'error'>('pending') const [message, setMessage] = useState('Finishing Patreon linking…') const mutate = api.entitlements.linkPatreonCallback.useMutation() const startedRef = useRef(false) + const invalidMessage = + !props.code || !props.state + ? 'Missing code or state' + : wasCodeUsed + ? 'This authorization code was already used. Please start the link again.' + : null + const displayStatus = isMounted && invalidMessage ? 'error' : status + const displayMessage = isMounted && invalidMessage ? invalidMessage : message + useEffect(() => { - if (startedRef.current) return - if (!code || !state) { - setStatus('error') - setMessage('Missing code or state') - return - } - // Guard against accidental double-invocation from Suspense/StrictMode - // and page remounts: ensure a single attempt per unique code in this tab. - const key = `patreon_oauth_code:${code}` - if (typeof window !== 'undefined') { - const seen = sessionStorage.getItem(key) - if (seen) { - setStatus('error') - setMessage('This authorization code was already used. Please start the link again.') - return - } - sessionStorage.setItem(key, '1') - } + if (startedRef.current || !props.code || !props.state || wasCodeUsed) return + // OAuth authorization codes can be redeemed only once. startedRef.current = true + try { + window.sessionStorage.setItem(`patreon_oauth_code:${props.code}`, '1') + } catch { + // Browser storage can be unavailable; the in-memory guard still applies. + } mutate - .mutateAsync({ code, state }) + .mutateAsync({ code: props.code, state: props.state }) .then((res) => { if (res && res.ok) { setStatus('success') setMessage('Patreon linked. You now have lifetime downloads!') - setTimeout(() => router.replace('/profile?tab=downloads'), ms.seconds(2)) } else { setStatus('error') setMessage(res?.message ?? 'Patreon linking failed.') } }) - .catch((err) => { - console.error(err) + .catch((error: unknown) => { + console.error(error) setStatus('error') - setMessage( - err?.message && typeof err.message === 'string' ? err.message : 'Patreon linking failed.', - ) + setMessage(getErrorMessage(error, 'Patreon linking failed.')) }) - }, [code, state, mutate, router]) + }, [props.code, props.state, wasCodeUsed, mutate]) + + useEffect(() => { + if (status !== 'success') return + const timer = setTimeout(() => router.replace('/profile?tab=downloads'), ms.seconds(2)) + return () => clearTimeout(timer) + }, [router, status]) return (
- {status === 'pending' && ( + {displayStatus === 'pending' && ( <> -

{message}

+

{displayMessage}

)} - {status !== 'pending' && ( + {displayStatus !== 'pending' && ( <> -

{message}

+

{displayMessage}

)} @@ -246,7 +211,7 @@ function GamesContent() { /> )} - {user && ( + {user && userQuery.data && (
+interface MutationOptions { + onSuccess: (result: ImageResult | null) => Promise + onError: (error: Error) => void +} +const recordMutation = vi.hoisted(() => vi.fn<(options: MutationOptions) => void>()) +vi.mock('@clerk/nextjs', () => ({ useUser: () => ({ user: { id: 'user-1' } }) })) +vi.mock('@/lib/toast', () => ({ default: { success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/api', () => ({ + api: { + useUtils: () => ({ games: { byId: { invalidate: vi.fn().mockResolvedValue(undefined) } } }), + users: { me: { useQuery: () => ({ data: { id: 'user-1', role: Role.MODERATOR } }) } }, + games: { + update: { + useMutation: (options: MutationOptions) => { + recordMutation(options) + return { mutate: vi.fn(), isPending: false } + }, + }, + }, + }, +})) +vi.mock('@/components/ui', async (importOriginal) => { + const actual = await importOriginal() + function ImagePreview(props: Pick, 'src' | 'alt'>) { + return
+ } + return { ...actual, OptimizedImage: ImagePreview } +}) + +function game(id: string, imageUrl: string | null): Game { + return { + id, + title: `Game ${id}`, + normalizedTitle: null, + systemId: 'system-1', + imageUrl, + boxartUrl: null, + bannerUrl: null, + tgdbGameId: null, + metadata: null, + isErotic: false, + ageRating: null, + status: ApprovalStatus.APPROVED, + submittedBy: null, + submittedAt: null, + approvedBy: null, + approvedAt: null, + createdAt: new Date('2026-01-01'), + } +} +async function acknowledge(images: ImageResult) { + const callback = recordMutation.mock.lastCall?.[0].onSuccess + if (!callback) throw new Error('Image update callback was not registered') + await act(() => callback(images)) +} +beforeEach(() => vi.clearAllMocks()) + +describe('game image acknowledgements', () => { + it.each(['boxartUrl', 'bannerUrl'] as const)('falls back to the available %s', (field) => { + const original = { ...game('one', null), [field]: 'https://example.com/fallback.webp' } + render() + expect(screen.getByRole('img')).toHaveAttribute( + 'data-source', + 'https://example.com/fallback.webp', + ) + }) + + it('keeps a confirmed image until refreshed server images arrive', async () => { + const original = game('one', 'https://example.com/one.webp') + const view = render() + await acknowledge({ + imageUrl: 'https://example.com/confirmed.webp', + boxartUrl: null, + bannerUrl: null, + }) + expect(screen.getByRole('img')).toHaveAttribute( + 'data-source', + 'https://example.com/confirmed.webp', + ) + view.rerender( + , + ) + expect(screen.getByRole('img')).toHaveAttribute( + 'data-source', + 'https://example.com/refreshed.webp', + ) + }) + + it('uses the confirmed empty images for fallback instead of resurrecting an old cover', async () => { + const original = game('one', 'https://example.com/one.webp') + render() + await acknowledge({ imageUrl: null, boxartUrl: null, bannerUrl: null }) + expect(screen.getByRole('img')).not.toHaveAttribute('data-source', original.imageUrl) + }) + + it('does not carry a confirmed image into a different game', async () => { + const view = render() + await acknowledge({ + imageUrl: 'https://example.com/confirmed.webp', + boxartUrl: null, + bannerUrl: null, + }) + view.rerender() + expect(screen.getByRole('img')).toHaveAttribute('data-source', 'https://example.com/two.webp') + }) +}) diff --git a/src/app/games/[id]/components/GameBoxartImage.tsx b/src/app/games/[id]/components/GameBoxartImage.tsx index fff6b5f60..d7d55f7c0 100644 --- a/src/app/games/[id]/components/GameBoxartImage.tsx +++ b/src/app/games/[id]/components/GameBoxartImage.tsx @@ -2,7 +2,7 @@ import { useUser } from '@clerk/nextjs' import { Edit3, Image as ImageIconLucide, Copy } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { Button, OptimizedImage, Modal } from '@/components/ui' import { AdminImageSelectorSwitcher } from '@/components/ui/image-selectors' import { api } from '@/lib/api' @@ -27,16 +27,35 @@ const DEFAULT_HEIGHT = 400 type ImageField = 'imageUrl' | 'boxartUrl' | 'bannerUrl' +function imageUrls(game: Pick) { + return { + imageUrl: game.imageUrl, + boxartUrl: game.boxartUrl, + bannerUrl: game.bannerUrl, + } +} + export function GameBoxartImage(props: Props) { + return +} + +function GameBoxartContent(props: Props) { const { user } = useUser() const [activeImageType, setActiveImageType] = useState('imageUrl') const [showModal, setShowModal] = useState(false) const [selectedUrl, setSelectedUrl] = useState('') - const [displayUrls, setDisplayUrls] = useState>({ - imageUrl: props.game.imageUrl ?? '', - boxartUrl: props.game.boxartUrl ?? '', - bannerUrl: props.game.bannerUrl ?? '', - }) + const [imageUpdate, setImageUpdate] = useState<{ + source: Pick + urls: ReturnType + } | null>(null) + if ( + imageUpdate && + (imageUpdate.source.imageUrl !== props.game.imageUrl || + imageUpdate.source.boxartUrl !== props.game.boxartUrl || + imageUpdate.source.bannerUrl !== props.game.bannerUrl) + ) + setImageUpdate(null) + const displayUrls = imageUpdate?.urls ?? imageUrls(props.game) const utils = api.useUtils() const userQuery = api.users.me.useQuery(undefined, { @@ -48,11 +67,7 @@ export function GameBoxartImage(props: Props) { toast.success('Game image updated successfully') setSelectedUrl('') if (updated) { - setDisplayUrls({ - imageUrl: updated.imageUrl ?? '', - boxartUrl: updated.boxartUrl ?? '', - bannerUrl: updated.bannerUrl ?? '', - }) + setImageUpdate({ source: props.game, urls: imageUrls(updated) }) } try { await utils.games.byId.invalidate({ id: props.game.id }) @@ -102,15 +117,6 @@ export function GameBoxartImage(props: Props) { const getFieldValue = (field: ImageField) => displayUrls[field] || '' - // Sync local display when server data changes - useEffect(() => { - setDisplayUrls({ - imageUrl: props.game.imageUrl ?? '', - boxartUrl: props.game.boxartUrl ?? '', - bannerUrl: props.game.bannerUrl ?? '', - }) - }, [props.game.imageUrl, props.game.boxartUrl, props.game.bannerUrl]) - const getFieldLabel = (field: ImageField) => { const labels = { imageUrl: 'Cover Image', @@ -124,7 +130,7 @@ export function GameBoxartImage(props: Props) { const activeImageUrl = getFieldValue(activeImageType) return activeImageUrl ? getImageUrl(activeImageUrl, props.game.title) - : getGameImageUrl(props.game) + : getGameImageUrl({ ...props.game, ...displayUrls }) } const availableImageTypes: ImageField[] = ['imageUrl', 'boxartUrl', 'bannerUrl'] diff --git a/src/app/games/components/GameCard.test.tsx b/src/app/games/components/GameCard.test.tsx new file mode 100644 index 000000000..7e90b7d19 --- /dev/null +++ b/src/app/games/components/GameCard.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react' +import { type ComponentProps } from 'react' +import { describe, expect, it } from 'vitest' +import { ApprovalStatus } from '@orm' +import GameCard from './GameCard' + +const game = { + id: '00000000-0000-4000-a000-000000000001', + title: 'Mario Kart World', + normalizedTitle: 'mario kart world', + systemId: '00000000-0000-4000-a000-000000000002', + imageUrl: null, + boxartUrl: 'https://example.com/game-cover.webp', + bannerUrl: null, + tgdbGameId: null, + metadata: null, + isErotic: false, + ageRating: null, + status: ApprovalStatus.APPROVED, + submittedBy: null, + submittedAt: null, + approvedBy: null, + approvedAt: null, + createdAt: new Date('2026-11-01T12:00:00.000Z'), + system: { name: 'Nintendo Switch 2' }, + _count: { listings: 1, pcListings: 1 }, +} satisfies ComponentProps['game'] + +describe('externally hosted game covers', () => { + it.each([ + { eagerLoad: undefined, loading: 'lazy' }, + { eagerLoad: false, loading: 'lazy' }, + { eagerLoad: true, loading: 'eager' }, + ])('uses $loading loading when eagerLoad is $eagerLoad', (input) => { + render() + const cover = screen.getByRole('img', { name: game.title }) + expect(cover).toHaveAttribute('src', game.boxartUrl) + expect(cover).toHaveAttribute('referrerpolicy', 'no-referrer') + expect(cover).toHaveAttribute('loading', input.loading) + }) +}) diff --git a/src/app/games/components/GameCard.tsx b/src/app/games/components/GameCard.tsx index ec199f4ef..9aab6ad6a 100644 --- a/src/app/games/components/GameCard.tsx +++ b/src/app/games/components/GameCard.tsx @@ -34,7 +34,7 @@ function GameCard(props: Props) { fill className="object-cover" sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" - loading={props.eagerLoad ? 'eager' : undefined} + loading={props.eagerLoad ? 'eager' : 'lazy'} unoptimized />
diff --git a/src/app/games/new/search/GameSearchPage.tsx b/src/app/games/new/search/GameSearchPage.tsx index 367b06d39..8249d0e42 100644 --- a/src/app/games/new/search/GameSearchPage.tsx +++ b/src/app/games/new/search/GameSearchPage.tsx @@ -14,6 +14,7 @@ import { LoadingSpinner } from '@/components/ui' import { useSubmitWithHumanVerification } from '@/features/human-verification/client' import { api } from '@/lib/api' import { logger } from '@/lib/logger' +import { type RouterInput } from '@/types/trpc' import getErrorMessage from '@/utils/getErrorMessage' import { hasRolePermission } from '@/utils/permissions' import { Role } from '@orm' @@ -22,7 +23,7 @@ import { useGameSearch } from './hooks/useGameSearch' import { extractBoxartUrl } from './utils/boxartHelpers' import { handleGameCreationError } from './utils/gameCreationErrors' import NotSignedInMessage from '../components/NotSignedInMessage' -import type { TGDBGame, TGDBGamesByNameResponse } from '@/types/tgdb' +import type { TGDBGame } from '@/types/tgdb' interface TGDBGameResult extends BaseGameResult { id: string @@ -68,15 +69,24 @@ function TGDBSearchContent() { const urlQuery = searchParams.get('q') ?? '' const urlSystemId = searchParams.get('system') ?? '' - const [searchResults, setSearchResults] = useState<{ - games: TGDBGameResult[] - count: number + const [submittedSearch, setSubmittedSearch] = useState<{ + urlKey: string + systemId: string | null + input: RouterInput['tgdb']['searchGames'] } | null>(null) + const urlKey = JSON.stringify([urlQuery, urlSystemId || null]) + const [formKey, setFormKey] = useState(0) + const [previousUrlKey, setPreviousUrlKey] = useState(urlKey) + if (previousUrlKey !== urlKey) { + setPreviousUrlKey(urlKey) + if (submittedSearch?.urlKey !== urlKey) { + setSubmittedSearch(null) + setFormKey((previous) => previous + 1) + } + } const [selectedGame, setSelectedGame] = useState(null) - const [searchResponse, setSearchResponse] = useState(null) const [isModalOpen, setIsModalOpen] = useState(false) const [isSelecting, setIsSelecting] = useState(false) - const [isSearching, setIsSearching] = useState(false) const utils = api.useUtils() const userQuery = api.users.me.useQuery(undefined, { @@ -86,71 +96,55 @@ function TGDBSearchContent() { const submitWithHumanVerification = useSubmitWithHumanVerification() const systemsQuery = api.systems.get.useQuery() + const selectedSystem = systemsQuery.data?.find((system) => system.id === urlSystemId) + const searchInput = + submittedSearch?.input ?? + (urlQuery && selectedSystem ? { query: urlQuery, systemKey: selectedSystem.key } : null) + const searchQuery = api.tgdb.searchGames.useQuery(searchInput ?? { query: '' }, { + enabled: Boolean(user && searchInput), + refetchOnWindowFocus: false, + retry: false, + }) + const searchResponse = searchQuery.data ?? null + const searchResults = useMemo(() => { + if (!searchResponse) return null + return { + games: (searchResponse.data?.games ?? []).map((game) => { + const enrichedGame: TGDBGameWithBoxart = { + ...game, + boxart: extractBoxartUrl(game, searchResponse) || undefined, + platform_name: typeof game.platform === 'string' ? game.platform : undefined, + genre_names: + Array.isArray(game.genres) && typeof game.genres[0] === 'string' + ? game.genres.map(String) + : undefined, + } + return mapTGDBToBaseGame(enrichedGame) + }), + count: searchResponse.data?.count ?? 0, + } + }, [searchResponse]) + const currentSystemId = submittedSearch?.systemId ?? urlSystemId const { existingGames, updateSearchParams } = useGameSearch({ searchResults, - urlSystemId, + urlSystemId: currentSystemId, }) - const handleSearch = useCallback( - async (query: string, _platformId: number | null, systemId: string | null) => { - setIsSearching(true) - - updateSearchParams(query, systemId) - - const selectedSystem = systemId ? systemsQuery.data?.find((s) => s.id === systemId) : null - - try { - const results = await utils.tgdb.searchGames.fetch({ - query, - systemKey: selectedSystem?.key, - }) - - setSearchResponse(results) // Store for modal - - // Map TGDB games to our unified format - const gameData = results.data?.games ?? [] - const mappedGames = gameData.map((game) => { - const boxartUrl = extractBoxartUrl(game, results) - - const enrichedGame: TGDBGameWithBoxart = { - ...game, - boxart: boxartUrl || undefined, - platform_name: typeof game.platform === 'string' ? game.platform : undefined, - genre_names: - Array.isArray(game.genres) && - game.genres.length > 0 && - typeof game.genres[0] === 'string' - ? game.genres.map(String) - : undefined, - } - return mapTGDBToBaseGame(enrichedGame) - }) - setSearchResults({ - games: mappedGames, - count: results.data?.count ?? 0, - }) - } catch (error) { - logger.error('Search error:', error) - toast.error(getErrorMessage(error, 'Failed to search games')) - } finally { - setIsSearching(false) - } - }, - [utils, updateSearchParams, systemsQuery.data], - ) + function handleSearch(query: string, _platformId: number | null, systemId: string | null) { + const system = systemsQuery.data?.find((item) => item.id === systemId) + const input = { query, systemKey: system?.key } + setSubmittedSearch({ urlKey: JSON.stringify([query, systemId]), systemId, input }) + updateSearchParams(query, systemId) + if (searchInput?.query === query && searchInput.systemKey === input.systemKey) { + void searchQuery.refetch() + } + } - // Auto-search on page load if URL has parameters useEffect(() => { - if (urlQuery && systemsQuery.data && !searchResults) { - const selectedSystem = urlSystemId - ? systemsQuery.data.find((system) => system.id === urlSystemId) - : null - - if (selectedSystem) { - handleSearch(urlQuery, null, selectedSystem.id).catch(console.error) - } - } - }, [urlQuery, urlSystemId, systemsQuery.data, searchResults, handleSearch]) + if (!searchQuery.error) return + logger.error('Search error:', searchQuery.error) + toast.error(getErrorMessage(searchQuery.error, 'Failed to search games')) + }, [searchQuery.error]) const handleGamePreview = useCallback( (game: BaseGameResult) => { @@ -241,12 +235,13 @@ function TGDBSearchContent() { /> @@ -256,7 +251,7 @@ function TGDBSearchContent() { results={searchResults} onGameSelect={handleGamePreview} existingGames={existingGames} - currentSystemId={urlSystemId} + currentSystemId={currentSystemId} /> )} @@ -267,7 +262,7 @@ function TGDBSearchContent() { onClose={() => setIsModalOpen(false)} onSelect={(game) => { const gameWithBoxart = { ...game, boxart: selectedGame?.boxart } - handleGameSelect(gameWithBoxart, urlSystemId) + handleGameSelect(gameWithBoxart, currentSystemId) }} isSelecting={isSelecting} /> diff --git a/src/app/games/new/search/provider-queries.test.tsx b/src/app/games/new/search/provider-queries.test.tsx new file mode 100644 index 000000000..cd72abf6d --- /dev/null +++ b/src/app/games/new/search/provider-queries.test.tsx @@ -0,0 +1,224 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { TRPCClientError, type TRPCLink } from '@trpc/client' +import { observable } from '@trpc/server/observable' +import { useEffect } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { type GameSearchFormProps, type BaseGameResult } from '@/components/game-search/types' +import { api } from '@/lib/api' +import { type AppRouter } from '@/types/trpc' +import GameSearchPage from './GameSearchPage' +import IGDBSearchPage from './v2/IGDBSearchPage' + +let params = new URLSearchParams() +const mocks = vi.hoisted(() => ({ + replace: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + formMount: vi.fn(), +})) +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: mocks.replace, push: vi.fn() }), + useSearchParams: () => params, + usePathname: () => '/games/new/search', +})) +vi.mock('@clerk/nextjs', () => ({ useUser: () => ({ user: { id: 'user' }, isLoaded: true }) })) +vi.mock('sonner', () => ({ toast: { error: mocks.error, warning: mocks.warning } })) +vi.mock('@/lib/logger', () => ({ logger: { error: vi.fn() } })) +vi.mock('@/features/human-verification/client', () => ({ + useSubmitWithHumanVerification: () => vi.fn(), +})) +vi.mock('./components/GamePreviewModal', () => ({ default: () => null })) +vi.mock('./v2/components/IGDBGamePreviewModal', () => ({ default: () => null })) +vi.mock('@/components/game-search', () => ({ + GameSearchHeader: () => null, + GameSearchForm: function Form(props: GameSearchFormProps) { + useEffect(() => { + mocks.formMount() + }, []) + return ( + + ) + }, + GameSearchResults: (props: { results: { games: BaseGameResult[] } }) => ( +
+ {props.results.games.map((game) => ( + {game.name} + ))} +
+ ), +})) + +const systems = [{ id: 'switch2', name: 'Nintendo Switch 2', key: 'nintendo_switch_2' }] +let providerResult: (query: string) => Promise +const requests: { path: string; input: unknown }[] = [] + +function mount(provider: 'igdb' | 'tgdb') { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const link: TRPCLink = + () => + ({ op }) => + observable((observer) => { + let active = true + async function respond() { + if (op.path === 'systems.get') return systems + if (op.path === 'users.me') return { role: 'ADMIN' } + if (op.path === 'games.checkExistingByNamesAndSystems') return {} + requests.push({ path: op.path, input: op.input }) + const input = op.input + if ( + typeof input !== 'object' || + input === null || + !('query' in input) || + typeof input.query !== 'string' + ) { + throw new Error('Unexpected test request') + } + const name = await providerResult(input.query) + if (provider === 'igdb') { + return { + games: [ + { id: 1, name, imageUrl: null, boxartUrl: null, bannerUrl: null, isErotic: false }, + ], + count: 1, + } + } + return { + code: 200, + status: 'Success', + remaining_monthly_allowance: 10, + extra_allowance: 0, + pages: { previous: '', current: '', next: '' }, + data: { count: 1, games: [{ id: 1, game_title: name }] }, + } + } + respond().then( + (data) => { + if (!active) return + observer.next({ result: { data } }) + observer.complete() + }, + (error: unknown) => { + if (active) + observer.error( + TRPCClientError.from( + error instanceof Error ? error : new Error(String(error)), + ), + ) + }, + ) + return () => { + active = false + } + }) + const client = api.createClient({ links: [link] }) + const Page = provider === 'igdb' ? IGDBSearchPage : GameSearchPage + const view = () => ( + + + + + + ) + const rendered = render(view()) + return { rerender: () => rendered.rerender(view()) } +} + +beforeEach(() => { + vi.clearAllMocks() + requests.length = 0 + params = new URLSearchParams('q=Initial&system=switch2') + providerResult = async (query) => `${query} result` +}) + +describe.each(['igdb', 'tgdb'] as const)('%s search query state', (provider) => { + it('uses the Switch 2 mapping for URL searches', async () => { + mount(provider) + await screen.findByText('Initial result') + expect(requests).toContainEqual({ + path: `${provider}.searchGames`, + input: + provider === 'igdb' + ? { query: 'Initial', platformId: 508, limit: 20 } + : { query: 'Initial', systemKey: 'nintendo_switch_2' }, + }) + }) + + it('waits for a recognized system before searching a URL', async () => { + params = new URLSearchParams('q=Initial&system=unknown') + mount(provider) + await screen.findByRole('button', { name: 'Search all platforms' }) + expect(requests).toHaveLength(0) + }) + + it('keeps a manual platform override through its URL update and allows repeat searches', async () => { + const page = mount(provider) + await screen.findByText('Initial result') + fireEvent.click(screen.getByRole('button', { name: 'Search all platforms' })) + await screen.findByText('Manual result') + expect(mocks.formMount).toHaveBeenCalledOnce() + const manual = { + path: `${provider}.searchGames`, + input: + provider === 'igdb' + ? { query: 'Manual', platformId: null, limit: 20 } + : { query: 'Manual', systemKey: 'nintendo_switch_2' }, + } + expect(requests).toContainEqual(manual) + params = new URLSearchParams('q=Manual&system=switch2') + page.rerender() + expect(mocks.formMount).toHaveBeenCalledOnce() + expect(screen.getByText('Manual result')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Search all platforms' })) + await waitFor(() => + expect( + requests.filter((request) => JSON.stringify(request) === JSON.stringify(manual)), + ).toHaveLength(2), + ) + }) + + it('starts a new form session when Back returns to its previous initialization URL', async () => { + const page = mount(provider) + await screen.findByText('Initial result') + fireEvent.click(screen.getByRole('button', { name: 'Search all platforms' })) + await screen.findByText('Manual result') + params = new URLSearchParams('q=Manual&system=switch2') + page.rerender() + expect(mocks.formMount).toHaveBeenCalledOnce() + params = new URLSearchParams('q=Initial&system=switch2') + page.rerender() + await screen.findByText('Initial result') + expect(mocks.formMount).toHaveBeenCalledTimes(2) + }) + + it('ignores late results after navigating to another query', async () => { + let resolveOld: (name: string) => void = () => {} + providerResult = (query) => + query === 'Initial' + ? new Promise((resolve) => { + resolveOld = resolve + }) + : Promise.resolve(`${query} result`) + const page = mount(provider) + await waitFor(() => expect(requests).toHaveLength(1)) + params = new URLSearchParams('q=New&system=switch2') + page.rerender() + await screen.findByText('New result') + await act(async () => resolveOld('Old result')) + expect(screen.queryByText('Old result')).not.toBeInTheDocument() + expect(screen.getByText('New result')).toBeInTheDocument() + }) + + it('reports provider errors and stops loading', async () => { + providerResult = async () => { + throw new Error('Provider unavailable') + } + mount(provider) + await waitFor(() => expect(mocks.error).toHaveBeenCalledWith('Provider unavailable')) + expect(screen.getByRole('button', { name: 'Search all platforms' })).toBeInTheDocument() + }) +}) diff --git a/src/app/games/new/search/v2/IGDBSearchPage.tsx b/src/app/games/new/search/v2/IGDBSearchPage.tsx index 7f5336536..3c35317c4 100644 --- a/src/app/games/new/search/v2/IGDBSearchPage.tsx +++ b/src/app/games/new/search/v2/IGDBSearchPage.tsx @@ -3,7 +3,7 @@ import { useUser } from '@clerk/nextjs' import { AlertTriangle } from 'lucide-react' import { useRouter, useSearchParams } from 'next/navigation' -import { useState, useCallback, useEffect, Suspense } from 'react' +import { useState, useCallback, useEffect, useMemo, Suspense } from 'react' import { toast } from 'sonner' import { GameSearchForm, @@ -15,6 +15,7 @@ import { LoadingSpinner, useConfirmDialog } from '@/components/ui' import { useSubmitWithHumanVerification } from '@/features/human-verification/client' import { api } from '@/lib/api' import { logger } from '@/lib/logger' +import { type RouterInput } from '@/types/trpc' import getErrorMessage from '@/utils/getErrorMessage' import { hasRolePermission } from '@/utils/permissions' import { getIGDBPlatformId } from '@/utils/system-platform-mapping' @@ -44,14 +45,24 @@ function IGDBSearchContent() { const urlQuery = searchParams.get('q') ?? '' const urlSystemId = searchParams.get('system') ?? '' - const [searchResults, setSearchResults] = useState<{ - games: IGDBGameResult[] - count: number + const [submittedSearch, setSubmittedSearch] = useState<{ + urlKey: string + systemId: string + input: RouterInput['igdb']['searchGames'] } | null>(null) + const urlKey = JSON.stringify([urlQuery, urlSystemId || null]) + const [formKey, setFormKey] = useState(0) + const [previousUrlKey, setPreviousUrlKey] = useState(urlKey) + if (previousUrlKey !== urlKey) { + setPreviousUrlKey(urlKey) + if (submittedSearch?.urlKey !== urlKey) { + setSubmittedSearch(null) + setFormKey((previous) => previous + 1) + } + } const [selectedGame, setSelectedGame] = useState(null) const [isModalOpen, setIsModalOpen] = useState(false) const [isSelecting, setIsSelecting] = useState(false) - const [isSearching, setIsSearching] = useState(false) const utils = api.useUtils() const userQuery = api.users.me.useQuery(undefined, { @@ -62,56 +73,54 @@ function IGDBSearchContent() { const systemsQuery = api.systems.get.useQuery() const confirm = useConfirmDialog() + const selectedSystem = systemsQuery.data?.find((system) => system.id === urlSystemId) + const searchInput = + submittedSearch?.input ?? + (urlQuery && selectedSystem + ? { query: urlQuery, platformId: getIGDBPlatformId(selectedSystem), limit: 20 } + : null) + const searchQuery = api.igdb.searchGames.useQuery(searchInput ?? { query: '', limit: 20 }, { + enabled: Boolean(user && searchInput), + refetchOnWindowFocus: false, + retry: false, + }) + const searchResults = useMemo( + () => + searchQuery.data + ? { + games: searchQuery.data.games.map((game) => ({ + ...game, + summary: game.summary ?? null, + })), + count: searchQuery.data.count, + } + : null, + [searchQuery.data], + ) + const currentSystemId = submittedSearch?.systemId ?? urlSystemId const { existingGames, updateSearchParams } = useGameSearch({ searchResults, - urlSystemId, + urlSystemId: currentSystemId, }) - const handleSearch = useCallback( - async (query: string, platformId: number | null, systemId: string | null) => { - if (!systemId) { - toast.warning('Please select a system before searching for games') - return - } - - setIsSearching(true) - - updateSearchParams(query, systemId) // Update URL with search parameters - - try { - const results = await utils.igdb.searchGames.fetch({ - query, - platformId, - limit: 20, - }) - setSearchResults({ - games: results.games.map((game) => ({ - ...game, - summary: game.summary ?? null, - })), - count: results.count, - }) - } catch (error) { - logger.error('Search error:', error) - toast.error(getErrorMessage(error, 'Failed to search games')) - } finally { - setIsSearching(false) - } - }, - [utils, updateSearchParams], - ) + function handleSearch(query: string, platformId: number | null, systemId: string | null) { + if (!systemId) { + toast.warning('Please select a system before searching for games') + return + } + const input = { query, platformId, limit: 20 } + setSubmittedSearch({ urlKey: JSON.stringify([query, systemId]), systemId, input }) + updateSearchParams(query, systemId) + if (searchInput?.query === query && searchInput.platformId === platformId) { + void searchQuery.refetch() + } + } - // Auto-search on page load if URL has parameters useEffect(() => { - if (urlQuery && urlSystemId && systemsQuery.data && !searchResults) { - const selectedSystem = systemsQuery.data.find((system) => system.id === urlSystemId) - - if (selectedSystem) { - const igdbPlatformId = getIGDBPlatformId(selectedSystem) - void handleSearch(urlQuery, igdbPlatformId, selectedSystem.id) - } - } - }, [urlQuery, urlSystemId, systemsQuery.data, searchResults, handleSearch]) + if (!searchQuery.error) return + logger.error('Search error:', searchQuery.error) + toast.error(getErrorMessage(searchQuery.error, 'Failed to search games')) + }, [searchQuery.error]) const showGameCreatedConfirmation = useCallback( async (gameId: string) => { @@ -205,16 +214,17 @@ function IGDBSearchContent() { /> - {searchResults && !urlSystemId && ( + {searchResults && !currentSystemId && (
@@ -229,13 +239,13 @@ function IGDBSearchContent() {
)} - {searchResults && urlSystemId && ( + {searchResults && currentSystemId && ( provider="igdb" results={searchResults} onGameSelect={handleGamePreview} existingGames={existingGames} - currentSystemId={urlSystemId} + currentSystemId={currentSystemId} /> )} @@ -247,7 +257,7 @@ function IGDBSearchContent() { systems={systemsQuery.data ?? []} isSelecting={isSelecting} existingGames={existingGames} - currentSystemId={urlSystemId} + currentSystemId={currentSystemId} />
diff --git a/src/app/listings/ListingsPage.tsx b/src/app/listings/ListingsPage.tsx index 29b3ab047..4d752b1df 100644 --- a/src/app/listings/ListingsPage.tsx +++ b/src/app/listings/ListingsPage.tsx @@ -31,6 +31,11 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/Tooltip import { VerifiedDeveloperBadge } from '@/components/ui/VerifiedDeveloperBadge' import { CACHE_DURATIONS } from '@/data/constants' import storageKeys from '@/data/storageKeys' +import BookmarkButton from '@/features/bookmarks/client/components/BookmarkButton' +import { + useBulkBookmarkStatuses, + getBookmarkStatus, +} from '@/features/bookmarks/client/hooks/useBulkBookmarkStatuses' import { useEmulatorLogos, useLocalStorage, @@ -39,6 +44,7 @@ import { } from '@/hooks' import { api } from '@/lib/api' import { cn } from '@/lib/utils' +import { BooleanPreferenceSchema } from '@/schemas/preferences' import { type RouterInput } from '@/types/trpc' import { filterNullAndEmpty } from '@/utils/filter' import { @@ -72,9 +78,11 @@ function ListingsPage() { const listingsState = useListingsState() const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false) + const [isFiltersCollapsed, setIsFiltersCollapsed] = useState(false) const [showSystemIcons, setShowSystemIcons, isSystemIconsHydrated] = useLocalStorage( storageKeys.showSystemIcons, true, + BooleanPreferenceSchema, ) // Preferred hardware filters (devices/SoCs) @@ -133,6 +141,13 @@ function ListingsPage() { placeholderData: (previous) => previous, }) + const bookmarks = useBulkBookmarkStatuses( + 'listing', + (listingsQuery.data?.listings ?? []) + .filter((report) => report.status === ApprovalStatus.APPROVED) + .map((report) => report.id), + ) + const handleSystemChange = useCallback( (values: string[]) => { listingsState.setSystemIds(values) @@ -242,8 +257,8 @@ function ListingsPage() { onPerformanceChange={handlePerformanceChange} onSearchChange={handleSearchChange} onClearAll={handleClearAllFilters} - isCollapsed={isMobileSidebarOpen} - onToggleCollapse={() => setIsMobileSidebarOpen((prevState) => !prevState)} + isCollapsed={isFiltersCollapsed} + onToggleCollapse={() => setIsFiltersCollapsed((prevState) => !prevState)} userPreferences={userPreferencesQuery.data} shouldUseUserDeviceFilter={preferred.shouldUseUserDeviceFilter} userDeviceIds={preferred.userDeviceIds} @@ -635,6 +650,13 @@ function ListingsPage() { {columnVisibility.isColumnVisible('actions') && ( e.stopPropagation()}>
+ {listing.status === ApprovalStatus.APPROVED && ( + + )} {isAdmin && ( ({ useLocalStorage: () => [false, vi.fn(), true] })) +vi.mock('@/lib/analytics', () => ({ + default: { engagement: { voteReminderShown: vi.fn() } }, +})) +vi.mock('../VotingHelpModal', () => ({ default: () => null })) + +describe('VoteReminderBanner timing', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + }) + + afterEach(() => vi.useRealTimers()) + + it('keeps the original timer and page duration across rerenders', () => { + const props = { + listingId: 'report-1', + currentVote: null, + onVote: vi.fn(), + onVoteClick: vi.fn(), + } + const view = render() + act(() => vi.advanceTimersByTime(30_000)) + view.rerender() + act(() => vi.advanceTimersByTime(30_000)) + + expect(analytics.engagement.voteReminderShown).toHaveBeenCalledExactlyOnceWith({ + listingId: 'report-1', + timeOnPage: 60, + }) + }) + + it('starts a new delay when an external vote is removed', () => { + const props = { listingId: 'report-1', onVote: vi.fn(), onVoteClick: vi.fn() } + const view = render() + act(() => vi.advanceTimersByTime(60_000)) + view.rerender() + view.rerender() + act(() => vi.advanceTimersByTime(59_000)) + expect(analytics.engagement.voteReminderShown).toHaveBeenCalledTimes(1) + act(() => vi.advanceTimersByTime(1_000)) + expect(analytics.engagement.voteReminderShown).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/app/listings/[id]/components/vote-reminder/VoteReminderBanner.tsx b/src/app/listings/[id]/components/vote-reminder/VoteReminderBanner.tsx index 7c35c4521..adc635b98 100644 --- a/src/app/listings/[id]/components/vote-reminder/VoteReminderBanner.tsx +++ b/src/app/listings/[id]/components/vote-reminder/VoteReminderBanner.tsx @@ -2,10 +2,11 @@ import { motion, AnimatePresence } from 'framer-motion' import { X, HelpCircle } from 'lucide-react' -import { useState, useEffect, useMemo, useRef } from 'react' +import { useState, useEffect } from 'react' import storageKeys from '@/data/storageKeys' import { useLocalStorage } from '@/hooks' import analytics from '@/lib/analytics' +import { BooleanPreferenceSchema } from '@/schemas/preferences' import { ms } from '@/utils/time' import VotingHelpModal from '../VotingHelpModal' import voteReminderMessages from './voteReminderMessages' @@ -22,22 +23,28 @@ export function VoteReminderBanner(props: Props) { const [hasBeenDismissed, setHasBeenDismissed] = useLocalStorage( storageKeys.popups.voteReminderDismissed, false, + BooleanPreferenceSchema, ) const [showHelpModal, setShowHelpModal] = useState(false) - // Track the time when component mounts - const startTimeRef = useRef(Date.now()) + const [startTime] = useState(() => Date.now()) + const [previousVote, setPreviousVote] = useState(props.currentVote) + + if (previousVote !== props.currentVote) { + setPreviousVote(props.currentVote) + setIsVisible(false) + } // Function to get actual time spent on page in seconds const getTimeOnPage = (): number => { - return Math.round((Date.now() - startTimeRef.current) / 1000) + return Math.round((Date.now() - startTime) / 1000) } // Randomly select a message when the component mounts - const selectedMessage = useMemo(() => { + const [selectedMessage] = useState(() => { const randomIndex = Math.floor(Math.random() * voteReminderMessages.length) return voteReminderMessages[randomIndex] - }, []) + }) useEffect(() => { // Don't show if user has already voted or dismissed @@ -47,19 +54,12 @@ export function VoteReminderBanner(props: Props) { setIsVisible(true) analytics.engagement.voteReminderShown({ listingId: props.listingId, - timeOnPage: getTimeOnPage(), + timeOnPage: Math.round((Date.now() - startTime) / 1000), }) }, ms.seconds(60)) return () => clearTimeout(timer) - }, [hasBeenDismissed, props.listingId, props.currentVote]) - - // Hide banner when user votes elsewhere after it's been shown - useEffect(() => { - if (isVisible && props.currentVote !== null) { - setIsVisible(false) - } - }, [props.currentVote, isVisible]) + }, [hasBeenDismissed, props.listingId, props.currentVote, startTime]) const handleDismiss = () => { analytics.engagement.voteReminderDismissed({ diff --git a/src/app/listings/components/ListingsFiltersContent.tsx b/src/app/listings/components/ListingsFiltersContent.tsx index f5b606e15..3f0175b15 100644 --- a/src/app/listings/components/ListingsFiltersContent.tsx +++ b/src/app/listings/components/ListingsFiltersContent.tsx @@ -1,14 +1,15 @@ 'use client' import { motion } from 'framer-motion' -import { Joystick, MonitorSmartphone, Cpu, Gamepad, Rocket } from 'lucide-react' +import { Joystick, MonitorSmartphone, Cpu, Gamepad, Rocket, Settings2 } from 'lucide-react' +import Link from 'next/link' import { ActiveFiltersSummary, ListingsSearchBar } from '@/app/listings/shared/components' import { buildActiveFilterItems } from '@/app/listings/shared/utils/buildActiveFilterItems' -import { MultiSelect } from '@/components/ui' +import { Button, MultiSelect } from '@/components/ui' import { type Option } from '@/components/ui/form/async-multi-select/AsyncMultiSelect' +import DeviceMultiSelect from '@/features/hardware/device/client/components/DeviceMultiSelect' +import SocMultiSelect from '@/features/hardware/soc/client/components/SocMultiSelect' import { performanceOptions, systemOptions, emulatorOptions } from '@/utils/options' -import AsyncDeviceFilterSelect from './filters/AsyncDeviceFilterSelect' -import AsyncSocFilterSelect from './filters/AsyncSocFilterSelect' interface Props { systemIds: string[] @@ -67,7 +68,7 @@ export default function ListingsFiltersContent(props: Props) { maxDisplayed={1} /> - } value={props.deviceIds} @@ -76,7 +77,7 @@ export default function ListingsFiltersContent(props: Props) { maxDisplayed={1} /> - } value={props.socIds} @@ -85,6 +86,18 @@ export default function ListingsFiltersContent(props: Props) { maxDisplayed={1} /> + + } diff --git a/src/app/listings/components/ListingsFiltersSidebar.tsx b/src/app/listings/components/ListingsFiltersSidebar.tsx index b53fec271..ec20c48a2 100644 --- a/src/app/listings/components/ListingsFiltersSidebar.tsx +++ b/src/app/listings/components/ListingsFiltersSidebar.tsx @@ -24,6 +24,7 @@ import { getSystemNames, getEmulatorNames, getPerformanceLabels, + getSelectedOptionNames, } from '@/app/listings/shared/utils/selectedLabels' import { Button } from '@/components/ui' import { type Option } from '@/components/ui/form/async-multi-select/AsyncMultiSelect' @@ -74,13 +75,13 @@ function ListingsFiltersSidebar(props: FiltersProps) { const handleDeviceChange = (values: string[], selectedOptions: Option[]) => { props.onDeviceChange(values) - const names = selectedOptions.map((option) => option.name) + const names = getSelectedOptionNames(selectedOptions, values) filterAnalytics.devices(values, names) } const handleSocChange = (values: string[], selectedOptions: Option[]) => { props.onSocChange(values) - const names = selectedOptions.map((option) => option.name) + const names = getSelectedOptionNames(selectedOptions, values) filterAnalytics.socs(values, names) } @@ -332,7 +333,7 @@ function ListingsFiltersSidebar(props: FiltersProps) {

diff --git a/src/app/listings/components/shared/CustomFieldRenderer.tsx b/src/app/listings/components/shared/CustomFieldRenderer.tsx index df211698c..4f0924632 100644 --- a/src/app/listings/components/shared/CustomFieldRenderer.tsx +++ b/src/app/listings/components/shared/CustomFieldRenderer.tsx @@ -1,8 +1,8 @@ 'use client' import { type Control, type FieldPath, type FieldValues } from 'react-hook-form' -import CustomFieldTypeDriverVersion from '@/app/listings/components/shared/custom-fields/CustomFieldTypeDriverVersion' import { DRIVER_VERSION_FIELD_NAME } from '@/constants/customFields' +import DriverVersionField from '@/features/custom-fields/driver/client/components/DriverVersionField' import { isCustomFieldValueEmpty } from '@/utils/custom-fields' import { CustomFieldType } from '@orm' import { @@ -82,10 +82,9 @@ export function CustomFieldRenderer diff --git a/src/app/listings/components/shared/custom-fields/CustomFieldTypeDriverVersion.tsx b/src/app/listings/components/shared/custom-fields/CustomFieldTypeDriverVersion.tsx deleted file mode 100644 index b9a22fef9..000000000 --- a/src/app/listings/components/shared/custom-fields/CustomFieldTypeDriverVersion.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { type ReactNode, useEffect, useMemo, useCallback } from 'react' -import { Controller, type Control, type FieldPath, type FieldValues } from 'react-hook-form' -import { Autocomplete, type AutocompleteOptionBase } from '@/components/ui' -import { cn } from '@/lib/utils' -import { reconcileDriverValue } from './driverVersionUtils' -import { type DriverRelease, useDriverVersions } from './hooks/useDriverVersions' - -const baseOptions: DriverReleaseOption[] = [ - { - label: 'Select for Non-Android Device', - value: 'N/A', - name: 'Select for Non-Android Device', - }, - { - label: 'Default System Driver', - value: 'Default System Driver', - name: 'Default System Driver', - }, -] - -interface DriverPlaceholderOption extends AutocompleteOptionBase { - label: string - value: string - name?: string -} - -type DriverReleaseOption = DriverRelease | DriverPlaceholderOption - -interface CustomFieldDefinitionWithOptions { - id: string - name: string - label: string - isRequired: boolean - placeholder?: string | null -} - -interface ValidationRules { - required: string | boolean - validate?: (value: unknown) => boolean | string -} - -interface Props { - fieldDef: CustomFieldDefinitionWithOptions - fieldName: FieldPath - index: number - rules: ValidationRules - control: Control - errorMessage: string | undefined - icon: ReactNode -} - -function CustomFieldTypeDriverVersion( - props: Props, -) { - const driverVersions = useDriverVersions() - - const selectOptions: DriverReleaseOption[] = useMemo( - () => (driverVersions.data ? [...baseOptions, ...driverVersions.data] : baseOptions), - [driverVersions.data], - ) - - const loadItems = useCallback( - async (query: string): Promise => { - const all = selectOptions - if (!query) return all - const q = query.toLowerCase() - return all.filter((opt) => { - const label = (opt as DriverReleaseOption).label?.toLowerCase?.() || '' - const value = (opt as DriverReleaseOption).value?.toLowerCase?.() || '' - const name = - ((opt as DriverReleaseOption).name as string | undefined)?.toLowerCase?.() || '' - return label.includes(q) || value.includes(q) || name.includes(q) - }) - }, - [selectOptions], - ) - - const helperMessage = driverVersions.errorMessage - ? driverVersions.errorMessage - : driverVersions.rateLimited - ? 'GitHub rate limit exceeded. Please try again in a few minutes or select a default option.' - : null - - return ( -
- - ( - field.onChange(value ?? '')} - loadItems={loadItems} - leftIcon={props.icon} - placeholder={ - props.fieldDef.placeholder || `Enter ${props.fieldDef.label.toLowerCase()}` - } - inputClassName={cn( - 'w-full mt-2', - props.errorMessage && - 'border-red-300 dark:border-red-600 focus:border-red-500 focus:ring-red-500', - )} - disabled={driverVersions.loading && !driverVersions.data} - driverReleases={driverVersions.data} - /> - )} - /> - {driverVersions.loading && !driverVersions.data && ( -

Loading driver versions…

- )} - {helperMessage && ( -

{helperMessage}

- )} - {props.errorMessage &&

{props.errorMessage}

} -
- ) -} - -export default CustomFieldTypeDriverVersion - -interface DriverVersionAutocompleteProps { - id: string - value: string | null - onChange: (v: string | null) => void - loadItems: (q: string) => Promise - leftIcon: ReactNode - placeholder: string - inputClassName?: string - disabled?: boolean - driverReleases: DriverRelease[] | null -} - -function DriverVersionAutocomplete(props: DriverVersionAutocompleteProps) { - const { driverReleases, value, onChange } = props - // Reconcile filename or plain value to canonical option when driver list arrives - useEffect(() => { - const canonical = reconcileDriverValue(value, driverReleases) - if (canonical) onChange(canonical) - }, [driverReleases, value, onChange]) - - return ( - - id={props.id} - value={props.value ?? ''} - onChange={(value) => props.onChange(value ?? '')} - loadItems={props.loadItems} - leftIcon={props.leftIcon} - optionToValue={(driverVersion) => driverVersion.value} - optionToLabel={(driverVersion) => driverVersion.label} - minCharsToTrigger={0} - debounceTime={300} - placeholder={props.placeholder} - className={props.inputClassName} - filterKeys={['name', 'label', 'value']} - disabled={props.disabled} - /> - ) -} diff --git a/src/app/listings/hooks/usePreSelectedGame.test.ts b/src/app/listings/hooks/usePreSelectedGame.test.ts new file mode 100644 index 000000000..4628c5a9d --- /dev/null +++ b/src/app/listings/hooks/usePreSelectedGame.test.ts @@ -0,0 +1,97 @@ +import { act, renderHook } from '@testing-library/react' +import { useState } from 'react' +import { useForm } from 'react-hook-form' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ApprovalStatus } from '@orm' +import { usePreSelectedGame } from './usePreSelectedGame' +import { type GameOption } from '../components/shared' + +let queryGame: GameOption | undefined +vi.mock('@/lib/api', () => ({ + api: { games: { byId: { useQuery: () => ({ data: queryGame }) } } }, +})) + +function game(id: string, title: string): GameOption { + return { id, title, status: ApprovalStatus.APPROVED, system: { id: 'system-1', name: 'System' } } +} +function useReportForm(gameIdFromUrl: string | null) { + const form = useForm({ defaultValues: { gameId: '' } }) + const [selected, setSelected] = useState(null) + const [search, setSearch] = useState('') + function select(option: GameOption) { + form.setValue('gameId', option.id) + setSelected(option) + } + usePreSelectedGame({ + gameIdFromUrl, + onGameSelect: select, + onSearchTermChange: setSearch, + }) + return { form, selected, search, select, setSearch } +} + +beforeEach(() => { + queryGame = undefined +}) + +describe('URL game preselection', () => { + it('applies a cached URL game directly to the form and metadata', () => { + queryGame = game('game-1', 'Cached game') + const { result } = renderHook(() => useReportForm('game-1')) + expect(result.current.form.getValues('gameId')).toBe('game-1') + expect(result.current.selected?.title).toBe('Cached game') + }) + + it('initializes the real report form and its game metadata after the game loads', () => { + const { result, rerender } = renderHook(() => useReportForm('game-1')) + queryGame = game('game-1', 'First game') + rerender() + expect(result.current.form.getValues('gameId')).toBe('game-1') + expect(result.current.selected?.title).toBe('First game') + expect(result.current.search).toBe('First game') + }) + + it('does not overwrite manual game selection on a refetch', () => { + queryGame = game('game-1', 'First game') + const { result, rerender } = renderHook(() => useReportForm('game-1')) + act(() => { + result.current.select(game('manual-game', 'Manual selection')) + result.current.setSearch('Manual selection') + }) + queryGame = game('game-1', 'Refreshed first game') + rerender() + expect(result.current.form.getValues('gameId')).toBe('manual-game') + expect(result.current.selected?.title).toBe('Manual selection') + expect(result.current.search).toBe('Manual selection') + }) + + it('waits for the new URL target instead of applying stale query data', () => { + queryGame = game('game-1', 'First game') + const { result, rerender } = renderHook(({ id }) => useReportForm(id), { + initialProps: { id: 'game-1' }, + }) + rerender({ id: 'game-2' }) + expect(result.current.form.getValues('gameId')).toBe('game-1') + queryGame = game('game-2', 'Second game') + rerender({ id: 'game-2' }) + expect(result.current.form.getValues('gameId')).toBe('game-2') + expect(result.current.selected?.title).toBe('Second game') + }) + + it('reapplies a returned URL target even if the intermediate target never loaded', () => { + queryGame = game('game-1', 'First game') + const { result, rerender } = renderHook(({ id }) => useReportForm(id), { + initialProps: { id: 'game-1' }, + }) + rerender({ id: 'game-2' }) + act(() => result.current.select(game('manual', 'Manual selection'))) + rerender({ id: 'game-1' }) + expect(result.current.form.getValues('gameId')).toBe('game-1') + }) + + it('ignores cached game data when no URL target is selected', () => { + queryGame = game('game-1', 'Cached game') + const { result } = renderHook(() => useReportForm(null)) + expect(result.current.form.getValues('gameId')).toBe('') + }) +}) diff --git a/src/app/listings/hooks/usePreSelectedGame.ts b/src/app/listings/hooks/usePreSelectedGame.ts index bb4753f61..456afac4c 100644 --- a/src/app/listings/hooks/usePreSelectedGame.ts +++ b/src/app/listings/hooks/usePreSelectedGame.ts @@ -1,51 +1,36 @@ -import { useEffect, useState } from 'react' -import { type UseFormReturn, type Path } from 'react-hook-form' +import { useEffect, useRef } from 'react' import { api } from '@/lib/api' import { type GameOption } from '../components/shared' -interface UsePreSelectedGameParams { +interface Params { gameIdFromUrl: string | null - form: UseFormReturn onGameSelect: (game: GameOption) => void onSearchTermChange: (term: string) => void } -export function usePreSelectedGame({ - gameIdFromUrl, - form, - onGameSelect, - onSearchTermChange, -}: UsePreSelectedGameParams) { - const [isInitialGameLoaded, setIsInitialGameLoaded] = useState(false) - - const preSelectedGameQuery = api.games.byId.useQuery( - { id: gameIdFromUrl! }, - { enabled: !!gameIdFromUrl && !isInitialGameLoaded }, +export function usePreSelectedGame(params: Params) { + const { gameIdFromUrl, onGameSelect, onSearchTermChange } = params + const preselection = useRef({ gameId: gameIdFromUrl, applied: false }) + const query = api.games.byId.useQuery( + { id: gameIdFromUrl ?? '' }, + { enabled: Boolean(gameIdFromUrl) }, ) + const game = query.data useEffect(() => { - if (preSelectedGameQuery.data && gameIdFromUrl && !isInitialGameLoaded) { - const gameOption: GameOption = { - id: preSelectedGameQuery.data.id, - title: preSelectedGameQuery.data.title, - system: preSelectedGameQuery.data.system, - status: preSelectedGameQuery.data.status, - imageUrl: preSelectedGameQuery.data.imageUrl ?? undefined, - boxartUrl: preSelectedGameQuery.data.boxartUrl ?? undefined, - } - onGameSelect(gameOption) - form.setValue('gameId' as Path, preSelectedGameQuery.data.id as never) - setIsInitialGameLoaded(true) - onSearchTermChange(preSelectedGameQuery.data.title) + if (preselection.current.gameId !== gameIdFromUrl) { + preselection.current = { gameId: gameIdFromUrl, applied: false } } - }, [ - preSelectedGameQuery.data, - gameIdFromUrl, - isInitialGameLoaded, - form, - onGameSelect, - onSearchTermChange, - ]) - - return { isInitialGameLoaded, preSelectedGameQuery } + if (!game || game.id !== gameIdFromUrl || preselection.current.applied) return + preselection.current.applied = true + onGameSelect({ + id: game.id, + title: game.title, + system: game.system, + status: game.status, + imageUrl: game.imageUrl ?? undefined, + boxartUrl: game.boxartUrl ?? undefined, + }) + onSearchTermChange(game.title) + }, [game, gameIdFromUrl, onGameSelect, onSearchTermChange]) } diff --git a/src/app/listings/new/NewListingPage.tsx b/src/app/listings/new/NewListingPage.tsx index ceed1c551..c8233fdb6 100644 --- a/src/app/listings/new/NewListingPage.tsx +++ b/src/app/listings/new/NewListingPage.tsx @@ -19,7 +19,9 @@ import '@/shared/emulator-config/azahar' import '@/shared/emulator-config/gamenative' import { Button, LoadingSpinner } from '@/components/ui' import { CACHE_DURATIONS, LOOKUP_PAGINATION } from '@/data/constants' +import { reconcileDriverValue } from '@/features/custom-fields/driver/shared/driver-version' import { useSubmitWithHumanVerification } from '@/features/human-verification/client' +import { useLastEmulatorSettings } from '@/features/reports/last-settings/client/useLastEmulatorSettings' import analytics from '@/lib/analytics' import { api } from '@/lib/api' import { MarkdownEditor } from '@/lib/dynamic-imports' @@ -46,7 +48,6 @@ import { useGameLoader, useEmulatorLoader, usePreSelectedGame, useFormKeyDown } import createDynamicListingSchema from './form-schemas/createDynamicListingSchema' import listingFormSchema from './form-schemas/listingFormSchema' import { useEmulatorConfigImporter } from './hooks/useEmulatorConfigImporter' -import { reconcileDriverValue } from '../components/shared/custom-fields/driverVersionUtils' export type ListingFormValues = RouterInput['listings']['create'] @@ -290,6 +291,15 @@ function AddListingPage() { const configUploadInputId = `${selectedEmulatorSlug || 'emulator'}-config-upload` const currentUserQuery = api.users.me.useQuery() + const currentSettings = useWatch({ control: form.control, name: 'customFieldValues' }) + const lastSettings = useLastEmulatorSettings({ + userId: currentUserQuery.data?.id, + emulatorId: selectedEmulatorId, + fields: parsedCustomFields, + enabled: customFieldDefinitionsQuery.isSuccess && !customFieldDefinitionsQuery.isFetching, + values: currentSettings, + blocked: Boolean(form.formState.dirtyFields.customFieldValues) || importSummary !== null, + }) const loadDeviceItems = useCallback( async (query: string): Promise => { @@ -332,26 +342,17 @@ function AddListingPage() { } }, []) - const selectedGameId = useWatch({ control: form.control, name: 'gameId' }) - const { isInitialGameLoaded } = usePreSelectedGame({ + usePreSelectedGame({ gameIdFromUrl, - form, - onGameSelect: setSelectedGame, + onGameSelect: (game) => { + form.setValue('gameId', game.id) + setSelectedGame(game) + }, onSearchTermChange: setGameSearchTerm, }) const { handleKeyDown } = useFormKeyDown() - // Update selected game when gameId changes (but not during initial load) - useEffect(() => { - if (isInitialGameLoaded && selectedGameId && !selectedGame) { - loadGameItems(gameSearchTerm).then((games) => { - const game = games.find((g) => g.id === selectedGameId) - if (game) setSelectedGame(game) - }) - } - }, [selectedGameId, selectedGame, gameSearchTerm, loadGameItems, isInitialGameLoaded]) - // Clear emulator when game changes and load initial emulators useEffect(() => { if (selectedGame) { @@ -388,15 +389,28 @@ function AddListingPage() { form.setValue('customFieldValues', newCustomValues) }, [customFieldIdsKey, form, parsedCustomFields]) - const createListingMutation = api.listings.create.useMutation({ - onSuccess: async (data) => { + const createListingMutation = api.listings.create.useMutation() + + const onSubmit = async (data: ListingFormValues) => { + if (!currentUserQuery.data?.id) { + return toast.error('You must be signed in to create a Compatibility Report.') + } + + try { + const result = await submitWithHumanVerification((humanVerificationToken) => + createListingMutation.mutateAsync({ + ...data, + humanVerificationToken, + }), + ) + lastSettings.remember(result.authorId, result.emulatorId, data.customFieldValues) analytics.listing.created({ - listingId: data?.id, - gameId: data?.gameId, + listingId: result.id, + gameId: result.gameId, systemId: selectedGame?.system.id, - emulatorId: data?.emulatorId, - deviceId: data?.deviceId, - performanceId: data?.performanceId, + emulatorId: result.emulatorId, + deviceId: result.deviceId, + performanceId: result.performanceId, }) if (currentUserQuery.data?.id) { @@ -409,27 +423,12 @@ function AddListingPage() { // Invalidate queries to refresh data await utils.listings.get.invalidate() - if (data?.gameId) { - await utils.games.byId.invalidate({ id: data.gameId }) + if (result.gameId) { + await utils.games.byId.invalidate({ id: result.gameId }) } toast.success('Handheld Report successfully submitted for review!') router.push('/listings') - }, - }) - - const onSubmit = async (data: ListingFormValues) => { - if (!currentUserQuery.data?.id) { - return toast.error('You must be signed in to create a Compatibility Report.') - } - - try { - await submitWithHumanVerification((humanVerificationToken) => - createListingMutation.mutateAsync({ - ...data, - humanVerificationToken, - }), - ) } catch (error) { toast.error(`Error creating Handheld Report: ${getErrorMessage(error)}`) } @@ -503,6 +502,7 @@ function AddListingPage() { selectedEmulator={selectedEmulator} onEmulatorSelect={(emulator) => { setSelectedEmulator(emulator) + setImportFeedback(EMPTY_IMPORT_FEEDBACK) if (!emulator) { form.setValue('emulatorId', '') form.setValue('customFieldValues', []) @@ -613,6 +613,28 @@ function AddListingPage() { !customFieldDefinitionsQuery.isPending && parsedCustomFields.length > 0 && (
+ {lastSettings.available && ( + + )} ({ + push: vi.fn(), + handheld: + vi.fn< + ( + input: RouterInput['listings']['create'], + ) => Promise< + Pick< + RouterOutput['listings']['create'], + 'id' | 'authorId' | 'emulatorId' | 'gameId' | 'deviceId' | 'performanceId' + > + > + >(), + pc: vi.fn< + ( + input: RouterInput['pcListings']['create'], + ) => Promise> + >(), + invalidate: vi.fn(), +})) +const storage = createStorage() +Object.defineProperty(window, 'localStorage', { value: storage, configurable: true }) +const game = { + id: fieldId(30), + title: 'Test game', + system: { id: fieldId(31), name: 'Test system' }, + status: ApprovalStatus.APPROVED, +} +const emulator = { id: emulatorId, name: 'Test Emulator', systems: [game.system], logo: null } +const device = { + id: fieldId(32), + modelName: 'Test device', + brand: { id: fieldId(33), name: 'Test brand' }, + soc: { id: fieldId(34), name: 'Test chip', manufacturer: 'Test manufacturer' }, +} +const cpu = { + id: fieldId(35), + modelName: 'Test CPU', + brand: { id: fieldId(33), name: 'Test brand' }, +} +const performance = [{ id: 1, label: 'Perfect', rank: 5, description: null }] +const driverData = { releases: [], rateLimited: false, errorMessage: null } +const fields = [ + customField(10, CustomFieldType.TEXT, { label: 'Emulator Version' }), + customField(11, CustomFieldType.BOOLEAN, { label: 'Enable feature', defaultValue: true }), + customField(12, CustomFieldType.RANGE, { + label: 'Scale', + rangeMin: 0, + rangeMax: 10, + defaultValue: 5, + }), +] +let definitions = fields.map((field) => ({ ...field, emulator, category: null })) +const values = [ + { customFieldDefinitionId: fieldId(10), value: '1.2.3' }, + { customFieldDefinitionId: fieldId(11), value: false }, + { customFieldDefinitionId: fieldId(12), value: 0 }, +] +const settingsKey = `${storageKeys.newListing.lastUsedEmulatorSettings}:${userId}:${emulatorId}` +const utils = { + emulators: { get: { fetch: vi.fn(async () => ({ emulators: [emulator] })) } }, + games: { + get: { fetch: vi.fn(async () => ({ games: [game] })) }, + byId: { invalidate: mocks.invalidate }, + }, + devices: { options: { fetch: vi.fn(async () => ({ devices: [device] })) } }, + cpus: { options: { fetch: vi.fn(async () => ({ cpus: [cpu] })) } }, + gpus: { options: { fetch: vi.fn(async () => ({ gpus: [] })) } }, + listings: { get: { invalidate: mocks.invalidate } }, + pcListings: { get: { invalidate: mocks.invalidate } }, +} + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mocks.push }), + useSearchParams: () => new URLSearchParams('gameId=00000000-0000-4000-a000-000000000030'), +})) +vi.mock('@clerk/nextjs', async (importOriginal) => ({ + ...(await importOriginal()), + useAuth: () => ({ isLoaded: true }), +})) +vi.mock('@/lib/toast', () => ({ default: { success: vi.fn(), warning: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/analytics', () => ({ + default: { listing: { created: vi.fn() }, conversion: { goalCompleted: vi.fn() } }, +})) +vi.mock('@/lib/dynamic-imports', async () => ({ + MarkdownEditor: (await import('@/components/ui/form/MarkdownEditor')).MarkdownEditor, +})) +vi.mock('@/features/human-verification/client', () => ({ + useSubmitWithHumanVerification: () => + async function submit(operation: (token?: string) => Promise) { + return operation() + }, +})) +vi.mock('@/lib/api', () => ({ + api: { + useUtils: () => utils, + users: { me: { useQuery: () => ({ data: { id: userId }, isLoading: false }) } }, + games: { byId: { useQuery: () => ({ data: game }) } }, + customFieldDefinitions: { + getByEmulator: { + useQuery: (input: { emulatorId: string }) => ({ + data: input.emulatorId ? definitions : undefined, + isPending: !input.emulatorId, + isSuccess: Boolean(input.emulatorId), + isFetching: false, + }), + }, + }, + listings: { + driverVersions: { useQuery: () => ({ data: driverData, isLoading: false }) }, + performanceScales: { useQuery: () => ({ data: performance }) }, + create: { useMutation: () => ({ mutateAsync: mocks.handheld, isPending: false }) }, + }, + performanceScales: { get: { useQuery: () => ({ data: performance }) } }, + pcListings: { + presets: { get: { useQuery: () => ({ data: [] }) } }, + create: { useMutation: () => ({ mutateAsync: mocks.pc, isPending: false }) }, + }, + }, +})) + +beforeEach(() => { + storage.clear() + vi.clearAllMocks() + emulator.name = 'Test Emulator' + definitions = fields.map((field) => ({ ...field, emulator, category: null })) + mocks.invalidate.mockResolvedValue(undefined) + mocks.handheld + .mockReset() + .mockImplementation(async (input: RouterInput['listings']['create']) => ({ + ...input, + id: fieldId(40), + authorId: userId, + })) + mocks.pc.mockReset().mockImplementation(async (input: RouterInput['pcListings']['create']) => ({ + ...input, + id: fieldId(40), + authorId: userId, + })) +}) + +async function chooseOption(placeholder: RegExp, query: string, option: RegExp) { + const input = await screen.findByPlaceholderText(placeholder) + fireEvent.focus(input) + fireEvent.change(input, { target: { value: query } }) + const match = await screen.findByRole('option', { name: option }) + await act(async () => { + fireEvent.mouseDown(match) + }) +} + +async function prepareForm(kind: 'handheld' | 'pc') { + const view = render(kind === 'handheld' ? : ) + await chooseOption(/Search for emulators/, emulator.name, new RegExp(emulator.name)) + await screen.findByLabelText('Emulator Version') + if (kind === 'handheld') await chooseOption(/Search for a device/, 'Test', /Test device/) + else { + await chooseOption(/Select a CPU/, 'Test', /Test CPU/) + fireEvent.change(screen.getByPlaceholderText(/Windows 11 Pro/), { + target: { value: 'Windows 11' }, + }) + } + const performanceSelect = screen.getByRole('option', { name: 'Perfect' }).closest('select') + if (!performanceSelect) throw new Error('Performance selector is missing') + fireEvent.change(performanceSelect, { target: { value: '1' } }) + fireEvent.change(screen.getByLabelText('Notes'), { target: { value: 'Keep these report notes' } }) + return view +} + +describe.each(['handheld', 'pc'] as const)('%s last emulator settings', (kind) => { + it('applies settings only on request and preserves the other report fields', async () => { + storage.setItem(settingsKey, JSON.stringify(createLastEmulatorSettings(values))) + await prepareForm(kind) + expect(screen.getByLabelText('Emulator Version')).toHaveValue('') + expect(screen.getByRole('checkbox', { name: 'Enable feature' })).toBeChecked() + expect(screen.getByRole('slider', { name: 'Scale' })).toHaveValue('5') + fireEvent.click(await screen.findByRole('button', { name: 'Use last settings' })) + expect(screen.getByLabelText('Emulator Version')).toHaveValue('1.2.3') + expect(screen.getByRole('checkbox', { name: 'Enable feature' })).not.toBeChecked() + expect(screen.getByRole('slider', { name: 'Scale' })).toHaveValue('0') + expect(screen.getByLabelText('Notes')).toHaveValue('Keep these report notes') + fireEvent.click(screen.getByRole('button', { name: 'Create Compatibility Report' })) + const mutation = kind === 'handheld' ? mocks.handheld : mocks.pc + await waitFor(() => expect(mutation).toHaveBeenCalledOnce()) + expect(mutation).toHaveBeenCalledWith( + expect.objectContaining({ + gameId: game.id, + emulatorId, + performanceId: 1, + notes: 'Keep these report notes', + customFieldValues: values, + ...(kind === 'handheld' ? { deviceId: device.id } : { cpuId: cpu.id, memorySize: 16 }), + }), + ) + await waitFor(() => expect(mocks.push).toHaveBeenCalled()) + }) + + it('keeps manual custom settings instead of offering to overwrite them', async () => { + storage.setItem(settingsKey, JSON.stringify(createLastEmulatorSettings(values))) + await prepareForm(kind) + await screen.findByRole('button', { name: 'Use last settings' }) + fireEvent.change(screen.getByLabelText('Emulator Version'), { + target: { value: 'Manual version' }, + }) + expect(screen.queryByRole('button', { name: 'Use last settings' })).not.toBeInTheDocument() + expect(screen.getByLabelText('Emulator Version')).toHaveValue('Manual version') + }) + + it('retains a manual draft when refreshed definitions reset form dirtiness', async () => { + storage.setItem(settingsKey, JSON.stringify(createLastEmulatorSettings(values))) + const view = await prepareForm(kind) + fireEvent.change(screen.getByLabelText('Emulator Version'), { + target: { value: 'Manual version' }, + }) + definitions = [ + ...definitions, + { + ...customField(13, CustomFieldType.TEXT, { label: 'New field' }), + emulator, + category: null, + }, + ] + view.rerender(kind === 'handheld' ? : ) + await screen.findByLabelText('New field') + expect(screen.getByLabelText('Emulator Version')).toHaveValue('Manual version') + expect(screen.queryByRole('button', { name: 'Use last settings' })).not.toBeInTheDocument() + }) + + it('saves the submitted snapshot only after a successful report', async () => { + await prepareForm(kind) + const submitted = Promise.withResolvers>>() + const mutation = kind === 'handheld' ? mocks.handheld : mocks.pc + mutation.mockImplementation(() => submitted.promise) + fireEvent.change(screen.getByLabelText('Emulator Version'), { + target: { value: 'Submitted version' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Create Compatibility Report' })) + await waitFor(() => expect(mutation).toHaveBeenCalledOnce()) + expect(storage.getItem(settingsKey)).toBeNull() + fireEvent.change(screen.getByLabelText('Emulator Version'), { + target: { value: 'Later unsubmitted draft' }, + }) + await act(async () => + submitted.resolve({ + id: fieldId(40), + authorId: userId, + emulatorId, + gameId: game.id, + deviceId: device.id, + performanceId: 1, + }), + ) + await waitFor(() => expect(mocks.push).toHaveBeenCalled()) + expect(JSON.parse(storage.getItem(settingsKey) ?? '{}')).toEqual({ + version: 1, + values: [ + { customFieldDefinitionId: fieldId(10), value: 'Submitted version' }, + { customFieldDefinitionId: fieldId(11), value: true }, + { customFieldDefinitionId: fieldId(12), value: 5 }, + ], + }) + }) + + it('does not save values from a failed report', async () => { + const previous = createLastEmulatorSettings(values) + storage.setItem(settingsKey, JSON.stringify(previous)) + await prepareForm(kind) + const mutation = kind === 'handheld' ? mocks.handheld : mocks.pc + mutation.mockRejectedValueOnce(new Error('Report rejected')) + fireEvent.change(screen.getByLabelText('Emulator Version'), { + target: { value: 'Failed version' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Create Compatibility Report' })) + await waitFor(() => expect(toast.error).toHaveBeenCalled()) + expect(JSON.parse(storage.getItem(settingsKey) ?? '{}')).toEqual(previous) + expect(mocks.push).not.toHaveBeenCalled() + }) + + it('continues successful navigation when saving the snapshot fails', async () => { + await prepareForm(kind) + const write = vi.spyOn(storage, 'setItem').mockImplementation(() => { + throw new DOMException('Storage is full', 'QuotaExceededError') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + fireEvent.click(screen.getByRole('button', { name: 'Create Compatibility Report' })) + await waitFor(() => expect(mocks.push).toHaveBeenCalled()) + expect(toast.success).toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + expect(toast.warning).toHaveBeenCalledWith( + 'Report submitted, but your last emulator settings could not be saved.', + ) + } finally { + write.mockRestore() + warn.mockRestore() + } + }) +}) + +it('keeps an imported configuration instead of replacing it with saved settings', async () => { + emulator.name = 'Eden' + definitions[1].name = 'disk_shader_cache' + storage.setItem(settingsKey, JSON.stringify(createLastEmulatorSettings(values))) + await prepareForm('handheld') + await screen.findByRole('button', { name: 'Use last settings' }) + const text = '[Renderer]\nuse_disk_shader_cache=true\n' + const file = new File([text], 'config.ini', { type: 'text/plain' }) + Object.defineProperty(file, 'text', { value: async () => text }) + await act(async () => { + fireEvent.change(screen.getByLabelText(/Import Eden config/), { target: { files: [file] } }) + }) + await screen.findByText(/Filled 0 fields/) + expect(screen.getByRole('checkbox', { name: 'Enable feature' })).toBeChecked() + expect(screen.queryByRole('button', { name: 'Use last settings' })).not.toBeInTheDocument() +}) diff --git a/src/app/listings/new/page.test.tsx b/src/app/listings/new/page.test.tsx deleted file mode 100644 index e45c65045..000000000 --- a/src/app/listings/new/page.test.tsx +++ /dev/null @@ -1,342 +0,0 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { render, screen, waitFor, fireEvent } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { useRouter, useSearchParams } from 'next/navigation' -import { type PropsWithChildren } from 'react' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { CustomFieldType } from '@orm' -import AddListingPage from './page' - -vi.mock('next/navigation', () => ({ - useRouter: vi.fn(), - useSearchParams: vi.fn(), -})) - -vi.mock('@/lib/api', () => ({ - api: { - devices: { - get: { - useQuery: vi.fn(() => ({ - data: [ - { - id: 'device-1', - brand: { name: 'Test Brand' }, - modelName: 'Test Model', - }, - ], - })), - }, - }, - listings: { - performanceScales: { - useQuery: vi.fn(() => ({ - data: [ - { id: 1, label: 'Perfect' }, - { id: 2, label: 'Great' }, - ], - })), - }, - create: { - useMutation: vi.fn(() => ({ - mutate: vi.fn(), - isPending: false, - })), - }, - }, - customFieldDefinitions: { - getByEmulator: { - useQuery: vi.fn((params) => { - if (params.emulatorId === 'emulator-with-fields') { - return { - data: [ - { - id: 'field-1', - name: 'driver_version', - label: 'Driver Version', - type: CustomFieldType.TEXT, - isRequired: true, - emulator: { name: 'Test Emulator' }, - }, - { - id: 'field-2', - name: 'graphics_setting', - label: 'Graphics Setting', - type: CustomFieldType.SELECT, - isRequired: true, - options: [ - { value: 'low', label: 'Low' }, - { value: 'high', label: 'High' }, - ], - emulator: { name: 'Test Emulator' }, - }, - ], - isLoading: false, - } - } - return { data: [], isLoading: false } - }), - }, - }, - games: { - byId: { - useQuery: vi.fn(() => ({ data: null })), - }, - }, - useUtils: vi.fn(() => ({ - games: { - get: { - fetch: vi.fn(() => - Promise.resolve({ - games: [ - { - id: 'game-1', - title: 'Test Game', - system: { id: 'system-1', name: 'Test System' }, - }, - ], - }), - ), - }, - }, - emulators: { - get: { - fetch: vi.fn(() => - Promise.resolve([{ id: 'emulator-with-fields', name: 'Test Emulator' }]), - ), - }, - byId: { - fetch: vi.fn(() => - Promise.resolve({ - id: 'emulator-with-fields', - name: 'Test Emulator', - systems: [{ id: 'system-1', name: 'Test System' }], - }), - ), - }, - }, - })), - }, -})) - -vi.mock('@/hooks/useMounted', () => ({ - default: () => true, -})) - -vi.mock('@/lib/toast', () => ({ - default: { - success: vi.fn(), - error: vi.fn(), - }, -})) - -// Mock the child components that are already tested separately -interface GameSelectorProps { - onGameSelect: ( - game: { - id: string - title: string - system: { id: string; name: string } - } | null, - ) => void - _control: unknown -} - -vi.mock('./components/GameSelector', () => ({ - default: ({ onGameSelect }: GameSelectorProps) => ( -
- -
- ), -})) - -interface EmulatorSelectorProps { - setValue: (field: string, value: string) => void -} - -vi.mock('./components/EmulatorSelector', () => ({ - default: ({ setValue }: EmulatorSelectorProps) => ( -
- -
- ), -})) - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, -}) - -function TestWrapper(props: PropsWithChildren) { - return {props.children} -} - -// TODO: this takes minutes to run, convert this into a playwright test, skip for now -describe.skip('AddListingPage Integration Tests', () => { - const mockPush = vi.fn() - const mockGet = vi.fn(() => null) - - beforeEach(() => { - vi.clearAllMocks() - ;(useRouter as ReturnType).mockReturnValue({ push: mockPush }) - ;(useSearchParams as ReturnType).mockReturnValue({ - get: mockGet, - }) - }) - - it('should display custom field validation errors when required fields are empty', async () => { - render( - - - , - ) - - const user = userEvent.setup() - - // Select a game - await user.click(screen.getByText('Select Game')) - - // Select an emulator with custom fields - await user.click(screen.getByText('Select Emulator')) - - // Wait for custom fields to load - await waitFor(() => { - expect(screen.getByText('Emulator-Specific Details')).toBeInTheDocument() - }) - - // Expect to see the required custom fields - expect(screen.getByText('Driver Version')).toBeInTheDocument() - expect(screen.getByText('Graphics Setting')).toBeInTheDocument() - - // Fill basic form fields - const deviceSelect = screen.getByDisplayValue('') - await user.selectOptions(deviceSelect, 'device-1') - - const performanceSelect = screen.getByDisplayValue('') - await user.selectOptions(performanceSelect, '1') - - // Try to submit without filling required custom fields - const submitButton = screen.getByRole('button', { name: /create listing/i }) - - // The form should have HTML5 validation preventing submission - // or show validation errors - expect(submitButton).toBeInTheDocument() - expect(submitButton).not.toBeDisabled() - }) - - it('should enable form submission when all required custom fields are filled', async () => { - render( - - - , - ) - - const user = userEvent.setup() - - // Select a game - await user.click(screen.getByText('Select Game')) - - // Select an emulator with custom fields - await user.click(screen.getByText('Select Emulator')) - - // Wait for custom fields to load - await waitFor(() => { - expect(screen.getByText('Emulator-Specific Details')).toBeInTheDocument() - }) - - // Fill the custom fields - const driverVersionInput = screen.getByPlaceholderText(/enter driver version/i) - await user.type(driverVersionInput, 'v1.0.0') - - const graphicsSelect = screen.getByDisplayValue('low') // Should default to first option - await user.selectOptions(graphicsSelect, 'high') - - // Fill basic form fields - const deviceSelect = screen.getByDisplayValue('') - await user.selectOptions(deviceSelect, 'device-1') - - const performanceSelect = screen.getByDisplayValue('') - await user.selectOptions(performanceSelect, '1') - - // Submit button should be enabled and ready - const submitButton = screen.getByRole('button', { name: /create listing/i }) - expect(submitButton).not.toBeDisabled() - }) - - it('should show specific error messages in FormValidationSummary', async () => { - render( - - - , - ) - - const user = userEvent.setup() - - // Select a game and emulator to show custom fields - await user.click(screen.getByText('Select Game')) - await user.click(screen.getByText('Select Emulator')) - - // Wait for custom fields to load - await waitFor(() => { - expect(screen.getByText('Emulator-Specific Details')).toBeInTheDocument() - }) - - // Try to submit the form without filling required fields - const form = screen.getByRole('form') || screen.getByText('Create New Listing').closest('form') - - if (form) { - // Trigger form validation by attempting to submit - fireEvent.submit(form) - - // Wait for validation errors to appear - await waitFor(() => { - // Should show specific error messages, not just generic ones - const errorSummary = screen.queryByText('Please fix the following errors:') - if (errorSummary) { - expect(errorSummary).toBeInTheDocument() - } - }) - } - }) - - it('should not show custom fields when emulator has none', async () => { - // Mock API to return emulator without custom fields - const { api } = await import('@/lib/api') - ;( - api.customFieldDefinitions.getByEmulator.useQuery as ReturnType - ).mockReturnValue({ - data: [], - isLoading: false, - }) - - render( - - - , - ) - - const user = userEvent.setup() - - // Select a game and emulator - await user.click(screen.getByText('Select Game')) - await user.click(screen.getByText('Select Emulator')) - - // Should not show custom fields section - await waitFor(() => { - expect(screen.queryByText('Emulator-Specific Details')).not.toBeInTheDocument() - }) - }) -}) diff --git a/src/app/listings/shared/hooks/usePreferredHardwareFilters.ts b/src/app/listings/shared/hooks/usePreferredHardwareFilters.ts index 80399c899..3e315f67a 100644 --- a/src/app/listings/shared/hooks/usePreferredHardwareFilters.ts +++ b/src/app/listings/shared/hooks/usePreferredHardwareFilters.ts @@ -20,7 +20,7 @@ export function usePreferredHardwareFilters(opts: Options) { userPreferences?.defaultToUserDevices && userPreferences.devicePreferences ? userPreferences.devicePreferences.map((pref) => pref.deviceId) : [], - [userPreferences?.defaultToUserDevices, userPreferences?.devicePreferences], + [userPreferences], ) const userSocIds = useMemo( @@ -28,7 +28,7 @@ export function usePreferredHardwareFilters(opts: Options) { userPreferences?.defaultToUserSocs && userPreferences.socPreferences ? userPreferences.socPreferences.map((pref) => pref.socId) : [], - [userPreferences?.defaultToUserSocs, userPreferences?.socPreferences], + [userPreferences], ) const shouldUseUserDeviceFilter = diff --git a/src/app/listings/shared/hooks/useUrlFilterState.page-size.test.ts b/src/app/listings/shared/hooks/useUrlFilterState.page-size.test.ts new file mode 100644 index 000000000..4a0aae673 --- /dev/null +++ b/src/app/listings/shared/hooks/useUrlFilterState.page-size.test.ts @@ -0,0 +1,130 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { PAGINATION } from '@/data/constants' +import toast from '@/lib/toast' +import { GetListingsSchema } from '@/schemas/listing' +import { GetPcListingsSchema } from '@/schemas/pcListing' +import { useUrlFilterState } from './useUrlFilterState' + +let query = new URLSearchParams() +const navigation = vi.hoisted(() => ({ push: vi.fn<(url: string) => void>(), replace: vi.fn() })) +vi.mock('next/navigation', () => ({ + useRouter: () => navigation, + useSearchParams: () => query, + usePathname: () => '/listings', +})) +vi.mock('@/lib/toast', () => ({ default: { warning: vi.fn() } })) +vi.mock('@/lib/analytics', () => ({ + default: { filter: { page: vi.fn(), sort: vi.fn(), myListings: vi.fn() } }, +})) +const storage = new Map() +const local = { + getItem: (key: string) => storage.get(key) ?? null, + setItem: vi.fn((key: string, value: string) => storage.set(key, value)), + removeItem: (key: string) => storage.delete(key), +} +Object.defineProperty(window, 'localStorage', { configurable: true, value: local }) +const config = { basePath: '/listings', storageKey: 'page-size', maxLimit: 100 } + +beforeEach(() => { + query = new URLSearchParams() + storage.clear() + vi.clearAllMocks() +}) +afterEach(() => vi.restoreAllMocks()) + +describe.each([ + { name: 'handheld', config, schema: GetListingsSchema }, + { + name: 'PC', + config: { basePath: '/pc-listings', storageKey: 'pc-page-size', maxLimit: 50 }, + schema: GetPcListingsSchema, + }, +])('$name report URL pagination', (catalog) => { + it('keeps a safe calculated offset and normalizes the next page before querying', () => { + const page = Math.floor(Number.MAX_SAFE_INTEGER / 50) + 1 + query = new URLSearchParams({ page: String(page), limit: '50' }) + const { result, rerender } = renderHook(() => useUrlFilterState(catalog.config)) + expect(result.current.page).toBe(page) + query = new URLSearchParams({ page: String(page + 1), limit: '50' }) + rerender() + expect(result.current.page).toBe(1) + expect( + catalog.schema.safeParse({ page: result.current.page, limit: result.current.limit }).success, + ).toBe(true) + }) + + it.each([ + { raw: '', page: 1 }, + { raw: '3', page: 3 }, + { raw: '2.7', page: 2 }, + { raw: '0.5', page: 1 }, + { raw: '-2', page: 1 }, + { raw: 'invalid', page: 1 }, + { raw: 'Infinity', page: 1 }, + { raw: '1e30', page: 1 }, + ])('normalizes page=$raw before the query', (testCase) => { + query = new URLSearchParams({ page: testCase.raw }) + const { result } = renderHook(() => useUrlFilterState(catalog.config)) + expect(result.current.page).toBe(testCase.page) + expect( + catalog.schema.safeParse({ page: result.current.page, limit: result.current.limit }).success, + ).toBe(true) + }) +}) + +describe('report page size preference', () => { + it.each(['0', '-5', '2.5', 'null', '"25"'])( + 'silently defaults an invalid stored page size %s', + (stored) => { + storage.set('page-size', stored) + const { result } = renderHook(() => useUrlFilterState(config)) + expect(result.current.limit).toBe(PAGINATION.PUBLIC_DEFAULT_LIMIT) + expect(toast.warning).not.toHaveBeenCalled() + }, + ) + + it('reads a saved page size without mirroring it into local state', () => { + storage.set('page-size', '25') + const { result } = renderHook(() => useUrlFilterState(config)) + expect(result.current.limit).toBe(25) + }) + + it('persists a URL page size and retains it after the URL parameter is removed', () => { + storage.set('page-size', '25') + query = new URLSearchParams('limit=50') + const { result, rerender } = renderHook(() => useUrlFilterState(config)) + expect(result.current.limit).toBe(50) + expect(storage.get('page-size')).toBe('50') + query = new URLSearchParams() + rerender() + expect(result.current.limit).toBe(50) + }) + + it('does not write the old URL size back over a pending user selection', () => { + query = new URLSearchParams('limit=10') + const { result, rerender } = renderHook(() => useUrlFilterState(config)) + act(() => result.current.setLimit(25)) + expect(storage.get('page-size')).toBe('25') + const pushed = navigation.push.mock.lastCall?.[0] + expect(pushed).toBeDefined() + const params = new URLSearchParams(pushed?.split('?')[1]) + expect(params.get('limit')).toBe('25') + expect(params.get('page')).toBe('1') + query = params + rerender() + expect(result.current.limit).toBe(25) + expect(storage.get('page-size')).toBe('25') + }) + + it('keeps the requested URL size usable when saving preferences is blocked', () => { + query = new URLSearchParams('limit=50') + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(local, 'setItem').mockImplementationOnce(() => { + throw new Error('Storage blocked') + }) + const { result } = renderHook(() => useUrlFilterState(config)) + expect(result.current.limit).toBe(50) + expect(warning).toHaveBeenCalledWith('Failed to save report page size:', expect.any(Error)) + }) +}) diff --git a/src/app/listings/shared/hooks/useUrlFilterState.test.tsx b/src/app/listings/shared/hooks/useUrlFilterState.test.tsx new file mode 100644 index 000000000..57ca5d1fc --- /dev/null +++ b/src/app/listings/shared/hooks/useUrlFilterState.test.tsx @@ -0,0 +1,48 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useUrlFilterState } from './useUrlFilterState' + +const mocks = vi.hoisted(() => ({ + router: { replace: vi.fn(), push: vi.fn() }, + params: new URLSearchParams('page=3'), +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => mocks.router, + useSearchParams: () => mocks.params, + usePathname: () => '/listings', +})) + +vi.mock('@/lib/analytics', () => ({ default: { filter: { page: vi.fn() } } })) + +const config = { basePath: '/listings', storageKey: 'test-filter-focus', maxLimit: 100 } + +describe('report filter navigation', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('localStorage', { getItem: vi.fn(() => null), setItem: vi.fn() }) + }) + + afterEach(() => vi.unstubAllGlobals()) + + it('preserves the active filter control when replacing filter parameters', () => { + const { result } = renderHook(() => useUrlFilterState(config)) + + act(() => result.current.updateFilters({ deviceIds: ['device-a'] })) + + expect(mocks.router.replace).toHaveBeenCalledExactlyOnceWith( + '?deviceIds=%5B%22device-a%22%5D', + { scroll: false }, + ) + expect(mocks.router.push).not.toHaveBeenCalled() + }) + + it('retains normal navigation for pagination', () => { + const { result } = renderHook(() => useUrlFilterState(config)) + + act(() => result.current.setPage(4)) + + expect(mocks.router.push).toHaveBeenCalledExactlyOnceWith('?page=4') + expect(mocks.router.replace).not.toHaveBeenCalled() + }) +}) diff --git a/src/app/listings/shared/hooks/useUrlFilterState.ts b/src/app/listings/shared/hooks/useUrlFilterState.ts index 5c558bde0..9fdae47cf 100644 --- a/src/app/listings/shared/hooks/useUrlFilterState.ts +++ b/src/app/listings/shared/hooks/useUrlFilterState.ts @@ -1,11 +1,15 @@ import { useRouter, useSearchParams } from 'next/navigation' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect } from 'react' import { PAGINATION, type PageSizeOption } from '@/data/constants' -import { useUrlSearch } from '@/hooks/useUrlState' +import { useLocalStorage } from '@/hooks/useLocalStorage' +import { useUrlSearch } from '@/hooks/useUrlSearch' import analytics from '@/lib/analytics' +import { PageSizePreferenceSchema } from '@/schemas/preferences' import { type SortDirection } from '@/types/api' import { parseLimit } from '../utils/pagination' +const storedPageSizeSchema = PageSizePreferenceSchema.catch(PAGINATION.PUBLIC_DEFAULT_LIMIT) + interface Config { basePath: string storageKey: string @@ -17,28 +21,31 @@ export function useUrlFilterState(config: Config) { const searchParams = useSearchParams() const [searchInput, setSearchInternal, search] = useUrlSearch(300) - const page = Math.max(1, Number(searchParams.get('page')) || 1) + const urlPage = Math.floor(Number(searchParams.get('page'))) const sortField = (searchParams.get('sortField') as TSortField) ?? null const sortDirection = (searchParams.get('sortDirection') as SortDirection) ?? null const myListings = searchParams.get('myListings') === 'true' const urlLimit = searchParams.get('limit') - const [storedLimit, setStoredLimit] = useState(null) - - const limit: PageSizeOption = urlLimit - ? parseLimit(urlLimit, config.maxLimit) - : (storedLimit ?? (PAGINATION.PUBLIC_DEFAULT_LIMIT as PageSizeOption)) + const [storedLimit, setStoredLimit] = useLocalStorage( + config.storageKey, + parseLimit(null, config.maxLimit), + storedPageSizeSchema, + ) + const limit = parseLimit(urlLimit || String(storedLimit), config.maxLimit) + const page = + Number.isSafeInteger(urlPage) && urlPage > 0 && Number.isSafeInteger((urlPage - 1) * limit) + ? urlPage + : 1 useEffect(() => { - if (urlLimit) { - localStorage.setItem(config.storageKey, String(parseLimit(urlLimit, config.maxLimit))) - return + if (!urlLimit) return + try { + setStoredLimit(parseLimit(urlLimit, config.maxLimit), { notifyOnError: false }) + } catch (error) { + console.warn('Failed to save report page size:', error) } - const stored = localStorage.getItem(config.storageKey) - if (stored) { - setStoredLimit(parseLimit(stored, config.maxLimit)) - } - }, [config.storageKey, config.maxLimit, urlLimit]) + }, [config.maxLimit, urlLimit, setStoredLimit]) const updateFilters = useCallback( (updates: Record, shouldPush = false) => { @@ -64,7 +71,7 @@ export function useUrlFilterState(config: Config) { if (shouldPush) { router.push(url) } else { - router.replace(url) + router.replace(url, { scroll: false }) } }, [config.basePath, router, searchParams], @@ -88,11 +95,9 @@ export function useUrlFilterState(config: Config) { const setLimit = useCallback( (value: PageSizeOption) => { updateFilters({ limit: value, page: 1 }, true) - if (typeof window !== 'undefined') { - localStorage.setItem(config.storageKey, String(value)) - } + setStoredLimit(value) }, - [config.storageKey, updateFilters], + [setStoredLimit, updateFilters], ) const setSortField = useCallback( diff --git a/src/app/listings/shared/utils/selectedLabels.test.ts b/src/app/listings/shared/utils/selectedLabels.test.ts new file mode 100644 index 000000000..3861f3a0e --- /dev/null +++ b/src/app/listings/shared/utils/selectedLabels.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { getSelectedOptionNames } from './selectedLabels' + +describe('getSelectedOptionNames', () => { + it('keeps names aligned with selected IDs regardless of option order', () => { + expect( + getSelectedOptionNames( + [ + { id: 'device-a', name: 'Device A' }, + { id: 'device-b', name: 'Device B' }, + ], + ['device-b', 'device-a'], + ), + ).toEqual(['Device B', 'Device A']) + }) + + it('uses unresolved IDs as names while URL-restored options are loading', () => { + expect( + getSelectedOptionNames([{ id: 'soc-a', name: 'SoC A' }], ['soc-pending', 'soc-a']), + ).toEqual(['soc-pending', 'SoC A']) + expect(getSelectedOptionNames([], ['device-pending'])).toEqual(['device-pending']) + }) + + it('does not include options that are not selected', () => { + expect( + getSelectedOptionNames( + [ + { id: 'device-a', name: 'Device A' }, + { id: 'device-b', name: 'Device B' }, + ], + ['device-a'], + ), + ).toEqual(['Device A']) + expect(getSelectedOptionNames([{ id: 'device-a', name: 'Device A' }], [])).toEqual([]) + }) +}) diff --git a/src/app/listings/shared/utils/selectedLabels.ts b/src/app/listings/shared/utils/selectedLabels.ts index a5ff3a74b..a2098d38e 100644 --- a/src/app/listings/shared/utils/selectedLabels.ts +++ b/src/app/listings/shared/utils/selectedLabels.ts @@ -1,3 +1,10 @@ +import type { Option } from '@/utils/options' + +export function getSelectedOptionNames(options: Option[], ids: string[]): string[] { + const namesById = new Map(options.map((option) => [option.id, option.name])) + return ids.map((id) => namesById.get(id) ?? id) +} + export function getSystemNames(systems: { id: string; name: string }[], ids: string[]): string[] { const idSet = new Set(ids) return systems.filter((s) => idSet.has(s.id)).map((s) => s.name) diff --git a/src/app/pc-listings/PcListingsPage.tsx b/src/app/pc-listings/PcListingsPage.tsx index 79059df99..a299f894f 100644 --- a/src/app/pc-listings/PcListingsPage.tsx +++ b/src/app/pc-listings/PcListingsPage.tsx @@ -32,6 +32,11 @@ import { ViewButton, } from '@/components/ui' import storageKeys from '@/data/storageKeys' +import BookmarkButton from '@/features/bookmarks/client/components/BookmarkButton' +import { + useBulkBookmarkStatuses, + getBookmarkStatus, +} from '@/features/bookmarks/client/hooks/useBulkBookmarkStatuses' import { getCpuLabel } from '@/features/hardware/cpu/shared/cpu-format' import { getGpuLabel } from '@/features/hardware/gpu/shared/gpu-format' import { @@ -42,6 +47,7 @@ import { } from '@/hooks' import { api } from '@/lib/api' import { cn } from '@/lib/utils' +import { BooleanPreferenceSchema } from '@/schemas/preferences' import { type RouterInput } from '@/types/trpc' import { filterNullAndEmpty } from '@/utils/filter' import { @@ -80,6 +86,7 @@ function PcListingsPage() { const [showSystemIcons, setShowSystemIcons, isSystemIconsHydrated] = useLocalStorage( storageKeys.showSystemIcons, true, + BooleanPreferenceSchema, ) const { @@ -125,6 +132,13 @@ function PcListingsPage() { placeholderData: (previous) => previous, }) + const bookmarks = useBulkBookmarkStatuses( + 'pcListing', + (listingsQuery.data?.pcListings ?? []) + .filter((report) => report.status === ApprovalStatus.APPROVED) + .map((report) => report.id), + ) + const handleCpuChange = (values: string[]) => { listingsState.setCpuIds(values) } @@ -605,6 +619,13 @@ function PcListingsPage() { {columnVisibility.isColumnVisible('actions') && ( e.stopPropagation()}>
+ {listing.status === ApprovalStatus.APPROVED && ( + + )} {isAdmin && ( parsedCustomFields.map((field) => field.id).join('|'), [parsedCustomFields], ) + const currentSettings = useWatch({ control: form.control, name: 'customFieldValues' }) + const lastSettings = useLastEmulatorSettings({ + userId: currentUserQuery.data?.id, + emulatorId: selectedEmulatorId, + fields: parsedCustomFields, + enabled: customFieldDefinitionsQuery.isSuccess && !customFieldDefinitionsQuery.isFetching, + values: currentSettings, + blocked: Boolean(form.formState.dirtyFields.customFieldValues), + }) usePreSelectedGame({ gameIdFromUrl, - form, - onGameSelect: setSelectedGame, + onGameSelect: (game) => { + form.setValue('gameId', game.id) + setSelectedGame(game) + }, onSearchTermChange: setGameSearchTerm, }) @@ -202,6 +215,7 @@ function AddPcListingPage() { humanVerificationToken, }), ) + lastSettings.remember(result.authorId, result.emulatorId, data.customFieldValues) analytics.listing.created({ listingId: result.id, @@ -422,6 +436,7 @@ function AddPcListingPage() { /> )} /> + {form.formState.errors.cpuId && (

{form.formState.errors.cpuId.message} @@ -450,6 +465,7 @@ function AddPcListingPage() { /> )} /> + {form.formState.errors.gpuId && (

{form.formState.errors.gpuId.message} @@ -638,6 +654,28 @@ function AddPcListingPage() { !customFieldDefinitionsQuery.isPending && parsedCustomFields.length > 0 && (

+ {lastSettings.available && ( + + )} , }, + { + id: 'reports', + label: 'My Reports', + icon: , + }, { id: 'privacy', label: 'Privacy', @@ -86,8 +93,7 @@ function ProfilePage() { const { user, isLoaded } = useUser() const searchParams = useSearchParams() const pathname = usePathname() - const router = useRouter() - const [activeTab, setActiveTab] = useState(searchParams.get('tab') || 'profile') + const activeTab = searchParams.get('tab') || 'profile' const userQuery = api.users.getProfile.useQuery(undefined, { enabled: !!user, @@ -133,11 +139,9 @@ function ProfilePage() { }, [userQuery.data, preferencesQuery.data, user?.id]) const handleTabChange = (tabId: string) => { - setActiveTab(tabId) - const params = new URLSearchParams(Array.from(searchParams.entries())) params.set('tab', tabId) - router.replace(`/profile?${params.toString()}`) + window.history.replaceState(null, '', `${pathname}?${params.toString()}`) analytics.navigation.menuItemClicked({ menuItem: `profile_tab_${tabId}`, @@ -146,17 +150,11 @@ function ProfilePage() { }) } - // Keep state in sync with URL (back/forward or external links) - useEffect(() => { - const tab = searchParams.get('tab') || 'profile' - setActiveTab(tab) - }, [searchParams]) - if (!isLoaded) return if (!user) return - if (userQuery.error) return + if (userQuery.error && !userQuery.data) return // Filter tabs based on user permissions const visibleTabs = baseTabs.filter((tab) => @@ -177,6 +175,8 @@ function ProfilePage() { {activeTab === 'account' && } + {activeTab === 'reports' && } + {activeTab === 'privacy' && } {activeTab === 'connections' && userQuery.data && ( diff --git a/src/app/profile/components/BookmarksManager.tsx b/src/app/profile/components/BookmarksManager.tsx index d67c20620..e0ca9f8ab 100644 --- a/src/app/profile/components/BookmarksManager.tsx +++ b/src/app/profile/components/BookmarksManager.tsx @@ -1,101 +1,33 @@ 'use client' -import { Bookmark, Monitor, Search, Smartphone } from 'lucide-react' -import { useState } from 'react' -import { Input } from '@/components/ui/form' -import { UI_CONSTANTS } from '@/data/constants' -import useDebouncedValue from '@/hooks/useDebouncedValue' +import { Bookmark } from 'lucide-react' +import PrivacyNote from '@/features/bookmarks/client/components/PrivacyNote' import { api } from '@/lib/api' -import BookmarkEmptyState from './bookmarks/BookmarkEmptyState' -import BookmarkGrid from './bookmarks/BookmarkGrid' -import BookmarkSubTabButton from './bookmarks/BookmarkSubTabButton' -import PrivacyNote from './bookmarks/PrivacyNote' +import BookmarkBrowser from './bookmarks/BookmarkBrowser' import SettingsSection from './SettingsSection' -type SubTab = 'handheld' | 'pc' - interface Props { userId: string } -function BookmarksManager(props: Props) { - const [subTab, setSubTab] = useState('handheld') - const [page, setPage] = useState(1) - const [search, setSearch] = useState('') - const debouncedSearch = useDebouncedValue(search, UI_CONSTANTS.DEBOUNCE_DELAY) - const limit = 12 - - const countsQuery = api.bookmarks.getCounts.useQuery({ userId: props.userId }) - - const handleSubTabChange = (tab: SubTab) => { - setSubTab(tab) - setPage(1) - setSearch('') - } - - const handheldCount = - countsQuery.data?.visibility === 'visible' ? countsQuery.data.counts.listingBookmarks : 0 - const pcCount = - countsQuery.data?.visibility === 'visible' ? countsQuery.data.counts.pcListingBookmarks : 0 - +export default function BookmarksManager(props: Props) { + const counts = api.bookmarks.getCounts.useQuery({ userId: props.userId }) return ( } > -
-
-
- } - label="Handheld" - onClick={() => handleSubTabChange('handheld')} - /> - } - label="PC" - onClick={() => handleSubTabChange('pc')} - /> -
- -
- - { - setSearch(e.target.value) - setPage(1) - }} - className="pl-9" - /> -
-
- - - - - } - /> -
+ +
) } - -export default BookmarksManager diff --git a/src/app/profile/components/DeviceAndSocPreferences.test.tsx b/src/app/profile/components/DeviceAndSocPreferences.test.tsx new file mode 100644 index 000000000..9982d065d --- /dev/null +++ b/src/app/profile/components/DeviceAndSocPreferences.test.tsx @@ -0,0 +1,304 @@ +import { + QueryClient, + QueryClientProvider, + useMutation, + useQuery, + type UseMutationOptions, +} from '@tanstack/react-query' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { type ComponentProps } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import toast from '@/lib/toast' +import { type RouterInput, type RouterOutput } from '@/types/trpc' +import DeviceAndSocPreferences from './DeviceAndSocPreferences' +import type DeviceMultiSelect from '@/features/hardware/device/client/components/DeviceMultiSelect' + +type Preferences = RouterOutput['userPreferences']['get'] +type DeviceInput = RouterInput['userPreferences']['bulkUpdateDevices'] +type SocInput = RouterInput['userPreferences']['bulkUpdateSocs'] +type DeviceResult = RouterOutput['userPreferences']['bulkUpdateDevices'] +type SocResult = RouterOutput['userPreferences']['bulkUpdateSocs'] + +const mocks = vi.hoisted(() => ({ + devices: vi.fn<(input: DeviceInput) => Promise>(), + socs: vi.fn<(input: SocInput) => Promise>(), + invalidate: vi.fn(), + setData: + vi.fn< + ( + input: undefined, + update: (current: Preferences | undefined) => Preferences | undefined, + ) => void + >(), +})) + +vi.mock('@/lib/api', () => ({ + api: { + useUtils: () => ({ + userPreferences: { get: { invalidate: mocks.invalidate, setData: mocks.setData } }, + users: { me: { invalidate: vi.fn() } }, + listings: { get: { invalidate: vi.fn() } }, + }), + userPreferences: { + update: { useMutation: () => ({ mutate: vi.fn() }) }, + bulkUpdateDevices: { + useMutation: (options: UseMutationOptions) => + useMutation({ ...options, mutationFn: mocks.devices }), + }, + bulkUpdateSocs: { + useMutation: (options: UseMutationOptions) => + useMutation({ ...options, mutationFn: mocks.socs }), + }, + }, + }, +})) +vi.mock('@/lib/toast', () => ({ default: { success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/analytics', () => ({ default: { user: { preferencesUpdated: vi.fn() } } })) +vi.mock('@/features/hardware/device/client/components/DeviceMultiSelect', () => ({ + default: Selection, +})) +vi.mock('@/features/hardware/soc/client/components/SocMultiSelect', () => ({ default: Selection })) + +function Selection(props: ComponentProps) { + const prefix = props.label === 'Preferred devices' ? 'device' : 'soc' + return ( +
+ {props.value.join(',')} + + +
+ ) +} + +const initialPreferences = { + id: 'user-1', + bio: null, + defaultToUserDevices: false, + defaultToUserSocs: false, + notifyOnNewListings: true, + showNsfw: false, + lastUsedDeviceId: null, + profilePublic: true, + showActivityInFeed: true, + showVotingActivity: true, + allowFollows: true, + allowFriendRequests: true, + followersVisible: true, + followingVisible: true, + bookmarksVisible: true, + followedGamesVisible: true, + devicePreferences: [], + socPreferences: [], +} satisfies Preferences + +let saved: Preferences = initialPreferences + +function saveDevices(deviceIds: string[]) { + const createdAt = new Date('2026-01-01T00:00:00Z') + const rows: DeviceResult = deviceIds.map((id) => ({ + id: `pref-${id}`, + userId: 'user-1', + deviceId: id, + createdAt, + device: { + id, + brandId: 'brand-1', + modelName: id, + socId: null, + brand: { id: 'brand-1', name: 'Brand', createdAt }, + soc: null, + }, + })) + saved = { + ...saved, + devicePreferences: rows, + } + return rows +} + +function saveSocs(socIds: string[]) { + const createdAt = new Date('2026-01-01T00:00:00Z') + const rows: SocResult = socIds.map((id) => ({ + id: `pref-${id}`, + userId: 'user-1', + socId: id, + createdAt, + updatedAt: createdAt, + soc: { + id, + name: id, + manufacturer: 'Manufacturer', + architecture: null, + processNode: null, + cpuCores: null, + gpuModel: null, + createdAt, + }, + })) + saved = { ...saved, socPreferences: rows } + return rows +} + +function Harness() { + const preferencesQuery = useQuery({ + queryKey: ['preferences'], + queryFn: async () => saved, + initialData: initialPreferences, + staleTime: Infinity, + }) + return +} + +function renderPreferences() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + mocks.invalidate.mockImplementation(() => client.invalidateQueries({ queryKey: ['preferences'] })) + mocks.setData.mockImplementation((_input, update) => { + client.setQueryData(['preferences'], update) + }) + return render( + + + , + ) +} + +describe('async hardware preference saves', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.devices.mockReset() + mocks.socs.mockReset() + saved = initialPreferences + }) + + it('keeps a later selection visible while earlier responses arrive, and serializes writes', async () => { + const first = Promise.withResolvers() + const second = Promise.withResolvers() + mocks.devices.mockImplementationOnce(async ({ deviceIds }) => { + await first.promise + return saveDevices(deviceIds) + }) + mocks.devices.mockImplementationOnce(async ({ deviceIds }) => { + await second.promise + return saveDevices(deviceIds) + }) + renderPreferences() + + fireEvent.click(screen.getByRole('button', { name: 'Add device' })) + await waitFor(() => expect(mocks.devices).toHaveBeenCalledTimes(1)) + fireEvent.click(screen.getByRole('button', { name: 'Add device' })) + expect(screen.getByTestId('device')).toHaveTextContent('device-1,device-2') + expect(mocks.devices).toHaveBeenCalledTimes(1) + + await act(async () => first.resolve()) + await waitFor(() => expect(mocks.devices).toHaveBeenCalledTimes(2)) + expect(screen.getByTestId('device')).toHaveTextContent('device-1,device-2') + expect(mocks.devices).toHaveBeenLastCalledWith( + { deviceIds: ['device-1', 'device-2'] }, + expect.anything(), + ) + await act(async () => second.resolve()) + await waitFor(() => expect(saved.devicePreferences).toHaveLength(2)) + expect(screen.getByTestId('device')).toHaveTextContent('device-1,device-2') + }) + + it('shares the save scope between device and SoC preferences', async () => { + const first = Promise.withResolvers() + mocks.socs.mockImplementationOnce(async ({ socIds }) => { + await first.promise + return saveSocs(socIds) + }) + mocks.devices.mockImplementation(async ({ deviceIds }) => saveDevices(deviceIds)) + renderPreferences() + fireEvent.click(screen.getByRole('button', { name: 'Add soc' })) + await waitFor(() => expect(mocks.socs).toHaveBeenCalledTimes(1)) + fireEvent.click(screen.getByRole('button', { name: 'Add device' })) + expect(mocks.devices).not.toHaveBeenCalled() + await act(async () => first.resolve()) + await waitFor(() => expect(mocks.devices).toHaveBeenCalledTimes(1)) + }) + + it('restores saved selection and reports a failed latest save', async () => { + const gate = Promise.withResolvers() + mocks.devices.mockImplementationOnce(async () => { + await gate.promise + throw new Error('Save failed') + }) + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + renderPreferences() + fireEvent.click(screen.getByRole('button', { name: 'Add device' })) + expect(screen.getByTestId('device')).toHaveTextContent('device-1') + await act(async () => gate.resolve()) + await waitFor(() => expect(screen.getByTestId('device')).toBeEmptyDOMElement()) + expect(toast.error).toHaveBeenCalledWith('Failed to update device preferences: Save failed') + } finally { + errorLog.mockRestore() + } + }) + + it.each(['success', 'error'] as const)( + 'keeps the latest selection when an older matching save completes with %s', + async (outcome) => { + const gates = [ + Promise.withResolvers(), + Promise.withResolvers(), + Promise.withResolvers(), + ] + for (const gate of gates) { + mocks.devices.mockImplementationOnce(async ({ deviceIds }) => { + await gate.promise + return saveDevices(deviceIds) + }) + } + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + renderPreferences() + fireEvent.click(screen.getByRole('button', { name: 'Add device' })) + await waitFor(() => expect(mocks.devices).toHaveBeenCalledTimes(1)) + fireEvent.click(screen.getByRole('button', { name: 'Add device' })) + fireEvent.click(screen.getByRole('button', { name: 'Remove device' })) + + await act(async () => { + if (outcome === 'error') gates[0].reject(new Error('Older save failed')) + else gates[0].resolve() + }) + await waitFor(() => expect(mocks.devices).toHaveBeenCalledTimes(2)) + await act(async () => gates[1].resolve()) + await waitFor(() => expect(mocks.devices).toHaveBeenCalledTimes(3)) + + expect(screen.getByTestId('device')).toHaveTextContent(/^device-1$/) + await act(async () => gates[2].resolve()) + await waitFor(() => expect(saved.devicePreferences).toHaveLength(1)) + expect(screen.getByTestId('device')).toHaveTextContent(/^device-1$/) + } finally { + await act(async () => gates.forEach((gate) => gate.resolve())) + errorLog.mockRestore() + } + }, + ) + + it.each(['device', 'soc'] as const)( + 'keeps confirmed %s selections when preference refetching fails', + async (kind) => { + mocks.devices.mockImplementation(async ({ deviceIds }) => saveDevices(deviceIds)) + mocks.socs.mockImplementation(async ({ socIds }) => saveSocs(socIds)) + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + renderPreferences() + mocks.invalidate.mockRejectedValueOnce(new Error('Refetch unavailable')) + fireEvent.click(screen.getByRole('button', { name: `Add ${kind}` })) + await waitFor(() => expect(toast.success).toHaveBeenCalledOnce()) + + expect(screen.getByTestId(kind)).toHaveTextContent(`${kind}-1`) + } finally { + errorLog.mockRestore() + } + }, + ) +}) diff --git a/src/app/profile/components/DeviceAndSocPreferences.tsx b/src/app/profile/components/DeviceAndSocPreferences.tsx index ee3fc973a..84f647a5a 100644 --- a/src/app/profile/components/DeviceAndSocPreferences.tsx +++ b/src/app/profile/components/DeviceAndSocPreferences.tsx @@ -2,15 +2,15 @@ import { type UseQueryResult } from '@tanstack/react-query' import { Smartphone, Cpu, Settings } from 'lucide-react' -import { useCallback, useRef, useEffect } from 'react' +import { useState } from 'react' import { AnimatedToggle } from '@/components/ui' +import DeviceMultiSelect from '@/features/hardware/device/client/components/DeviceMultiSelect' +import SocMultiSelect from '@/features/hardware/soc/client/components/SocMultiSelect' import analytics from '@/lib/analytics' import { api } from '@/lib/api' import toast from '@/lib/toast' import { type RouterOutput } from '@/types/trpc' import getErrorMessage from '@/utils/getErrorMessage' -import DeviceSelector from './DeviceSelector' -import SocSelector from './SocSelector' type UserPreferencesData = RouterOutput['userPreferences']['get'] @@ -20,6 +20,8 @@ interface Props { function DeviceAndSocPreferences(props: Props) { const utils = api.useUtils() + const [deviceDraft, setDeviceDraft] = useState(null) + const [socDraft, setSocDraft] = useState(null) const updatePreferences = api.userPreferences.update.useMutation({ onSuccess: () => { @@ -39,8 +41,13 @@ function DeviceAndSocPreferences(props: Props) { }) const bulkUpdateDevices = api.userPreferences.bulkUpdateDevices.useMutation({ - onSuccess: () => { - invalidatePreferences() + scope: { id: 'hardware-preferences' }, + onSuccess: async (data, variables) => { + utils.userPreferences.get.setData(undefined, (current) => + current ? { ...current, devicePreferences: data } : current, + ) + await invalidatePreferences() + setDeviceDraft((current) => clearSubmittedSelection(current, variables.deviceIds)) toast.success('Device preferences updated successfully!') analytics.user.preferencesUpdated({ @@ -49,15 +56,21 @@ function DeviceAndSocPreferences(props: Props) { action: 'updated', }) }, - onError: (error) => { + onError: (error, variables) => { + setDeviceDraft((current) => clearSubmittedSelection(current, variables.deviceIds)) console.error('Error updating device preferences:', error) toast.error(`Failed to update device preferences: ${getErrorMessage(error)}`) }, }) const bulkUpdateSocs = api.userPreferences.bulkUpdateSocs.useMutation({ - onSuccess: () => { - invalidatePreferences() + scope: { id: 'hardware-preferences' }, + onSuccess: async (data, variables) => { + utils.userPreferences.get.setData(undefined, (current) => + current ? { ...current, socPreferences: data } : current, + ) + await invalidatePreferences() + setSocDraft((current) => clearSubmittedSelection(current, variables.socIds)) toast.success('SOC preferences updated successfully!') analytics.user.preferencesUpdated({ @@ -66,14 +79,15 @@ function DeviceAndSocPreferences(props: Props) { action: 'updated', }) }, - onError: (error) => { + onError: (error, variables) => { + setSocDraft((current) => clearSubmittedSelection(current, variables.socIds)) console.error('Error updating SOC preferences:', error) toast.error(`Failed to update SOC preferences: ${getErrorMessage(error)}`) }, }) const invalidatePreferences = () => { - Promise.all([ + return Promise.all([ utils.userPreferences.get.invalidate(), utils.users.me.invalidate(), utils.listings.get.invalidate(), @@ -87,56 +101,17 @@ function DeviceAndSocPreferences(props: Props) { updatePreferences.mutate({ [key]: value }) } - const deviceTimeoutRef = useRef(null) - const socTimeoutRef = useRef(null) - - useEffect(() => { - return () => { - if (deviceTimeoutRef.current) { - clearTimeout(deviceTimeoutRef.current) - } - if (socTimeoutRef.current) { - clearTimeout(socTimeoutRef.current) - } - } - }, []) - - const handleDevicesChange = useCallback( - ( - devices: { - id: string - modelName: string - brand: { id: string; name: string } - soc?: { id: string; name: string; manufacturer: string } | null - }[], - ) => { - const deviceIds = devices.map((device) => device.id) - - if (deviceTimeoutRef.current) { - clearTimeout(deviceTimeoutRef.current) - } - - deviceTimeoutRef.current = setTimeout(() => { - bulkUpdateDevices.mutate({ deviceIds }) - }, 500) - }, - [bulkUpdateDevices], - ) - - const handleSocsChange = useCallback( - (socs: typeof selectedSocs) => { - const socIds = socs.map((soc) => soc.id) - - if (socTimeoutRef.current) { - clearTimeout(socTimeoutRef.current) - } + function handleDevicesChange(deviceIds: string[]) { + const submittedIds = [...deviceIds] + setDeviceDraft(submittedIds) + bulkUpdateDevices.mutate({ deviceIds: submittedIds }) + } - socTimeoutRef.current = setTimeout(() => { - bulkUpdateSocs.mutate({ socIds }) - }, 500) - }, - [bulkUpdateSocs], - ) + function handleSocsChange(socIds: string[]) { + const submittedIds = [...socIds] + setSocDraft(submittedIds) + bulkUpdateSocs.mutate({ socIds: submittedIds }) + } if (props.preferencesQuery.isPending) { return ( @@ -168,8 +143,9 @@ function DeviceAndSocPreferences(props: Props) { const { data: preferences } = props.preferencesQuery - const selectedDevices = preferences.devicePreferences?.map((pref) => pref.device) || [] - const selectedSocs = preferences.socPreferences?.map((pref) => pref.soc) || [] + const selectedDeviceIds = + deviceDraft ?? preferences.devicePreferences.map((pref) => pref.device.id) + const selectedSocIds = socDraft ?? preferences.socPreferences.map((pref) => pref.soc.id) return (
@@ -247,7 +223,13 @@ function DeviceAndSocPreferences(props: Props) { and filter relevant content.

- +
@@ -261,10 +243,20 @@ function DeviceAndSocPreferences(props: Props) { reports to show performance data most relevant to you.

- +
) } +function clearSubmittedSelection(current: string[] | null, submitted: string[]): string[] | null { + return current === submitted ? null : current +} + export default DeviceAndSocPreferences diff --git a/src/app/profile/components/DeviceSelector.tsx b/src/app/profile/components/DeviceSelector.tsx deleted file mode 100644 index aef02a2b5..000000000 --- a/src/app/profile/components/DeviceSelector.tsx +++ /dev/null @@ -1,356 +0,0 @@ -'use client' - -import { motion, AnimatePresence } from 'framer-motion' -import { Smartphone, Search, Loader2, ChevronDown, Check } from 'lucide-react' -import { useState, useMemo } from 'react' -import { Input } from '@/components/ui' -import { LOOKUP_PAGINATION } from '@/data/constants' -import { api } from '@/lib/api' -import { cn } from '@/lib/utils' -import getErrorMessage from '@/utils/getErrorMessage' -import { searchItems, getDeviceSearchText } from '@/utils/simpleSearch' - -interface Device { - id: string - modelName: string - brand: { - id: string - name: string - } - soc?: { - id: string - name: string - manufacturer: string - } | null -} - -interface Props { - selectedDevices: Device[] - onDevicesChange: (devices: Device[]) => void - className?: string -} - -const EMPTY_DEVICES: Device[] = [] - -function DeviceSelector(props: Props) { - const [searchQuery, setSearchQuery] = useState('') - const [expandedBrands, setExpandedBrands] = useState>(new Set()) - - // TODO: Make this selector async instead of preloading 1000 options. - const devicesQuery = api.devices.options.useQuery({ limit: LOOKUP_PAGINATION.MAX_LIMIT }) - const devices = devicesQuery.data?.devices ?? EMPTY_DEVICES - - const filteredDevices = useMemo(() => { - return searchItems(devices, searchQuery, getDeviceSearchText) - }, [devices, searchQuery]) - - // Group devices by brand for better organization - const devicesByBrand = useMemo(() => { - const groups: Record = {} - - filteredDevices.forEach((device) => { - const brandName = device.brand.name - if (!groups[brandName]) { - groups[brandName] = [] - } - groups[brandName].push(device) - }) - - // Sort brands alphabetically and devices within each brand - return Object.entries(groups) - .sort(([a], [b]) => a.localeCompare(b)) - .reduce>((acc, [brandName, devices]) => { - acc[brandName] = devices.sort((a, b) => a.modelName.localeCompare(b.modelName)) - return acc - }, {}) - }, [filteredDevices]) - - function isDeviceSelected(device: Device) { - return props.selectedDevices.some((selected) => selected.id === device.id) - } - - function handleDeviceToggle(device: Device) { - // Normalize the device data to match the expected structure - const normalizedDevice = { - id: device.id, - modelName: device.modelName, - brand: { - id: device.brand.id, - name: device.brand.name, - }, - soc: device.soc - ? { - id: device.soc.id, - name: device.soc.name, - manufacturer: device.soc.manufacturer, - } - : null, - } - - if (isDeviceSelected(device)) { - const newSelection = props.selectedDevices.filter((selected) => selected.id !== device.id) - props.onDevicesChange(newSelection) - } else { - const newSelection = [...props.selectedDevices, normalizedDevice] - props.onDevicesChange(newSelection) - } - } - - function handleRemoveDevice(device: Device) { - props.onDevicesChange(props.selectedDevices.filter((selected) => selected.id !== device.id)) - } - - function handleClearAll() { - props.onDevicesChange([]) - } - - function toggleBrand(brandName: string) { - const newExpanded = new Set(expandedBrands) - if (newExpanded.has(brandName)) { - newExpanded.delete(brandName) - } else { - newExpanded.add(brandName) - } - setExpandedBrands(newExpanded) - } - - function selectAllFromBrand(brandName: string) { - const brandDevices = devicesByBrand[brandName] || [] - const allSelected = brandDevices.every((device) => isDeviceSelected(device)) - - if (allSelected) { - // Deselect all from this brand - const remaining = props.selectedDevices.filter( - (selected) => !brandDevices.some((device) => device.id === selected.id), - ) - props.onDevicesChange(remaining) - } else { - // Select all from this brand - normalize the device data - const toAdd = brandDevices - .filter((device) => !isDeviceSelected(device)) - .map((device) => ({ - id: device.id, - modelName: device.modelName, - brand: { - id: device.brand.id, - name: device.brand.name, - }, - soc: device.soc - ? { - id: device.soc.id, - name: device.soc.name, - manufacturer: device.soc.manufacturer, - } - : null, - })) - props.onDevicesChange([...props.selectedDevices, ...toAdd]) - } - } - - if (devicesQuery.isPending) { - return ( -
- -
- ) - } - - if (devicesQuery.error) { - return ( -
-

- Failed to load devices: {getErrorMessage(devicesQuery.error)} -

-
- ) - } - - const selectedDeviceIds = new Set(props.selectedDevices.map((device) => device.id)) - - return ( -
- {/* Search */} -
- } - type="text" - placeholder="Search devices... (e.g., Retroid Pocket 5, Odin 2)" - value={searchQuery} - onChange={(ev) => setSearchQuery(ev.target.value)} - className="w-full" - /> -
- - {/* Selected Devices Summary */} - {props.selectedDevices.length > 0 && ( - -
-
- - - Selected Devices ({props.selectedDevices.length}) - -
- -
- -
- - {props.selectedDevices.map((device) => ( - handleRemoveDevice(device)} - className="inline-flex items-center gap-1 bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-900/50 text-blue-800 dark:text-blue-200 text-xs px-2 py-1 rounded-lg transition-colors" - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - initial={{ opacity: 0, scale: 0.9 }} - animate={{ opacity: 1, scale: 1 }} - exit={{ opacity: 0, scale: 0.9 }} - > - {device.brand.name} {device.modelName} - × - - ))} - -
-
- )} - - {/* Device List */} -
- {Object.keys(devicesByBrand).length === 0 ? ( -
- -

- No devices found matching your criteria -

-
- ) : ( - Object.entries(devicesByBrand).map(([brandName, devices]) => { - const isExpanded = expandedBrands.has(brandName) - const allSelected = devices.every((device) => selectedDeviceIds.has(device.id)) - const someSelected = devices.some((device) => selectedDeviceIds.has(device.id)) - - return ( - - {/* Brand Header */} -
-
- - -
-
- - {/* Device List */} - - {isExpanded && ( - -
- {devices.map((device) => { - const isSelected = selectedDeviceIds.has(device.id) - - return ( - handleDeviceToggle(device)} - className={cn( - 'w-full flex items-center gap-3 p-3 rounded-lg transition-all text-left', - isSelected - ? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800' - : 'hover:bg-gray-50 dark:hover:bg-gray-800/50', - )} - whileHover={{ scale: 1.02 }} - whileTap={{ scale: 0.98 }} - > -
- {isSelected && } -
- - - -
-
- - {device.modelName} - -
- - {device.soc && ( -

- {device.soc.manufacturer} {device.soc.name} -

- )} -
-
- ) - })} -
-
- )} -
-
- ) - }) - )} -
-
- ) -} - -export default DeviceSelector diff --git a/src/app/profile/components/ProfileInformation.test.tsx b/src/app/profile/components/ProfileInformation.test.tsx new file mode 100644 index 000000000..78cbfee6f --- /dev/null +++ b/src/app/profile/components/ProfileInformation.test.tsx @@ -0,0 +1,229 @@ +import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { useEffect } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { type api } from '@/lib/api' +import toast from '@/lib/toast' +import { type RouterOutput } from '@/types/trpc' +import { Role } from '@orm' +import ProfilePage from '../ProfilePage' +import ProfileInformation from './ProfileInformation' +import type * as Clerk from '@clerk/nextjs' + +const mutation = vi.hoisted(() => ({ mutate: vi.fn(), isPending: false })) +const invalidate = vi.hoisted(() => + vi.fn['users']['getProfile']['invalidate']>(), +) +const fetchProfile = vi.hoisted(() => vi.fn<() => Promise>()) +const profileQueryError = vi.hoisted(() => vi.fn()) +let onSuccess: NonNullable[0]>['onSuccess'] +vi.mock('@/lib/api', () => ({ + api: { + useUtils: () => ({ users: { getProfile: { invalidate } } }), + users: { + getProfile: { useQuery: () => useProfileQuery() }, + update: { + useMutation: (options: Parameters[0]) => { + onSuccess = options?.onSuccess + return mutation + }, + }, + }, + userPreferences: { get: { useQuery: () => ({}) } }, + notifications: { getPreferences: { useQuery: () => ({}) } }, + }, +})) +vi.mock('@clerk/nextjs', async (importOriginal) => ({ + ...(await importOriginal()), + useUser: () => ({ user: { id: 'clerk-user' }, isLoaded: true }), +})) +vi.mock('next/navigation', () => ({ + usePathname: () => '/profile', + useSearchParams: () => new URLSearchParams(), +})) +vi.mock('@/lib/analytics', () => ({ + default: { navigation: { pageView: vi.fn() }, conversion: { goalCompleted: vi.fn() } }, +})) +vi.mock('@/lib/toast', () => ({ + default: { success: vi.fn(), error: vi.fn(), warning: vi.fn() }, +})) + +const initial = { data: { name: 'Original name', bio: 'Original bio' }, isPending: false } +const refreshed = { data: { name: 'Refreshed name', bio: 'Refreshed bio' }, isPending: false } +const profile: RouterOutput['users']['getProfile'] = { + ...initial.data, + id: '00000000-0000-4000-a000-000000000001', + email: 'owner@example.com', + profileImage: null, + role: Role.USER, + trustScore: 1, + createdAt: new Date('2026-11-01T12:00:00Z'), + _count: { listings: 0 }, +} +let initialProfile: typeof profile | undefined = profile + +function useProfileQuery() { + const query = useQuery({ + queryKey: ['profile'], + queryFn: fetchProfile, + initialData: initialProfile, + staleTime: Infinity, + retry: false, + }) + useEffect(() => { + if (query.isError) profileQueryError() + }, [query.isError]) + return query +} + +describe('profile information draft', () => { + beforeEach(() => { + vi.clearAllMocks() + onSuccess = undefined + invalidate.mockReset().mockResolvedValue(undefined) + initialProfile = profile + fetchProfile.mockReset().mockResolvedValue(profile) + }) + + it('waits for the saved profile refetch before another edit can start', async () => { + const refetch = Promise.withResolvers() + invalidate.mockReturnValueOnce(refetch.promise) + const view = render() + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + fireEvent.change(screen.getByPlaceholderText('Enter your name'), { + target: { value: 'My saved name' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Save Changes' })) + const complete = onSuccess + if (!complete) throw new Error('Expected a profile success callback') + let completion: unknown + act(() => { + completion = complete( + { success: true, profile: { name: 'My saved name', bio: 'Original bio' } }, + { name: 'My saved name', bio: 'Original bio' }, + undefined, + { client: new QueryClient(), meta: undefined, mutationKey: undefined }, + ) + }) + try { + expect(invalidate).toHaveBeenCalledOnce() + expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument() + expect(screen.getByDisplayValue('My saved name')).toBeInTheDocument() + view.rerender() + await act(async () => { + refetch.resolve() + await completion + }) + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + expect(screen.getByDisplayValue('Refreshed name')).toBeInTheDocument() + expect(screen.getByDisplayValue('Refreshed bio')).toBeInTheDocument() + } finally { + refetch.resolve() + await completion + } + }) + + it('preserves an edit during a query refresh and submits the displayed draft', () => { + const view = render() + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + fireEvent.change(screen.getByPlaceholderText('Enter your name'), { + target: { value: 'My draft' }, + }) + view.rerender() + expect(screen.getByDisplayValue('My draft')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Save Changes' })) + expect(mutation.mutate).toHaveBeenCalledWith({ name: 'My draft', bio: 'Original bio' }) + }) + + it('keeps the saved draft open when a real background profile refetch fails', async () => { + const queryClient = new QueryClient() + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}) + fetchProfile.mockRejectedValue(new Error('Profile refresh unavailable')) + invalidate.mockImplementation((_input, _filters, options) => + queryClient.invalidateQueries({ queryKey: ['profile'] }, options), + ) + try { + render( + + + , + ) + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + fireEvent.change(screen.getByPlaceholderText('Enter your name'), { + target: { value: ' My saved name ' }, + }) + fireEvent.change(screen.getByPlaceholderText('Tell us about yourself'), { + target: { value: 'Submitted bio' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Save Changes' })) + const complete = onSuccess + if (!complete) throw new Error('Expected a profile success callback') + await act(async () => { + await complete( + { success: true, profile: { name: 'My saved name', bio: 'Persisted bio' } }, + { name: ' My saved name ', bio: 'Submitted bio' }, + undefined, + { client: queryClient, meta: undefined, mutationKey: undefined }, + ) + }) + await waitFor(() => expect(profileQueryError).toHaveBeenCalledOnce()) + expect(fetchProfile).toHaveBeenCalledOnce() + expect(queryClient.getQueryData(['profile'])).toEqual(profile) + expect(screen.getByPlaceholderText('Enter your name')).toHaveValue(' My saved name ') + expect( + screen.queryByText('Error loading profile. Please try again later.'), + ).not.toBeInTheDocument() + expect(toast.warning).toHaveBeenCalledWith( + 'Profile saved, but it could not be refreshed. Your draft is still open.', + ) + expect(toast.success).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.getByText('My saved name', { selector: 'p' }).textContent).toBe('My saved name') + expect(screen.getByText('Persisted bio', { selector: 'p' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + expect(screen.getByDisplayValue('My saved name')).toBeInTheDocument() + expect(screen.getByDisplayValue('Persisted bio')).toBeInTheDocument() + + act(() => { + queryClient.setQueryData(['profile'], { ...profile, name: 'Latest server name' }) + }) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(await screen.findByText('Latest server name', { selector: 'p' })).toBeInTheDocument() + } finally { + queryClient.clear() + errorLog.mockRestore() + } + }) + + it('keeps the full-page error for an initial failure without a cached profile', async () => { + const queryClient = new QueryClient() + initialProfile = undefined + fetchProfile.mockRejectedValue(new Error('Profile unavailable')) + try { + render( + + + , + ) + await screen.findByText('Error loading profile. Please try again later.') + expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument() + } finally { + queryClient.clear() + } + }) + + it('starts the next edit with the latest saved values after cancellation', () => { + const view = render() + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + fireEvent.change(screen.getByPlaceholderText('Enter your name'), { + target: { value: 'Discard me' }, + }) + view.rerender() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + expect(screen.getByDisplayValue('Refreshed name')).toBeInTheDocument() + expect(screen.getByDisplayValue('Refreshed bio')).toBeInTheDocument() + }) +}) diff --git a/src/app/profile/components/ProfileInformation.tsx b/src/app/profile/components/ProfileInformation.tsx index e1bcd9407..21a337485 100644 --- a/src/app/profile/components/ProfileInformation.tsx +++ b/src/app/profile/components/ProfileInformation.tsx @@ -1,14 +1,14 @@ 'use client' import { User, Save, X, Pencil } from 'lucide-react' -import { useState, useEffect } from 'react' +import { useState } from 'react' import { Button, Input } from '@/components/ui' import { api } from '@/lib/api' import toast from '@/lib/toast' import { type RouterOutput } from '@/types/trpc' import getErrorMessage from '@/utils/getErrorMessage' -type UserProfileData = RouterOutput['users']['getProfile'] +type UserProfileData = Pick interface Props { userQuery: { @@ -24,23 +24,39 @@ function ProfileInformation(props: Props) { name: '', bio: '', }) + const [savedProfile, setSavedProfile] = useState<{ + data: UserProfileData + previousQueryData: Props['userQuery']['data'] + } | null>(null) + const profileData = + savedProfile && savedProfile.previousQueryData === props.userQuery.data + ? savedProfile.data + : props.userQuery.data const utils = api.useUtils() - // Initialize form data when user data loads - useEffect(() => { - if (!props.userQuery.data) return + function handleEditToggle() { setFormData({ - name: props.userQuery.data.name ?? '', - bio: props.userQuery.data.bio ?? '', + name: profileData?.name ?? '', + bio: profileData?.bio ?? '', }) - }, [props.userQuery.data]) + setIsEditing(!isEditing) + } const updateProfile = api.users.update.useMutation({ - onSuccess: () => { - utils.users.getProfile.invalidate().catch(console.error) - toast.success('Profile updated successfully!') - setIsEditing(false) + onSuccess: async (result) => { + setSavedProfile({ + data: result.profile, + previousQueryData: props.userQuery.data, + }) + try { + await utils.users.getProfile.invalidate(undefined, undefined, { throwOnError: true }) + toast.success('Profile updated successfully!') + setIsEditing(false) + } catch (error) { + console.error('Error refreshing the saved profile:', error) + toast.warning('Profile saved, but it could not be refreshed. Your draft is still open.') + } }, onError: (error) => { console.error('Error updating profile:', error) @@ -53,10 +69,10 @@ function ProfileInformation(props: Props) { } const handleFormReset = () => { - if (props.userQuery.data) { + if (profileData) { setFormData({ - name: props.userQuery.data.name ?? '', - bio: props.userQuery.data.bio ?? '', + name: profileData.name ?? '', + bio: profileData.bio ?? '', }) } setIsEditing(false) @@ -90,7 +106,7 @@ function ProfileInformation(props: Props) {
@@ -133,7 +147,7 @@ function ProfileInformation(props: Props) { /> ) : (

- {props.userQuery.data?.bio || 'No bio added yet'} + {profileData?.bio || 'No bio added yet'}

)}
diff --git a/src/app/profile/components/SocSelector.tsx b/src/app/profile/components/SocSelector.tsx deleted file mode 100644 index f43aa00c8..000000000 --- a/src/app/profile/components/SocSelector.tsx +++ /dev/null @@ -1,281 +0,0 @@ -'use client' - -import { motion, AnimatePresence } from 'framer-motion' -import { Search, Check, Cpu, ChevronDown } from 'lucide-react' -import { useState, useMemo } from 'react' -import { Input } from '@/components/ui' -import { LOOKUP_PAGINATION } from '@/data/constants' -import { api } from '@/lib/api' -import { cn } from '@/lib/utils' -import getErrorMessage from '@/utils/getErrorMessage' - -interface Soc { - id: string - name: string - manufacturer: string -} - -interface Props { - selectedSocs: Soc[] - onSocsChange: (socs: Soc[]) => void -} - -function SocSelector(props: Props) { - const [searchTerm, setSearchTerm] = useState('') - const [expandedManufacturers, setExpandedManufacturers] = useState>(new Set()) - // TODO: Make this selector async instead of preloading 1000 options. - const socsQuery = api.socs.options.useQuery({ limit: LOOKUP_PAGINATION.MAX_LIMIT }) - - const filteredSocs = useMemo(() => { - const allSocs: Soc[] = - socsQuery.data?.socs?.map((soc) => ({ - id: soc.id, - name: soc.name, - manufacturer: soc.manufacturer, - })) ?? [] - - if (!searchTerm) return allSocs - return allSocs.filter( - (soc) => - soc.name.toLowerCase().includes(searchTerm.toLowerCase()) || - soc.manufacturer.toLowerCase().includes(searchTerm.toLowerCase()), - ) - }, [searchTerm, socsQuery.data?.socs]) - - const socsByManufacturer = useMemo(() => { - const grouped = filteredSocs.reduce>((acc, soc) => { - if (!acc[soc.manufacturer]) { - acc[soc.manufacturer] = [] - } - acc[soc.manufacturer].push(soc) - return acc - }, {}) - - // Sort manufacturers alphabetically and SOCs within each manufacturer - return Object.entries(grouped) - .sort(([a], [b]) => a.localeCompare(b)) - .reduce>((acc, [manufacturer, socs]) => { - acc[manufacturer] = socs.sort((a, b) => a.name.localeCompare(b.name)) - return acc - }, {}) - }, [filteredSocs]) - - if (socsQuery.isPending) { - return ( -
- -
- ) - } - - if (socsQuery.error || !socsQuery.data?.socs) { - return ( -
-

- Failed to load SOCs: {getErrorMessage(socsQuery.error)} -

-
- ) - } - - const selectedSocIds = new Set(props.selectedSocs.map((soc) => soc.id)) - - const toggleSoc = (soc: Soc) => { - if (selectedSocIds.has(soc.id)) { - const newSelection = props.selectedSocs.filter((selected) => selected.id !== soc.id) - props.onSocsChange(newSelection) - } else { - const newSelection = [...props.selectedSocs, soc] - props.onSocsChange(newSelection) - } - } - - const toggleManufacturer = (manufacturer: string) => { - const newExpanded = new Set(expandedManufacturers) - if (newExpanded.has(manufacturer)) { - newExpanded.delete(manufacturer) - } else { - newExpanded.add(manufacturer) - } - setExpandedManufacturers(newExpanded) - } - - const selectAllFromManufacturer = (manufacturer: string) => { - const manufacturerSocs = socsByManufacturer[manufacturer] || [] - const allSelected = manufacturerSocs.every((soc) => selectedSocIds.has(soc.id)) - - if (allSelected) { - // Deselect all from this manufacturer - const remaining = props.selectedSocs.filter( - (selected) => !manufacturerSocs.some((soc) => soc.id === selected.id), - ) - props.onSocsChange(remaining) - } else { - // Select all from this manufacturer - const toAdd = manufacturerSocs.filter((soc) => !selectedSocIds.has(soc.id)) - props.onSocsChange([...props.selectedSocs, ...toAdd]) - } - } - - return ( -
- {/* Search */} -
- } - type="text" - placeholder="Search SOCs by name or manufacturer..." - value={searchTerm} - onChange={(e) => setSearchTerm(e.target.value)} - className="w-full" - /> -
- - {/* Selected SOCs Summary */} - {props.selectedSocs.length > 0 && ( - -
- - - Selected SOCs ({props.selectedSocs.length}) - -
-
- {props.selectedSocs.map((soc) => ( - toggleSoc(soc)} - className="inline-flex items-center gap-1 bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-900/50 text-blue-800 dark:text-blue-200 text-xs px-2 py-1 rounded-lg transition-colors" - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - {soc.manufacturer} {soc.name} - × - - ))} -
-
- )} - - {/* SOC List */} -
- {Object.entries(socsByManufacturer).map(([manufacturer, socs]) => { - const isExpanded = expandedManufacturers.has(manufacturer) - const allSelected = socs.every((soc) => selectedSocIds.has(soc.id)) - const someSelected = socs.some((soc) => selectedSocIds.has(soc.id)) - - return ( - - {/* Manufacturer Header */} -
-
- - -
-
- - {/* SOC List */} - - {isExpanded && ( - -
- {socs.map((soc) => { - const isSelected = selectedSocIds.has(soc.id) - - return ( - toggleSoc(soc)} - className={cn( - 'w-full flex items-center gap-3 p-3 rounded-lg transition-all text-left', - isSelected - ? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800' - : 'hover:bg-gray-50 dark:hover:bg-gray-800/50', - )} - whileHover={{ scale: 1.02 }} - whileTap={{ scale: 0.98 }} - > -
- {isSelected && } -
- -
-
- - {soc.name} - -
-
-
- ) - })} -
-
- )} -
-
- ) - })} -
- - {filteredSocs.length === 0 && ( -
- -

No SOCs found matching your search.

-
- )} -
- ) -} - -export default SocSelector diff --git a/src/app/profile/components/bookmarks/BookmarkBrowser.tsx b/src/app/profile/components/bookmarks/BookmarkBrowser.tsx new file mode 100644 index 000000000..e50ad3308 --- /dev/null +++ b/src/app/profile/components/bookmarks/BookmarkBrowser.tsx @@ -0,0 +1,59 @@ +'use client' + +import BookmarkBrowserView from '@/features/bookmarks/client/components/BookmarkBrowser' +import { useBookmarkBrowser } from '@/features/bookmarks/client/hooks/useBookmarkBrowser' +import AsyncCpuFilterSelect from '@/features/hardware/cpu/client/components/AsyncCpuFilterSelect' +import DeviceMultiSelect from '@/features/hardware/device/client/components/DeviceMultiSelect' +import AsyncGpuFilterSelect from '@/features/hardware/gpu/client/components/AsyncGpuFilterSelect' +import SocMultiSelect from '@/features/hardware/soc/client/components/SocMultiSelect' + +interface Props { + userId: string + handheldCount: number + pcCount: number + ownProfile?: boolean +} + +export default function BookmarkBrowser(props: Props) { + const browser = useBookmarkBrowser() + const filters = browser.filters + return ( + + {browser.variant === 'handheld' ? ( + <> + browser.changeFilters({ ...filters, deviceIds })} + maxDisplayed={1} + placeholder="All devices" + /> + browser.changeFilters({ ...filters, socIds })} + maxDisplayed={1} + placeholder="All SoCs" + /> + + ) : ( + <> + browser.changeFilters({ ...filters, cpuIds })} + maxDisplayed={1} + placeholder="All CPUs" + /> + browser.changeFilters({ ...filters, gpuIds })} + maxDisplayed={1} + placeholder="All GPUs" + /> + + )} + + ) +} diff --git a/src/app/profile/components/bookmarks/BookmarkGrid.tsx b/src/app/profile/components/bookmarks/BookmarkGrid.tsx deleted file mode 100644 index cd159069c..000000000 --- a/src/app/profile/components/bookmarks/BookmarkGrid.tsx +++ /dev/null @@ -1,92 +0,0 @@ -'use client' - -import { keepPreviousData } from '@tanstack/react-query' -import { LoadingSpinner, Pagination } from '@/components/ui' -import { api } from '@/lib/api' -import BookmarkCard from './BookmarkCard' -import SearchEmptyState from './SearchEmptyState' -import type { ReactNode } from 'react' - -interface Props { - variant: 'handheld' | 'pc' - userId: string - page: number - limit: number - search: string - onPageChange: (page: number) => void - emptyContent: ReactNode -} - -function BookmarkGrid(props: Props) { - const listingQuery = api.bookmarks.getListingBookmarks.useQuery( - { - userId: props.userId, - page: props.page, - limit: props.limit, - search: props.search || undefined, - }, - { placeholderData: keepPreviousData, enabled: props.variant === 'handheld' }, - ) - - const pcQuery = api.bookmarks.getPcListingBookmarks.useQuery( - { - userId: props.userId, - page: props.page, - limit: props.limit, - search: props.search || undefined, - }, - { placeholderData: keepPreviousData, enabled: props.variant === 'pc' }, - ) - - const query = props.variant === 'handheld' ? listingQuery : pcQuery - - if (query.isPending) { - return ( -
- -
- ) - } - - if (query.data?.visibility !== 'visible') { - return <>{props.emptyContent} - } - - if (query.data.items.length === 0) { - if (props.search) { - return - } - return <>{props.emptyContent} - } - - const renderItems = () => { - if (props.variant === 'handheld' && listingQuery.data?.visibility === 'visible') { - return listingQuery.data.items.map((item, index) => ( - - )) - } - if (props.variant === 'pc' && pcQuery.data?.visibility === 'visible') { - return pcQuery.data.items.map((item, index) => ( - - )) - } - return null - } - - return ( - <> -
{renderItems()}
- {query.data.pagination.pages > 1 && ( - - )} - - ) -} - -export default BookmarkGrid diff --git a/src/app/testing/page.test.tsx b/src/app/testing/page.test.tsx new file mode 100644 index 000000000..d847befe8 --- /dev/null +++ b/src/app/testing/page.test.tsx @@ -0,0 +1,96 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ConfirmDialogProvider } from '@/components/ui' +import TestingPage from './page' + +vi.mock('@clerk/nextjs', () => ({ + useAuth: () => ({ isSignedIn: false, signOut: vi.fn() }), + useSignIn: () => ({ signIn: null }), + useUser: () => ({ user: null }), +})) +vi.mock('./testScenarios', () => ({ + testScenarios: [ + { + id: 'user', + name: 'User', + icon: '', + color: '', + categories: [ + { + id: 'reports', + name: 'Reports', + scenarios: [{ id: 'create', description: 'Create a report' }], + }, + ], + }, + ], + testAccounts: { + user: { role: 'User', email: 'tester@example.invalid', password: 'fixture-only' }, + }, +})) +vi.mock('@/lib/toast', () => ({ default: { warning: vi.fn(), error: vi.fn() } })) + +const storage = new Map() +Object.defineProperty(window, 'localStorage', { + configurable: true, + value: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }, +}) +const key = 'emuready-test-results' +const saved = { + testerName: 'Tester', + selectedRole: 'user', + expandedCategories: ['user-reports'], + results: { user: { reports: { create: { checked: true, notes: 'Saved note' } } } }, +} + +beforeEach(() => storage.clear()) + +describe('test checklist persistence', () => { + it('loads saved progress without overwriting it on mount', () => { + const snapshot = JSON.stringify(saved) + storage.set(key, snapshot) + render() + expect(screen.getByPlaceholderText('Enter your name')).toHaveValue('Tester') + expect(screen.getByDisplayValue('Saved note')).toBeInTheDocument() + expect(storage.get(key)).toBe(snapshot) + }) + + it('persists edits and keeps existing report results', () => { + storage.set(key, JSON.stringify(saved)) + render() + fireEvent.change(screen.getByPlaceholderText('Enter your name'), { + target: { value: 'Updated' }, + }) + expect(JSON.parse(storage.get(key) ?? '{}')).toMatchObject({ ...saved, testerName: 'Updated' }) + }) + + it('requires confirmation before clearing progress', async () => { + storage.set(key, JSON.stringify(saved)) + render( + + + , + ) + fireEvent.click(screen.getByRole('button', { name: 'Clear All Progress' })) + expect(screen.getByRole('alertdialog')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.getByPlaceholderText('Enter your name')).toHaveValue('Tester') + fireEvent.click(screen.getByRole('button', { name: 'Clear All Progress' })) + fireEvent.click(screen.getByRole('button', { name: 'Clear Results' })) + await waitFor(() => expect(screen.getByPlaceholderText('Enter your name')).toHaveValue('')) + expect(JSON.parse(storage.get(key) ?? '{}')).toMatchObject({ results: {}, testerName: '' }) + }) + + it('falls back safely when stored data is invalid', () => { + storage.set(key, JSON.stringify({ results: 'invalid' })) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + render() + expect(screen.getByPlaceholderText('Enter your name')).toHaveValue('') + expect(screen.getByText('0 of 1 tests')).toBeInTheDocument() + error.mockRestore() + }) +}) diff --git a/src/app/testing/page.tsx b/src/app/testing/page.tsx index 701b3a4d6..9b791050d 100644 --- a/src/app/testing/page.tsx +++ b/src/app/testing/page.tsx @@ -14,95 +14,77 @@ import { LogIn, Shield, } from 'lucide-react' -import { useState, useEffect } from 'react' -import { Button, Card, Input, LoadingSpinner } from '@/components/ui' +import { useState } from 'react' +import { type z } from 'zod' +import { Button, Card, Input, LoadingSpinner, useConfirmDialog } from '@/components/ui' +import { useLocalStorage } from '@/hooks/useLocalStorage' +import useMounted from '@/hooks/useMounted' import toast from '@/lib/toast' import { cn } from '@/lib/utils' +import { TestingSessionSchema, type TestResultSchema } from '@/schemas/testing' import { testScenarios, testAccounts } from './testScenarios' -type TestResult = { - checked: boolean - notes?: string -} - -type TestResults = { - [roleId: string]: { - [categoryId: string]: { - [scenarioId: string]: TestResult - } - } -} +type TestResult = z.infer +type TestingSession = z.infer +const initialSession = TestingSessionSchema.parse({}) +const allowedHosts = ['localhost', '127.0.0.1', 'staging.emuready.com', 'dev.emuready.com'] export default function TestingPage() { - const [testerName, setTesterName] = useState('') - const [selectedRole, setSelectedRole] = useState('user') - const [testResults, setTestResults] = useState({}) - const [expandedCategories, setExpandedCategories] = useState>(new Set()) + const [session, setSession] = useLocalStorage( + 'emuready-test-results', + initialSession, + TestingSessionSchema, + ) + const { testerName, selectedRole, results: testResults } = session + const expandedCategories = new Set(session.expandedCategories) const [copiedEmail, setCopiedEmail] = useState(false) const [copiedPassword, setCopiedPassword] = useState(false) const [isSigningIn, setIsSigningIn] = useState(false) - const [isAllowed, setIsAllowed] = useState(null) - + const isMounted = useMounted() + const isAllowed = isMounted && allowedHosts.includes(window.location.hostname) + const confirm = useConfirmDialog() const { signOut, isSignedIn } = useAuth() const { signIn, setActive } = useSignIn() const { user } = useUser() - // Check if we're on an allowed environment - useEffect(() => { - if (typeof window !== 'undefined') { - const hostname = window.location.hostname - const allowedHosts = ['localhost', '127.0.0.1', 'staging.emuready.com', 'dev.emuready.com'] - setIsAllowed(allowedHosts.some((host) => hostname.includes(host))) - } - }, []) - - // Load saved data on mount - useEffect(() => { - const saved = localStorage.getItem('emuready-test-results') - if (saved) { - const parsed = JSON.parse(saved) - setTestResults(parsed.results || {}) - setTesterName(parsed.testerName || '') - setSelectedRole(parsed.selectedRole || 'user') - setExpandedCategories(new Set(parsed.expandedCategories || [])) - } - }, []) - - // Auto-save - useEffect(() => { - const data = { - testerName, - results: testResults, - selectedRole, - expandedCategories: Array.from(expandedCategories), - lastSaved: new Date().toISOString(), + function updateSession( + update: Partial | ((current: TestingSession) => Partial), + ) { + const lastSaved = new Date().toISOString() + try { + setSession((current) => ({ + ...current, + ...(typeof update === 'function' ? update(current) : update), + lastSaved, + })) + } catch (error) { + console.error('Failed to save test checklist:', error) } - localStorage.setItem('emuready-test-results', JSON.stringify(data)) - }, [testResults, testerName, selectedRole, expandedCategories]) + } - const toggleCategory = (categoryId: string) => { - const newExpanded = new Set(expandedCategories) - if (newExpanded.has(categoryId)) { - newExpanded.delete(categoryId) - } else { - newExpanded.add(categoryId) - } - setExpandedCategories(newExpanded) + function toggleCategory(categoryId: string) { + updateSession((current) => ({ + expandedCategories: current.expandedCategories.includes(categoryId) + ? current.expandedCategories.filter((id) => id !== categoryId) + : [...current.expandedCategories, categoryId], + })) } - const updateTestResult = ( + function updateTestResult( roleId: string, categoryId: string, scenarioId: string, result: TestResult, - ) => { - setTestResults((prev) => ({ - ...prev, - [roleId]: { - ...prev[roleId], - [categoryId]: { - ...(prev[roleId]?.[categoryId] || {}), - [scenarioId]: result, + ) { + updateSession((current) => ({ + results: { + ...current.results, + [roleId]: { + ...current.results[roleId], + [categoryId]: { + ...current.results[roleId]?.[categoryId], + [scenarioId]: result, + }, }, }, })) @@ -272,12 +254,14 @@ export default function TestingPage() { URL.revokeObjectURL(url) } - const clearAll = () => { - if (confirm('Are you sure you want to clear all test results? This cannot be undone.')) { - setTestResults({}) - setTesterName('') - localStorage.removeItem('emuready-test-results') - } + async function clearAll() { + const confirmed = await confirm({ + title: 'Clear all test results?', + description: 'This cannot be undone.', + confirmText: 'Clear Results', + confirmVariant: 'destructive', + }) + if (confirmed) updateSession({ results: {}, testerName: '' }) } const progress = getProgress() @@ -285,7 +269,7 @@ export default function TestingPage() { const currentAccount = testAccounts[selectedRole] // Show loading while checking environment - if (isAllowed === null) { + if (!isMounted) { return } @@ -341,7 +325,7 @@ export default function TestingPage() { setTesterName(e.target.value)} + onChange={(e) => updateSession({ testerName: e.target.value })} placeholder="Enter your name" />
@@ -376,7 +360,7 @@ export default function TestingPage() { return (
@@ -743,6 +737,7 @@ function UserDetailsPage() { {activeTab === 'bookmarks' && bookmarkCounts && ( diff --git a/src/app/users/[id]/components/UserBookmarksSection.tsx b/src/app/users/[id]/components/UserBookmarksSection.tsx index 8e3fb32cf..4c52ffd76 100644 --- a/src/app/users/[id]/components/UserBookmarksSection.tsx +++ b/src/app/users/[id]/components/UserBookmarksSection.tsx @@ -1,80 +1,14 @@ 'use client' -import { Monitor, Search, Smartphone } from 'lucide-react' -import { useState } from 'react' -import BookmarkGrid from '@/app/profile/components/bookmarks/BookmarkGrid' -import BookmarkSubTabButton from '@/app/profile/components/bookmarks/BookmarkSubTabButton' -import NoBookmarksMessage from '@/app/profile/components/bookmarks/NoBookmarksMessage' -import { Input } from '@/components/ui/form' -import { UI_CONSTANTS } from '@/data/constants' -import useDebouncedValue from '@/hooks/useDebouncedValue' - -type SubTab = 'handheld' | 'pc' +import BookmarkBrowser from '@/app/profile/components/bookmarks/BookmarkBrowser' interface Props { userId: string + ownProfile?: boolean handheldCount: number pcCount: number } -function UserBookmarksSection(props: Props) { - const [subTab, setSubTab] = useState('handheld') - const [page, setPage] = useState(1) - const [search, setSearch] = useState('') - const debouncedSearch = useDebouncedValue(search, UI_CONSTANTS.DEBOUNCE_DELAY) - const limit = 12 - - const handleSubTabChange = (tab: SubTab) => { - setSubTab(tab) - setPage(1) - setSearch('') - } - - return ( -
-
-
- } - label="Handheld" - onClick={() => handleSubTabChange('handheld')} - /> - } - label="PC" - onClick={() => handleSubTabChange('pc')} - /> -
- -
- - { - setSearch(e.target.value) - setPage(1) - }} - className="pl-9" - /> -
-
- - } - /> -
- ) +export default function UserBookmarksSection(props: Props) { + return } - -export default UserBookmarksSection diff --git a/src/components/CookieConsent.test.tsx b/src/components/CookieConsent.test.tsx new file mode 100644 index 000000000..fd7747a40 --- /dev/null +++ b/src/components/CookieConsent.test.tsx @@ -0,0 +1,87 @@ +import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import storageKeys from '@/data/storageKeys' +import { useLocalStorage } from '@/hooks/useLocalStorage' +import toast from '@/lib/toast' +import { BooleanPreferenceSchema } from '@/schemas/preferences' +import CookieConsent from './CookieConsent' + +vi.mock('@/lib/toast', () => ({ default: { warning: vi.fn() } })) +const storage = new Map() +Object.defineProperty(window, 'localStorage', { + configurable: true, + value: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }, +}) + +beforeEach(() => { + vi.clearAllMocks() + storage.clear() + vi.useFakeTimers() +}) +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +describe('cookie banner storage updates', () => { + it('offers a new save when preferences were persisted without the consent marker', () => { + storage.set( + storageKeys.cookies.preferences, + JSON.stringify({ necessary: true, analytics: false, performance: false }), + ) + render() + act(() => vi.advanceTimersByTime(2000)) + expect(screen.getByRole('heading', { name: 'Cookie Preferences' })).toBeInTheDocument() + }) + + it.each(Object.values(storageKeys.cookies))( + 'keeps retry controls available when saving %s fails', + (failedKey) => { + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const writer = vi.spyOn(window.localStorage, 'setItem').mockImplementation((key, value) => { + if (key === failedKey) throw new Error('Storage blocked') + storage.set(key, value) + }) + try { + render() + act(() => vi.advanceTimersByTime(2000)) + fireEvent.click(screen.getByRole('button', { name: 'Necessary Only' })) + expect(screen.getByRole('alert')).toHaveTextContent('Please try again') + expect(toast.warning).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: 'Necessary Only' })).toBeInTheDocument() + writer.mockRestore() + fireEvent.click(screen.getByRole('button', { name: 'Necessary Only' })) + expect( + screen.queryByRole('heading', { name: 'Cookie Preferences' }), + ).not.toBeInTheDocument() + expect(storage.get(storageKeys.cookies.consent)).toBe('true') + } finally { + writer.mockRestore() + warning.mockRestore() + } + }, + ) + + it('shows after its delay and closes when consent is saved elsewhere', () => { + const writer = renderHook(() => + useLocalStorage(storageKeys.cookies.consent, false, BooleanPreferenceSchema), + ) + render() + expect(screen.queryByRole('heading', { name: 'Cookie Preferences' })).not.toBeInTheDocument() + act(() => vi.advanceTimersByTime(2000)) + expect(screen.getByRole('heading', { name: 'Cookie Preferences' })).toBeInTheDocument() + act(() => writer.result.current[1](true)) + expect(screen.queryByRole('heading', { name: 'Cookie Preferences' })).not.toBeInTheDocument() + }) + + it('does not reopen for a saved consent decision', () => { + storage.set(storageKeys.cookies.consent, 'true') + render() + act(() => vi.advanceTimersByTime(2500)) + expect(screen.queryByRole('heading', { name: 'Cookie Preferences' })).not.toBeInTheDocument() + }) +}) diff --git a/src/components/CookieConsent.tsx b/src/components/CookieConsent.tsx index 17729ff5d..0d0345378 100644 --- a/src/components/CookieConsent.tsx +++ b/src/components/CookieConsent.tsx @@ -3,18 +3,21 @@ import { motion, AnimatePresence } from 'framer-motion' import { X, Settings, Shield, BarChart3, Target, Cookie, Check } from 'lucide-react' import { useEffect, useState } from 'react' +import { type z } from 'zod' import { Button } from '@/components/ui' import storageKeys from '@/data/storageKeys' import { useLocalStorage } from '@/hooks/useLocalStorage' import { cn } from '@/lib/utils' +import { + SavedCookiePreferencesSchema, + type CookiePreferencesSchema, + BooleanPreferenceSchema, + NullableStringPreferenceSchema, +} from '@/schemas/preferences' type CookieCategory = 'necessary' | 'analytics' | 'performance' -interface CookiePreferences { - necessary: boolean - analytics: boolean - performance: boolean -} +type CookiePreferences = z.output const DEFAULT_PREFERENCES: CookiePreferences = { necessary: true, // Always required @@ -70,58 +73,55 @@ const colorClasses = { function CookieConsent() { const [isVisible, setIsVisible] = useState(false) const [showDetails, setShowDetails] = useState(false) + const [saveError, setSaveError] = useState(null) const [savedPreferences, setSavedPreferences] = useLocalStorage( storageKeys.cookies.preferences, null, + SavedCookiePreferencesSchema, ) const [hasConsented, setHasConsented] = useLocalStorage( storageKeys.cookies.consent, false, + BooleanPreferenceSchema, + ) + const [, setConsentDate] = useLocalStorage( + storageKeys.cookies.consentDate, + null, + NullableStringPreferenceSchema, ) - const [, setConsentDate] = useLocalStorage(storageKeys.cookies.consentDate, null) const [, setAnalyticsEnabled] = useLocalStorage( storageKeys.cookies.analyticsEnabled, false, + BooleanPreferenceSchema, ) const [, setPerformanceEnabled] = useLocalStorage( storageKeys.cookies.performanceEnabled, false, + BooleanPreferenceSchema, ) - const [preferences, setPreferences] = useState( - savedPreferences || DEFAULT_PREFERENCES, - ) - const [hasInteracted, setHasInteracted] = useState(false) + const [draftPreferences, setDraftPreferences] = useState(null) + const preferences = draftPreferences ?? savedPreferences ?? DEFAULT_PREFERENCES useEffect(() => { - // Check if user has already made a choice - if (savedPreferences) { - setPreferences(savedPreferences) - setHasInteracted(true) - } - - // Show banner if user hasn't consented yet - if (!hasConsented && !hasInteracted) { - // Small delay to avoid being too aggressive - const timer = setTimeout(() => { - setIsVisible(true) - }, 2000) - + if (!hasConsented) { + const timer = setTimeout(() => setIsVisible(true), 2000) return () => clearTimeout(timer) } - }, [hasInteracted, savedPreferences, hasConsented]) + }, [hasConsented]) const savePreferences = (newPreferences: CookiePreferences) => { - setSavedPreferences(newPreferences) - setHasConsented(true) - setConsentDate(new Date().toISOString()) - - // Apply preferences immediately - setAnalyticsEnabled(newPreferences.analytics) - setPerformanceEnabled(newPreferences.performance) - - setPreferences(newPreferences) - setIsVisible(false) - setHasInteracted(true) + setDraftPreferences(newPreferences) + setSaveError(null) + try { + setSavedPreferences(newPreferences, { notifyOnError: false }) + setConsentDate(new Date().toISOString(), { notifyOnError: false }) + setAnalyticsEnabled(newPreferences.analytics, { notifyOnError: false }) + setPerformanceEnabled(newPreferences.performance, { notifyOnError: false }) + setHasConsented(true, { notifyOnError: false }) + setIsVisible(false) + } catch { + setSaveError('Your cookie preferences could not be saved. Please try again.') + } } const handleAcceptAll = () => { @@ -136,13 +136,13 @@ function CookieConsent() { const handleCategoryToggle = (category: CookieCategory) => { if (category === 'necessary') return // Cannot disable necessary cookies - setPreferences((prev) => ({ - ...prev, - [category]: !prev[category], - })) + setDraftPreferences((previous) => { + const current = previous ?? preferences + return { ...current, [category]: !current[category] } + }) } - if (!isVisible) return null + if (!isVisible || (hasConsented && !saveError)) return null return ( @@ -212,6 +212,11 @@ function CookieConsent() {
+ {saveError && ( +

+ {saveError} +

+ )} {/* Content */} {!showDetails ? ( diff --git a/src/components/footer/Footer.tsx b/src/components/footer/Footer.tsx index 7f213c1aa..070f9b281 100644 --- a/src/components/footer/Footer.tsx +++ b/src/components/footer/Footer.tsx @@ -1,22 +1,20 @@ 'use client' import Link from 'next/link' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { FooterAppLinks } from '@/components/footer/components/FooterAppLinks' import { FooterKofiButton } from '@/components/footer/components/FooterKofiButton' import { FooterLink } from '@/components/footer/components/FooterLink' import { FooterPatreonButton } from '@/components/footer/components/FooterPatreonButton' import { GitHubIcon } from '@/components/icons' import { ThemeSelect } from '@/components/ui' +import useMounted from '@/hooks/useMounted' import analytics from '@/lib/analytics' import { env } from '@/lib/env' function Footer() { - const [copyrightYear, setCopyrightYear] = useState(null) - - useEffect(() => { - setCopyrightYear(new Date().getUTCFullYear()) - }, []) + const mounted = useMounted() + const [copyrightYear] = useState(() => new Date().getUTCFullYear()) return (