diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 58e747ab..f1174a10 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -16,9 +16,11 @@ dependencies { implementation project(':capacitor-browser') implementation project(':capacitor-clipboard') implementation project(':capacitor-preferences') + implementation project(':capacitor-share') implementation project(':capacitor-status-bar') implementation project(':capawesome-team-capacitor-nfc') implementation project(':capawesome-capacitor-android-edge-to-edge-support') + implementation project(':capgo-capacitor-shake') implementation project(':revenuecat-purchases-capacitor') } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9484b1aa..f7bd9d01 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -46,11 +46,20 @@ - - + + + + + + + + + + + diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 2154feab..143aa9a6 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -23,14 +23,20 @@ project(':capacitor-clipboard').projectDir = new File('../node_modules/.pnpm/@ca include ':capacitor-preferences' project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@capacitor+preferences@7.0.1_@capacitor+core@7.4.1/node_modules/@capacitor/preferences/android') +include ':capacitor-share' +project(':capacitor-share').projectDir = new File('../node_modules/.pnpm/@capacitor+share@7.0.2_@capacitor+core@7.4.1/node_modules/@capacitor/share/android') + include ':capacitor-status-bar' project(':capacitor-status-bar').projectDir = new File('../node_modules/.pnpm/@capacitor+status-bar@7.0.1_@capacitor+core@7.4.1/node_modules/@capacitor/status-bar/android') include ':capawesome-team-capacitor-nfc' -project(':capawesome-team-capacitor-nfc').projectDir = new File('../node_modules/.pnpm/@capawesome-team+capacitor-nfc@7.2.0_@capacitor+core@7.4.1/node_modules/@capawesome-team/capacitor-nfc/android') +project(':capawesome-team-capacitor-nfc').projectDir = new File('../node_modules/.pnpm/@capawesome-team+capacitor-nfc@7.3.0_@capacitor+core@7.4.1/node_modules/@capawesome-team/capacitor-nfc/android') include ':capawesome-capacitor-android-edge-to-edge-support' project(':capawesome-capacitor-android-edge-to-edge-support').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-android-edge-to-edge-support@7.2.3_@capacitor+core@7.4.1/node_modules/@capawesome/capacitor-android-edge-to-edge-support/android') +include ':capgo-capacitor-shake' +project(':capgo-capacitor-shake').projectDir = new File('../node_modules/.pnpm/@capgo+capacitor-shake@7.2.14_@capacitor+core@7.4.1/node_modules/@capgo/capacitor-shake/android') + include ':revenuecat-purchases-capacitor' project(':revenuecat-purchases-capacitor').projectDir = new File('../node_modules/.pnpm/@revenuecat+purchases-capacitor@10.3.7_@capacitor+core@7.4.1/node_modules/@revenuecat/purchases-capacitor/android') diff --git a/capacitor.config.ts b/capacitor.config.ts index efa68964..eecb55b0 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -10,11 +10,10 @@ const config: CapacitorConfig = { appName: "Zaparoo", backgroundColor: "#111928", server: { - // url: `http://${process.env.DEV_SERVER_IP}:8100`, - // url: - // process.env.NODE_ENV === "development" && process.env.DEV_SERVER_IP - // ? `http://${process.env.DEV_SERVER_IP}:8100` - // : undefined, + url: + process.env.NODE_ENV === "development" && process.env.DEV_SERVER_IP + ? `http://${process.env.DEV_SERVER_IP}:8100` + : undefined, androidScheme: "http", cleartext: true }, @@ -30,7 +29,7 @@ const config: CapacitorConfig = { providers: ["google.com"] }, EdgeToEdge: { - backgroundColor: '#111928' + backgroundColor: "#111928" } } }; diff --git a/index.html b/index.html index 5d830a7f..7b8e5df2 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + Zaparoo diff --git a/ios/App/Podfile b/ios/App/Podfile index bda74eb8..3d365d1a 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -18,8 +18,10 @@ def capacitor_pods pod 'CapacitorBrowser', :path => '../../node_modules/.pnpm/@capacitor+browser@7.0.1_@capacitor+core@7.4.1/node_modules/@capacitor/browser' pod 'CapacitorClipboard', :path => '../../node_modules/.pnpm/@capacitor+clipboard@7.0.1_@capacitor+core@7.4.1/node_modules/@capacitor/clipboard' pod 'CapacitorPreferences', :path => '../../node_modules/.pnpm/@capacitor+preferences@7.0.1_@capacitor+core@7.4.1/node_modules/@capacitor/preferences' + pod 'CapacitorShare', :path => '../../node_modules/.pnpm/@capacitor+share@7.0.2_@capacitor+core@7.4.1/node_modules/@capacitor/share' pod 'CapacitorStatusBar', :path => '../../node_modules/.pnpm/@capacitor+status-bar@7.0.1_@capacitor+core@7.4.1/node_modules/@capacitor/status-bar' - pod 'CapawesomeTeamCapacitorNfc', :path => '../../node_modules/.pnpm/@capawesome-team+capacitor-nfc@7.2.0_@capacitor+core@7.4.1/node_modules/@capawesome-team/capacitor-nfc' + pod 'CapawesomeTeamCapacitorNfc', :path => '../../node_modules/.pnpm/@capawesome-team+capacitor-nfc@7.3.0_@capacitor+core@7.4.1/node_modules/@capawesome-team/capacitor-nfc' + pod 'CapgoCapacitorShake', :path => '../../node_modules/.pnpm/@capgo+capacitor-shake@7.2.14_@capacitor+core@7.4.1/node_modules/@capgo/capacitor-shake' pod 'RevenuecatPurchasesCapacitor', :path => '../../node_modules/.pnpm/@revenuecat+purchases-capacitor@10.3.7_@capacitor+core@7.4.1/node_modules/@revenuecat/purchases-capacitor' end diff --git a/package.json b/package.json index a0afd02f..6531aaf0 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,10 @@ "type": "module", "scripts": { "dev": "vite", + "dev:server": "cross-env NODE_ENV=development vite", "build": "tsc --noEmit -p tsconfig.build.json && vite build && pnpm exec cap sync", + "build:server": "tsc --noEmit -p tsconfig.build.json && vite build && cross-env NODE_ENV=development cap sync", + "build:analyze": "tsc --noEmit -p tsconfig.build.json && vite build --mode analyze", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 3", "eslist": "pnpm run lint", "preview": "vite preview", @@ -25,9 +28,12 @@ "@capacitor/core": "^7.4.1", "@capacitor/ios": "^7.4.1", "@capacitor/preferences": "^7.0.1", + "@capacitor/share": "^7.0.2", "@capacitor/status-bar": "^7.0.1", - "@capawesome-team/capacitor-nfc": "7.2.0", + "@capawesome-team/capacitor-nfc": "7.3.0", "@capawesome/capacitor-android-edge-to-edge-support": "^7.2.3", + "@capgo/capacitor-shake": "^7.2.14", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-slot": "^1.2.3", @@ -37,8 +43,9 @@ "@tailwindcss/vite": "^4.1.11", "@tanstack/react-query": "^5.81.5", "@tanstack/react-router": "^1.125.6", + "@tanstack/react-virtual": "^3.13.12", "@uidotdev/usehooks": "^2.4.1", - "axios": "1.10.0", + "axios": "1.12.0", "class-variance-authority": "^0.7.1", "classnames": "^2.5.1", "clsx": "^2.1.1", @@ -46,13 +53,13 @@ "firebase": "^11.10.0", "i18next": "^25.3.1", "i18next-browser-languagedetector": "^8.2.0", - "lodash": "^4.17.21", "lucide-react": "^0.525.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-hot-toast": "^2.5.2", "react-i18next": "^15.6.0", "react-swipeable": "^7.0.2", + "shepherd.js": "^14.5.1", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "use-debounce": "^10.0.5", @@ -70,7 +77,6 @@ "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "@types/lodash": "^4.17.20", "@types/node": "^24.0.11", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", @@ -81,6 +87,7 @@ "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", "autoprefixer": "^10.4.21", + "cross-env": "^10.1.0", "eslint": "^9.34.0", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import-x": "^4.16.1", @@ -94,11 +101,13 @@ "postcss": "^8.5.6", "prettier": "^3.6.2", "prettier-plugin-tailwindcss": "^0.6.13", + "rollup-plugin-visualizer": "^5.12.0", "tailwindcss": "^4.1.11", "tdd-guard-vitest": "^0.1.5", + "terser": "^5.44.1", "tw-animate-css": "^1.3.5", "typescript": "^5.8.3", - "vite": "7.0.3", + "vite": "7.1.11", "vitest": "^3.2.4" }, "pnpm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5e32424..b324612e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,15 +38,24 @@ importers: '@capacitor/preferences': specifier: ^7.0.1 version: 7.0.1(@capacitor/core@7.4.1) + '@capacitor/share': + specifier: ^7.0.2 + version: 7.0.2(@capacitor/core@7.4.1) '@capacitor/status-bar': specifier: ^7.0.1 version: 7.0.1(@capacitor/core@7.4.1) '@capawesome-team/capacitor-nfc': - specifier: 7.2.0 - version: 7.2.0(@capacitor/core@7.4.1) + specifier: 7.3.0 + version: 7.3.0(@capacitor/core@7.4.1) '@capawesome/capacitor-android-edge-to-edge-support': specifier: ^7.2.3 version: 7.2.3(@capacitor/core@7.4.1) + '@capgo/capacitor-shake': + specifier: ^7.2.14 + version: 7.2.14(@capacitor/core@7.4.1) + '@radix-ui/react-accordion': + specifier: ^1.2.12 + version: 1.2.12(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-dialog': specifier: ^1.1.14 version: 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -67,19 +76,22 @@ importers: version: 4.1.11 '@tailwindcss/vite': specifier: ^4.1.11 - version: 4.1.11(vite@7.0.3(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0)) + version: 4.1.11(vite@7.1.11(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0)) '@tanstack/react-query': specifier: ^5.81.5 version: 5.81.5(react@19.1.0) '@tanstack/react-router': specifier: ^1.125.6 version: 1.125.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/react-virtual': + specifier: ^3.13.12 + version: 3.13.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@uidotdev/usehooks': specifier: ^2.4.1 version: 2.4.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) axios: - specifier: 1.10.0 - version: 1.10.0 + specifier: 1.12.0 + version: 1.12.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -101,9 +113,6 @@ importers: i18next-browser-languagedetector: specifier: ^8.2.0 version: 8.2.0 - lodash: - specifier: ^4.17.21 - version: 4.17.21 lucide-react: specifier: ^0.525.0 version: 0.525.0(react@19.1.0) @@ -122,6 +131,9 @@ importers: react-swipeable: specifier: ^7.0.2 version: 7.0.2(react@19.1.0) + shepherd.js: + specifier: ^14.5.1 + version: 14.5.1 tailwind-merge: specifier: ^3.3.1 version: 3.3.1 @@ -158,7 +170,7 @@ importers: version: 5.81.2(eslint@9.34.0(jiti@2.4.2))(typescript@5.8.3) '@tanstack/router-vite-plugin': specifier: ^1.125.6 - version: 1.125.6(@tanstack/react-router@1.125.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.0.3(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0)) + version: 1.125.6(@tanstack/react-router@1.125.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.1.11(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0)) '@testing-library/jest-dom': specifier: ^6.8.0 version: 6.8.0 @@ -168,9 +180,6 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) - '@types/lodash': - specifier: ^4.17.20 - version: 4.17.20 '@types/node': specifier: ^24.0.11 version: 24.0.11 @@ -191,7 +200,7 @@ importers: version: 8.36.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.8.3) '@vitejs/plugin-react': specifier: ^4.6.0 - version: 4.6.0(vite@7.0.3(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0)) + version: 4.6.0(vite@7.1.11(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0)) '@vitest/coverage-v8': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4) @@ -201,6 +210,9 @@ importers: autoprefixer: specifier: ^10.4.21 version: 10.4.21(postcss@8.5.6) + cross-env: + specifier: ^10.1.0 + version: 10.1.0 eslint: specifier: ^9.34.0 version: 9.34.0(jiti@2.4.2) @@ -240,12 +252,18 @@ importers: prettier-plugin-tailwindcss: specifier: ^0.6.13 version: 0.6.13(prettier@3.6.2) + rollup-plugin-visualizer: + specifier: ^5.12.0 + version: 5.14.0(rollup@4.44.2) tailwindcss: specifier: ^4.1.11 version: 4.1.11 tdd-guard-vitest: specifier: ^0.1.5 version: 0.1.5(vitest@3.2.4) + terser: + specifier: ^5.44.1 + version: 5.44.1 tw-animate-css: specifier: ^1.3.5 version: 1.3.5 @@ -253,11 +271,11 @@ importers: specifier: ^5.8.3 version: 5.8.3 vite: - specifier: 7.0.3 - version: 7.0.3(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0) + specifier: 7.1.11 + version: 7.1.11(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@24.0.11)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.4.2)(lightningcss@1.30.1)(msw@2.11.0(@types/node@24.0.11)(typescript@5.8.3))(tsx@4.20.3)(yaml@2.8.0) + version: 3.2.4(@types/node@24.0.11)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.4.2)(lightningcss@1.30.1)(msw@2.11.0(@types/node@24.0.11)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0) packages: @@ -506,13 +524,18 @@ packages: peerDependencies: '@capacitor/core': '>=7.0.0' + '@capacitor/share@7.0.2': + resolution: {integrity: sha512-VyNPo/9831xnL17IMDeft5yNdBjoKNb451P95sRcr69hulRDqHc+kndqOVaMXnaA6IyBdWnnFv/n1HUf4cXpGw==} + peerDependencies: + '@capacitor/core': '>=7.0.0' + '@capacitor/status-bar@7.0.1': resolution: {integrity: sha512-iDv3mXYo9CdxYRVwt3/pRyuk25p7Sn4GfaS/zMZyVIqTzsvKLCIIH3GdKK+ta+nsNcAVpCw/t5jFEBt1D18ctA==} peerDependencies: '@capacitor/core': '>=7.0.0' - '@capawesome-team/capacitor-nfc@7.2.0': - resolution: {integrity: sha512-I8fCz/bX3dz3C69l4chzFaBTjddbDudCaxssegcq13cPaVh35c5KBgieY3us9TjfQ74uGN/mONxzCJZod1BYtg==, tarball: https://npm.pkg.github.com/download/@capawesome-team/capacitor-nfc/7.2.0/5aca8e942d29a4ba1a32351b4c374c0bbfc233fc} + '@capawesome-team/capacitor-nfc@7.3.0': + resolution: {integrity: sha512-JdiGf7mzKm6HHKOrL3No3aoCyv4LCZjCZB8ECCtxKf+Jhn1Zs/DMCjzVXYikQN4CF3qFWJyUYm7+KKOPQHIVsQ==, tarball: https://npm.pkg.github.com/download/@capawesome-team/capacitor-nfc/7.3.0/58d8ceeb088cb31db9da2981c1a404ada866d1e7} peerDependencies: '@capacitor/core': '>=7.0.0' @@ -521,6 +544,11 @@ packages: peerDependencies: '@capacitor/core': '>=7.0.0' + '@capgo/capacitor-shake@7.2.14': + resolution: {integrity: sha512-9d/mr7+DVVqZO2d03q4WzFQivLSuT+ZFpN4Fhn58uZzc6JNA+pBoglexXXMMDFEhzsmwr0K2VnfCmq7tuGDgrw==} + peerDependencies: + '@capacitor/core': '>=7.0.0' + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -534,6 +562,9 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild/aix-ppc64@0.25.6': resolution: {integrity: sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw==} engines: {node: '>=18'} @@ -942,6 +973,15 @@ packages: '@firebase/webchannel-wrapper@1.0.3': resolution: {integrity: sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==} + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@grpc/grpc-js@1.9.15': resolution: {integrity: sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==} engines: {node: ^8.13.0 || >=10.10.0} @@ -1142,6 +1182,9 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + '@jridgewell/sourcemap-codec@1.5.4': resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==} @@ -1235,6 +1278,32 @@ packages: '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/react-accordion@1.2.12': + resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.7': resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} peerDependencies: @@ -1588,6 +1657,9 @@ packages: cpu: [x64] os: [win32] + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@tailwindcss/node@4.1.11': resolution: {integrity: sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==} @@ -1711,6 +1783,12 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-virtual@3.13.12': + resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/router-core@1.125.4': resolution: {integrity: sha512-tdgGI0Kwt3Lgs9ceLbG32NPh4I2H1T9t2TKjcS+I78sifm5rjTWV8lfqVRNrvMPk5ek60vXPOL2AHAUg6ohwsA==} engines: {node: '>=12'} @@ -1751,6 +1829,9 @@ packages: '@tanstack/store@0.7.2': resolution: {integrity: sha512-RP80Z30BYiPX2Pyo0Nyw4s1SJFH2jyM6f9i3HfX4pA+gm5jsnYryscdq2aIQLnL4TaGuQMO+zXmN9nh1Qck+Pg==} + '@tanstack/virtual-core@3.13.12': + resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==} + '@tanstack/virtual-file-routes@1.121.21': resolution: {integrity: sha512-3nuYsTyaq6ZN7jRZ9z6Gj3GXZqBOqOT0yzd/WZ33ZFfv4yVNIvsa5Lw+M1j3sgyEAxKMqGu/FaNi7FCjr3yOdw==} engines: {node: '>=12'} @@ -1838,9 +1919,6 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/lodash@4.17.20': - resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} - '@types/minimist@1.2.5': resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} @@ -2265,8 +2343,8 @@ packages: resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==} engines: {node: '>=4'} - axios@1.10.0: - resolution: {integrity: sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==} + axios@1.12.0: + resolution: {integrity: sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -2357,6 +2435,9 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -2481,6 +2562,9 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + commander@8.3.0: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} @@ -2577,6 +2661,11 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2661,6 +2750,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -2989,6 +3082,15 @@ packages: picomatch: optional: true + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} @@ -3039,8 +3141,8 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - form-data@4.0.3: - resolution: {integrity: sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==} + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} formidable@3.5.4: @@ -4095,6 +4197,10 @@ packages: resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} engines: {node: '>=12'} + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} @@ -4418,6 +4524,19 @@ packages: engines: {node: 20 || >=22} hasBin: true + rollup-plugin-visualizer@5.14.0: + resolution: {integrity: sha512-VlDXneTDaKsHIw8yzJAFWtrzguoJ/LnQ+lMpoVfYJ3jJF4Ihe5oYLAqLklIK/35lgUY+1yEzCkHyZ1j4A5w5fA==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + rolldown: 1.x + rollup: 2.x || 3.x || 4.x + peerDependenciesMeta: + rolldown: + optional: true + rollup: + optional: true + rollup@4.44.2: resolution: {integrity: sha512-PVoapzTwSEcelaWGth3uR66u7ZRo6qhPHc0f2uRO9fX6XDVNrIiGYS0Pj9+R8yIIYSD/mCx2b16Ws9itljKSPg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -4493,6 +4612,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shepherd.js@14.5.1: + resolution: {integrity: sha512-VuvPvLG1QjNOLP7AIm2HGyfmxEIz8QdskvWOHwUcxLDibYWjLRBmCWd8LSL5FlwhBW7D/GU+3gNVC/ASxAWdxg==} + engines: {node: 18.* || >= 20} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -4550,6 +4673,9 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -4738,6 +4864,11 @@ packages: resolution: {integrity: sha512-biM9brNqxSc04Ee71hzFbryD11nX7VPhQQY32AdDmjFvodsRFz/3ufeoTZ6uYkRFfGo188tENcASNs3vTdsM0w==} engines: {node: '>=10'} + terser@5.44.1: + resolution: {integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==} + engines: {node: '>=10'} + hasBin: true + test-exclude@7.0.1: resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} engines: {node: '>=18'} @@ -4774,6 +4905,10 @@ packages: resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + tinypool@1.1.1: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -5012,8 +5147,8 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite@7.0.3: - resolution: {integrity: sha512-y2L5oJZF7bj4c0jgGYgBNSdIu+5HF+m68rn2cQXFbGoShdhV1phX9rbnxy9YXj82aS8MMsCLAAFkRxZeWdldrQ==} + vite@7.1.11: + resolution: {integrity: sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -5624,11 +5759,15 @@ snapshots: dependencies: '@capacitor/core': 7.4.1 + '@capacitor/share@7.0.2(@capacitor/core@7.4.1)': + dependencies: + '@capacitor/core': 7.4.1 + '@capacitor/status-bar@7.0.1(@capacitor/core@7.4.1)': dependencies: '@capacitor/core': 7.4.1 - '@capawesome-team/capacitor-nfc@7.2.0(@capacitor/core@7.4.1)': + '@capawesome-team/capacitor-nfc@7.3.0(@capacitor/core@7.4.1)': dependencies: '@capacitor/core': 7.4.1 @@ -5636,6 +5775,10 @@ snapshots: dependencies: '@capacitor/core': 7.4.1 + '@capgo/capacitor-shake@7.2.14(@capacitor/core@7.4.1)': + dependencies: + '@capacitor/core': 7.4.1 + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -5656,6 +5799,8 @@ snapshots: tslib: 2.8.1 optional: true + '@epic-web/invariant@1.0.0': {} + '@esbuild/aix-ppc64@0.25.6': optional: true @@ -6098,6 +6243,17 @@ snapshots: '@firebase/webchannel-wrapper@1.0.3': {} + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.4': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/utils@0.2.10': {} + '@grpc/grpc-js@1.9.15': dependencies: '@grpc/proto-loader': 0.7.15 @@ -6343,6 +6499,11 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.12 + '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/sourcemap-codec@1.5.4': {} '@jridgewell/trace-mapping@0.3.29': @@ -6440,6 +6601,39 @@ snapshots: '@radix-ui/primitive@1.1.3': {} + '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) @@ -6720,6 +6914,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.44.2': optional: true + '@scarf/scarf@1.4.0': {} + '@tailwindcss/node@4.1.11': dependencies: '@ampproject/remapping': 2.3.0 @@ -6792,12 +6988,12 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.11 - '@tailwindcss/vite@4.1.11(vite@7.0.3(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0))': + '@tailwindcss/vite@4.1.11(vite@7.1.11(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0))': dependencies: '@tailwindcss/node': 4.1.11 '@tailwindcss/oxide': 4.1.11 tailwindcss: 4.1.11 - vite: 7.0.3(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0) + vite: 7.1.11(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0) '@tanstack/eslint-plugin-query@5.81.2(eslint@9.34.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: @@ -6835,6 +7031,12 @@ snapshots: react-dom: 19.1.0(react@19.1.0) use-sync-external-store: 1.5.0(react@19.1.0) + '@tanstack/react-virtual@3.13.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/virtual-core': 3.13.12 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + '@tanstack/router-core@1.125.4': dependencies: '@tanstack/history': 1.121.34 @@ -6857,7 +7059,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.125.6(@tanstack/react-router@1.125.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.0.3(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0))': + '@tanstack/router-plugin@1.125.6(@tanstack/react-router@1.125.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.1.11(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0))': dependencies: '@babel/core': 7.28.0 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.0) @@ -6875,7 +7077,7 @@ snapshots: zod: 3.25.76 optionalDependencies: '@tanstack/react-router': 1.125.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - vite: 7.0.3(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0) + vite: 7.1.11(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0) transitivePeerDependencies: - supports-color @@ -6890,9 +7092,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-vite-plugin@1.125.6(@tanstack/react-router@1.125.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.0.3(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0))': + '@tanstack/router-vite-plugin@1.125.6(@tanstack/react-router@1.125.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.1.11(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0))': dependencies: - '@tanstack/router-plugin': 1.125.6(@tanstack/react-router@1.125.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.0.3(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0)) + '@tanstack/router-plugin': 1.125.6(@tanstack/react-router@1.125.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.1.11(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0)) transitivePeerDependencies: - '@rsbuild/core' - '@tanstack/react-router' @@ -6903,6 +7105,8 @@ snapshots: '@tanstack/store@0.7.2': {} + '@tanstack/virtual-core@3.13.12': {} + '@tanstack/virtual-file-routes@1.121.21': {} '@testing-library/dom@10.4.1': @@ -7027,8 +7231,6 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/lodash@4.17.20': {} - '@types/minimist@1.2.5': {} '@types/node@20.19.11': @@ -7215,7 +7417,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitejs/plugin-react@4.6.0(vite@7.0.3(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0))': + '@vitejs/plugin-react@4.6.0(vite@7.1.11(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0))': dependencies: '@babel/core': 7.28.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.0) @@ -7223,7 +7425,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.19 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.0.3(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0) + vite: 7.1.11(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0) transitivePeerDependencies: - supports-color @@ -7242,7 +7444,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.0.11)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.4.2)(lightningcss@1.30.1)(msw@2.11.0(@types/node@24.0.11)(typescript@5.8.3))(tsx@4.20.3)(yaml@2.8.0) + vitest: 3.2.4(@types/node@24.0.11)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.4.2)(lightningcss@1.30.1)(msw@2.11.0(@types/node@24.0.11)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0) transitivePeerDependencies: - supports-color @@ -7254,14 +7456,14 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(msw@2.11.0(@types/node@24.0.11)(typescript@5.8.3))(vite@7.0.3(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0))': + '@vitest/mocker@3.2.4(msw@2.11.0(@types/node@24.0.11)(typescript@5.8.3))(vite@7.1.11(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: msw: 2.11.0(@types/node@24.0.11)(typescript@5.8.3) - vite: 7.0.3(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0) + vite: 7.1.11(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -7292,7 +7494,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.0.11)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.4.2)(lightningcss@1.30.1)(msw@2.11.0(@types/node@24.0.11)(typescript@5.8.3))(tsx@4.20.3)(yaml@2.8.0) + vitest: 3.2.4(@types/node@24.0.11)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.4.2)(lightningcss@1.30.1)(msw@2.11.0(@types/node@24.0.11)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: @@ -7481,10 +7683,10 @@ snapshots: axe-core@4.10.3: {} - axios@1.10.0: + axios@1.12.0: dependencies: follow-redirects: 1.15.9 - form-data: 4.0.3 + form-data: 4.0.4 proxy-from-env: 1.1.0 transitivePeerDependencies: - debug @@ -7577,6 +7779,8 @@ snapshots: buffer-crc32@0.2.13: {} + buffer-from@1.1.2: {} + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -7714,6 +7918,8 @@ snapshots: commander@12.1.0: {} + commander@2.20.3: {} + commander@8.3.0: {} commander@9.5.0: {} @@ -7836,6 +8042,11 @@ snapshots: create-require@1.1.1: {} + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -7907,6 +8118,8 @@ snapshots: deep-is@0.1.4: {} + deepmerge-ts@7.1.5: {} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -8363,6 +8576,10 @@ snapshots: optionalDependencies: picomatch: 4.0.2 + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + fflate@0.8.2: {} file-entry-cache@8.0.0: @@ -8438,7 +8655,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data@4.0.3: + form-data@4.0.4: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 @@ -9462,6 +9679,8 @@ snapshots: picomatch@4.0.2: {} + picomatch@4.0.3: {} + pify@2.3.0: {} pify@3.0.0: {} @@ -9752,6 +9971,15 @@ snapshots: glob: 11.0.3 package-json-from-dist: 1.0.1 + rollup-plugin-visualizer@5.14.0(rollup@4.44.2): + dependencies: + open: 8.4.2 + picomatch: 4.0.2 + source-map: 0.7.4 + yargs: 17.7.2 + optionalDependencies: + rollup: 4.44.2 + rollup@4.44.2: dependencies: '@types/estree': 1.0.8 @@ -9860,6 +10088,12 @@ snapshots: shebang-regex@3.0.0: {} + shepherd.js@14.5.1: + dependencies: + '@floating-ui/dom': 1.7.4 + '@scarf/scarf': 1.4.0 + deepmerge-ts: 7.1.5 + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -9930,6 +10164,11 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + source-map@0.6.1: {} source-map@0.7.4: {} @@ -10146,7 +10385,7 @@ snapshots: tdd-guard-vitest@0.1.5(vitest@3.2.4): dependencies: tdd-guard: 1.0.2 - vitest: 3.2.4(@types/node@24.0.11)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.4.2)(lightningcss@1.30.1)(msw@2.11.0(@types/node@24.0.11)(typescript@5.8.3))(tsx@4.20.3)(yaml@2.8.0) + vitest: 3.2.4(@types/node@24.0.11)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.4.2)(lightningcss@1.30.1)(msw@2.11.0(@types/node@24.0.11)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0) tdd-guard@1.0.2: dependencies: @@ -10168,6 +10407,13 @@ snapshots: type-fest: 0.16.0 unique-string: 2.0.0 + terser@5.44.1: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.15.0 + commander: 2.20.3 + source-map-support: 0.5.21 + test-exclude@7.0.1: dependencies: '@istanbuljs/schema': 0.1.3 @@ -10204,6 +10450,11 @@ snapshots: fdir: 6.4.6(picomatch@4.0.2) picomatch: 4.0.2 + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + tinypool@1.1.1: {} tinyrainbow@2.0.0: {} @@ -10437,13 +10688,13 @@ snapshots: - '@types/react' - '@types/react-dom' - vite-node@3.2.4(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0): + vite-node@3.2.4(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.0.3(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0) + vite: 7.1.11(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0) transitivePeerDependencies: - '@types/node' - jiti @@ -10458,27 +10709,28 @@ snapshots: - tsx - yaml - vite@7.0.3(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0): + vite@7.1.11(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0): dependencies: esbuild: 0.25.6 - fdir: 6.4.6(picomatch@4.0.2) - picomatch: 4.0.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 postcss: 8.5.6 rollup: 4.44.2 - tinyglobby: 0.2.14 + tinyglobby: 0.2.15 optionalDependencies: '@types/node': 24.0.11 fsevents: 2.3.3 jiti: 2.4.2 lightningcss: 1.30.1 + terser: 5.44.1 tsx: 4.20.3 yaml: 2.8.0 - vitest@3.2.4(@types/node@24.0.11)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.4.2)(lightningcss@1.30.1)(msw@2.11.0(@types/node@24.0.11)(typescript@5.8.3))(tsx@4.20.3)(yaml@2.8.0): + vitest@3.2.4(@types/node@24.0.11)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.4.2)(lightningcss@1.30.1)(msw@2.11.0(@types/node@24.0.11)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.11.0(@types/node@24.0.11)(typescript@5.8.3))(vite@7.0.3(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0)) + '@vitest/mocker': 3.2.4(msw@2.11.0(@types/node@24.0.11)(typescript@5.8.3))(vite@7.1.11(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -10496,8 +10748,8 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.0.3(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0) - vite-node: 3.2.4(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0) + vite: 7.1.11(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0) + vite-node: 3.2.4(@types/node@24.0.11)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.20.3)(yaml@2.8.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.0.11 diff --git a/public/lockup.png b/public/lockup.png deleted file mode 100644 index 1cf913b7..00000000 Binary files a/public/lockup.png and /dev/null differ diff --git a/public/lockup.webp b/public/lockup.webp new file mode 100644 index 00000000..525cc24b Binary files /dev/null and b/public/lockup.webp differ diff --git a/public/logo.png b/public/logo.png deleted file mode 100644 index 3f3e31cc..00000000 Binary files a/public/logo.png and /dev/null differ diff --git a/public/logo.webp b/public/logo.webp new file mode 100644 index 00000000..63921d71 Binary files /dev/null and b/public/logo.webp differ diff --git a/src/App.tsx b/src/App.tsx index 908c4520..785535f6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,31 @@ import { MediaFinishedToast } from "./components/MediaFinishedToast.tsx"; import { useDataCache } from "./hooks/useDataCache"; import { getDeviceAddress } from "./lib/coreApi"; import { SlideModalProvider } from "./components/SlideModalProvider"; +import { usePreferencesStore } from "./lib/preferencesStore"; +import { useProAccessCheck } from "./hooks/useProAccessCheck"; +import { useNfcAvailabilityCheck } from "./hooks/useNfcAvailabilityCheck"; +import { useCameraAvailabilityCheck } from "./hooks/useCameraAvailabilityCheck"; +import { useAccelerometerAvailabilityCheck } from "./hooks/useAccelerometerAvailabilityCheck"; +import { useRunQueueProcessor } from "./hooks/useRunQueueProcessor"; +import { useWriteQueueProcessor } from "./hooks/useWriteQueueProcessor"; +import { useShakeDetection } from "./hooks/useShakeDetection"; + +// Component to initialize queue processors after preferences hydrate +// This ensures sessionManager.launchOnScan is set correctly before processing +function QueueProcessors() { + const shakeEnabled = usePreferencesStore((state) => state.shakeEnabled); + const launcherAccess = usePreferencesStore((state) => state.launcherAccess); + const connected = useStatusStore((state) => state.connected); + + useRunQueueProcessor(); + useWriteQueueProcessor(); + useShakeDetection({ + shakeEnabled, + launcherAccess, + connected + }); + return null; +} const router = createRouter({ scrollRestoration: true, @@ -32,18 +57,38 @@ declare module "@tanstack/react-router" { } } - export default function App() { + // Wait for preferences to hydrate before rendering to prevent layout shifts + const hasHydrated = usePreferencesStore((state) => state._hasHydrated); + const proAccessHydrated = usePreferencesStore( + (state) => state._proAccessHydrated + ); + const nfcAvailabilityHydrated = usePreferencesStore( + (state) => state._nfcAvailabilityHydrated + ); + const cameraAvailabilityHydrated = usePreferencesStore( + (state) => state._cameraAvailabilityHydrated + ); + const accelerometerAvailabilityHydrated = usePreferencesStore( + (state) => state._accelerometerAvailabilityHydrated + ); + // Initialize data cache early in app lifecycle useDataCache(); + // Check Pro access status once at app startup + useProAccessCheck(); + // Check hardware availability once at app startup + useNfcAvailabilityCheck(); + useCameraAvailabilityCheck(); + useAccelerometerAvailabilityCheck(); const playing = useStatusStore((state) => state.playing); + const prevPlaying = usePrevious(playing); const gamesIndex = useStatusStore((state) => state.gamesIndex); const prevGamesIndex = usePrevious(gamesIndex); const setLoggedInUser = useStatusStore((state) => state.setLoggedInUser); const { t } = useTranslation(); - useEffect(() => { FirebaseAuthentication.addListener("authStateChange", (change) => { setLoggedInUser(change.user); @@ -55,7 +100,12 @@ export default function App() { useEffect(() => { // Only show completion toast, progress is now shown in MediaDatabaseCard - if (!gamesIndex.indexing && prevGamesIndex?.indexing) { + // Skip toast if totalFiles is 0 (indicates cancellation) + if ( + !gamesIndex.indexing && + prevGamesIndex?.indexing && + (gamesIndex.totalFiles ?? 0) > 0 + ) { toast.success((to) => , { id: "indexed", icon: ( @@ -68,7 +118,12 @@ export default function App() { }, [gamesIndex, prevGamesIndex, t]); useEffect(() => { - if (playing.mediaName !== "") { + // Only show toast when a new game actually starts (mediaName changes) + // This prevents duplicate toasts for the same game and handles debouncing naturally + if ( + playing.mediaName !== "" && + playing.mediaName !== prevPlaying?.mediaName + ) { toast.success( (to) => ( ), { - id: "playingGame-" + playing.mediaName, + id: "playingGame-" + Date.now(), icon: ( @@ -94,12 +149,18 @@ export default function App() { } ); } - }, [playing, t]); + }, [playing, prevPlaying, t]); + + // Block rendering until preferences, Pro access, and hardware availability are hydrated to prevent layout shifts + if (!hasHydrated || !proAccessHydrated || !nfcAvailabilityHydrated || !cameraAvailabilityHydrated || !accelerometerAvailabilityHydrated) { + return null; // Keep splash screen visible + } return ( <> + { beforeEach(() => { - // Reset store to initial state + // Reset stores to initial state useStatusStore.getState().setRunQueue(null); useStatusStore.getState().setWriteQueue(""); + useStatusStore.getState().setProPurchaseModalOpen(false); + useStatusStore.getState().setWriteOpen(false); + + // Reset session manager + sessionManager.launchOnScan = true; + sessionManager.shouldRestart = false; + }); + + describe('Store State Management', () => { + it('should initialize queues in empty state', () => { + const state = useStatusStore.getState(); + + expect(state.runQueue).toBeNull(); + expect(state.writeQueue).toBe(""); + }); + + it('should have global modal states in store', () => { + const state = useStatusStore.getState(); + + expect(state.proPurchaseModalOpen).toBe(false); + expect(state.writeOpen).toBe(false); + expect(typeof state.setProPurchaseModalOpen).toBe('function'); + expect(typeof state.setWriteOpen).toBe('function'); + }); + + it('should update modal states globally', () => { + const { setProPurchaseModalOpen, setWriteOpen } = useStatusStore.getState(); + + setProPurchaseModalOpen(true); + expect(useStatusStore.getState().proPurchaseModalOpen).toBe(true); + + setWriteOpen(true); + expect(useStatusStore.getState().writeOpen).toBe(true); + }); + }); + + describe('Queue Processor Initialization', () => { + it('should respect launchOnScan preference from storage on startup', () => { + const prefsStore = usePreferencesStore.getState(); + + // Simulate user disabled launch on scan in preferences + prefsStore.setLaunchOnScan(false); + + // Verify sessionManager was updated + expect(sessionManager.launchOnScan).toBe(false); + + // Simulate user enabled it back + prefsStore.setLaunchOnScan(true); + expect(sessionManager.launchOnScan).toBe(true); + }); + }); + + describe('Run Queue Processing', () => { + it('should process run queue items added to store', () => { + const { setRunQueue } = useStatusStore.getState(); + + // Add item to run queue + setRunQueue({ value: '**launch.system:menu', unsafe: true }); + + // Verify queue was set + const state = useStatusStore.getState(); + expect(state.runQueue).toEqual({ value: '**launch.system:menu', unsafe: true }); + }); + + it('should clear run queue after processing', () => { + const { setRunQueue } = useStatusStore.getState(); + + setRunQueue({ value: 'test', unsafe: false }); + expect(useStatusStore.getState().runQueue).not.toBeNull(); + + // Simulate processor clearing the queue + setRunQueue(null); + expect(useStatusStore.getState().runQueue).toBeNull(); + }); + }); + + describe('Write Queue Processing', () => { + it('should process write queue items added to store', () => { + const { setWriteQueue } = useStatusStore.getState(); + + // Add item to write queue + setWriteQueue('**launch.system:menu'); + + // Verify queue was set + const state = useStatusStore.getState(); + expect(state.writeQueue).toBe('**launch.system:menu'); + }); + + it('should clear write queue after processing', () => { + const { setWriteQueue } = useStatusStore.getState(); + + setWriteQueue('test-content'); + expect(useStatusStore.getState().writeQueue).toBe('test-content'); + + // Simulate processor clearing the queue + setWriteQueue(''); + expect(useStatusStore.getState().writeQueue).toBe(''); + }); }); - it('should initialize queues in empty state', () => { - const state = useStatusStore.getState(); + describe('Cross-Route Functionality', () => { + it('should maintain queue state when navigating between routes', () => { + const { setRunQueue, setWriteQueue } = useStatusStore.getState(); + + // Simulate being on home route and adding to queue + setRunQueue({ value: 'home-route-command', unsafe: false }); + setWriteQueue('home-route-write'); + + // Queue should persist regardless of route + // (In real app, queue processors are at App level, not route level) + expect(useStatusStore.getState().runQueue?.value).toBe('home-route-command'); + expect(useStatusStore.getState().writeQueue).toBe('home-route-write'); + + // Simulate navigation to settings route + // Queue state should still be accessible + const state = useStatusStore.getState(); + expect(state.runQueue?.value).toBe('home-route-command'); + expect(state.writeQueue).toBe('home-route-write'); + }); + + it('should allow shake-to-launch from any route via global queue', () => { + const { setRunQueue } = useStatusStore.getState(); + + // Simulate shake detection adding to queue from settings route + setRunQueue({ value: '**launch.random', unsafe: true }); + + // Verify queue was set (processor will handle it regardless of route) + expect(useStatusStore.getState().runQueue).toEqual({ + value: '**launch.random', + unsafe: true + }); + }); + + it('should show Pro purchase modal globally from any route', () => { + const { setProPurchaseModalOpen } = useStatusStore.getState(); + + // Simulate non-Pro user trying to launch from settings route + setProPurchaseModalOpen(true); + + // Modal state should be global + expect(useStatusStore.getState().proPurchaseModalOpen).toBe(true); + + // Close modal + setProPurchaseModalOpen(false); + expect(useStatusStore.getState().proPurchaseModalOpen).toBe(false); + }); + + it('should show write modal globally from any route', () => { + const { setWriteOpen } = useStatusStore.getState(); + + // Simulate write triggered from create route + setWriteOpen(true); + + // Modal state should be global + expect(useStatusStore.getState().writeOpen).toBe(true); + + // Close modal + setWriteOpen(false); + expect(useStatusStore.getState().writeOpen).toBe(false); + }); + }); + + describe('Session Manager Integration', () => { + it('should sync preferences to sessionManager on change', () => { + const prefsStore = usePreferencesStore.getState(); + + // Test restartScan + expect(sessionManager.shouldRestart).toBe(false); + prefsStore.setRestartScan(true); + expect(sessionManager.shouldRestart).toBe(true); + + // Test launchOnScan + expect(sessionManager.launchOnScan).toBe(true); + prefsStore.setLaunchOnScan(false); + expect(sessionManager.launchOnScan).toBe(false); + }); + + it('should initialize sessionManager from preferences on hydration', () => { + const prefsStore = usePreferencesStore.getState(); + + // Set preferences + prefsStore.setRestartScan(true); + prefsStore.setLaunchOnScan(false); + + // Verify sessionManager was updated immediately + expect(sessionManager.shouldRestart).toBe(true); + expect(sessionManager.launchOnScan).toBe(false); + }); + }); + + describe('Queue Processing Edge Cases', () => { + it('should handle empty run queue value', () => { + const { setRunQueue } = useStatusStore.getState(); + + setRunQueue({ value: '', unsafe: false }); + expect(useStatusStore.getState().runQueue?.value).toBe(''); + }); + + it('should handle empty write queue value', () => { + const { setWriteQueue } = useStatusStore.getState(); + + setWriteQueue(''); + expect(useStatusStore.getState().writeQueue).toBe(''); + }); + + it('should handle rapid queue updates', () => { + const { setRunQueue } = useStatusStore.getState(); + + // Simulate rapid shake detections or deep links + setRunQueue({ value: 'first', unsafe: true }); + setRunQueue({ value: 'second', unsafe: true }); + setRunQueue({ value: 'third', unsafe: true }); - expect(state.runQueue).toBeNull(); - expect(state.writeQueue).toBe(""); + // Last one should win (queue is not actually a queue, it's a single slot) + expect(useStatusStore.getState().runQueue?.value).toBe('third'); + }); }); -}); \ No newline at end of file +}); diff --git a/src/__tests__/integration/route-interactions.test.tsx b/src/__tests__/integration/route-interactions.test.tsx index ab2b71aa..c5c48274 100644 --- a/src/__tests__/integration/route-interactions.test.tsx +++ b/src/__tests__/integration/route-interactions.test.tsx @@ -258,7 +258,15 @@ describe("Route Interactions - Integration Tests", () => { { id: "2", name: "Sonic", system: "Genesis", path: "/games/sonic.bin" } ]; - mockCoreAPI.mediaSearch.mockResolvedValue({ results: searchResults }); + mockCoreAPI.mediaSearch.mockResolvedValue({ + results: searchResults, + total: searchResults.length, + pagination: { + nextCursor: null, + hasNextPage: false, + pageSize: searchResults.length, + } + }); const SearchToWriteFlow = () => { const [results, setResults] = React.useState([]); diff --git a/src/__tests__/integration/tour.test.tsx b/src/__tests__/integration/tour.test.tsx new file mode 100644 index 00000000..9a02843d --- /dev/null +++ b/src/__tests__/integration/tour.test.tsx @@ -0,0 +1,206 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { readFileSync } from "fs"; +import { resolve } from "path"; + +/** + * Integration tests to ensure tour data attributes are properly placed + * in the actual component files + */ +describe("Tour Integration - data-tour attributes", () => { + let settingsSource: string; + let mediaDatabaseCardSource: string; + let createIndexSource: string; + + beforeEach(() => { + settingsSource = readFileSync( + resolve(__dirname, "../../routes/settings.index.tsx"), + "utf-8" + ); + mediaDatabaseCardSource = readFileSync( + resolve(__dirname, "../../components/MediaDatabaseCard.tsx"), + "utf-8" + ); + createIndexSource = readFileSync( + resolve(__dirname, "../../routes/create.index.tsx"), + "utf-8" + ); + }); + + describe("settings page", () => { + it("should have data-tour attribute for device address", () => { + expect(settingsSource).toMatch(/data-tour="device-address"/); + }); + + it("should wrap device address input with tour target", () => { + // Check that data-tour is on a div that contains TextInput + expect(settingsSource).toMatch( + / { + it("should have data-tour attribute for update database button", () => { + expect(mediaDatabaseCardSource).toMatch(/data-tour="update-database"/); + }); + + it("should wrap update button with tour target", () => { + // Check that data-tour is on a div that contains the update button + expect(mediaDatabaseCardSource).toMatch( + / { + it("should have data-tour attribute for create search", () => { + expect(createIndexSource).toMatch(/data-tour="create-search"/); + }); + + it("should have tour target on Link to search", () => { + // Check that data-tour is on Link component + expect(createIndexSource).toMatch( + / { + it("should reference all data-tour attributes", () => { + const tourServiceSource = readFileSync( + resolve(__dirname, "../../lib/tourService.ts"), + "utf-8" + ); + + // Check that tourService references the correct data-tour selectors + expect(tourServiceSource).toMatch(/\[data-tour="device-address"\]/); + expect(tourServiceSource).toMatch(/\[data-tour="update-database"\]/); + expect(tourServiceSource).toMatch(/\[data-tour="create-search"\]/); + }); + }); + + describe("translations", () => { + it("should have all tour translation keys defined", () => { + const translationsSource = readFileSync( + resolve(__dirname, "../../translations/en-US.json"), + "utf-8" + ); + + const translations = JSON.parse(translationsSource); + + // Check tour section exists + expect(translations.translation.tour).toBeDefined(); + + // Check step indicator + expect(translations.translation.tour.stepIndicator).toBeDefined(); + + // Check all steps + expect(translations.translation.tour.welcome).toBeDefined(); + expect(translations.translation.tour.welcome.title).toBeDefined(); + expect(translations.translation.tour.welcome.text).toBeDefined(); + + expect(translations.translation.tour.deviceAddress).toBeDefined(); + expect(translations.translation.tour.deviceAddress.title).toBeDefined(); + expect(translations.translation.tour.deviceAddress.text).toBeDefined(); + + expect(translations.translation.tour.mediaDatabase).toBeDefined(); + expect(translations.translation.tour.mediaDatabase.title).toBeDefined(); + expect(translations.translation.tour.mediaDatabase.text).toBeDefined(); + + expect(translations.translation.tour.createCards).toBeDefined(); + expect(translations.translation.tour.createCards.title).toBeDefined(); + expect(translations.translation.tour.createCards.text).toBeDefined(); + + expect(translations.translation.tour.complete).toBeDefined(); + expect(translations.translation.tour.complete.title).toBeDefined(); + expect(translations.translation.tour.complete.text).toBeDefined(); + + // Check buttons + expect(translations.translation.tour.buttons).toBeDefined(); + expect(translations.translation.tour.buttons.skip).toBeDefined(); + expect(translations.translation.tour.buttons.getStarted).toBeDefined(); + expect(translations.translation.tour.buttons.back).toBeDefined(); + expect(translations.translation.tour.buttons.next).toBeDefined(); + expect(translations.translation.tour.buttons.finish).toBeDefined(); + }); + }); + + describe("preferences store", () => { + it("should have tourCompleted in preferences store", () => { + const preferencesStoreSource = readFileSync( + resolve(__dirname, "../../lib/preferencesStore.ts"), + "utf-8" + ); + + expect(preferencesStoreSource).toMatch(/tourCompleted:\s*boolean/); + expect(preferencesStoreSource).toMatch(/setTourCompleted/); + }); + + it("should include tourCompleted in DEFAULT_PREFERENCES", () => { + const preferencesStoreSource = readFileSync( + resolve(__dirname, "../../lib/preferencesStore.ts"), + "utf-8" + ); + + expect(preferencesStoreSource).toMatch(/DEFAULT_PREFERENCES[\s\S]*?tourCompleted:\s*false/); + }); + + it("should persist tourCompleted in partialize", () => { + const preferencesStoreSource = readFileSync( + resolve(__dirname, "../../lib/preferencesStore.ts"), + "utf-8" + ); + + expect(preferencesStoreSource).toMatch(/partialize[\s\S]*?tourCompleted:/); + }); + }); + + describe("root route integration", () => { + it("should import TourInitializer component", () => { + const rootSource = readFileSync( + resolve(__dirname, "../../routes/__root.tsx"), + "utf-8" + ); + + expect(rootSource).toMatch(/import.*TourInitializer.*from/); + }); + + it("should render TourInitializer in component tree", () => { + const rootSource = readFileSync( + resolve(__dirname, "../../routes/__root.tsx"), + "utf-8" + ); + + expect(rootSource).toMatch(//); + }); + }); + + describe("CSS styling", () => { + it("should have tour-specific CSS classes", () => { + const cssSource = readFileSync( + resolve(__dirname, "../../index.css"), + "utf-8" + ); + + // Check for zaparoo-tour-step class + expect(cssSource).toMatch(/\.zaparoo-tour-step/); + + // Check for shepherd styling + expect(cssSource).toMatch(/\.shepherd-element/); + expect(cssSource).toMatch(/\.shepherd-content/); + expect(cssSource).toMatch(/\.shepherd-button/); + expect(cssSource).toMatch(/\.shepherd-modal-overlay-container/); + }); + + it("should have custom theme matching app design", () => { + const cssSource = readFileSync( + resolve(__dirname, "../../index.css"), + "utf-8" + ); + + // Check that custom colors are used (not default shepherd) + expect(cssSource).toMatch(/hsl\(210 22% 15%\)/); // --card background + expect(cssSource).toMatch(/background-image:\s*radial-gradient/); // button gradient + }); + }); +}); diff --git a/src/__tests__/unit/App.integration.test.tsx b/src/__tests__/unit/App.integration.test.tsx index e54cff24..d8bd1c08 100644 --- a/src/__tests__/unit/App.integration.test.tsx +++ b/src/__tests__/unit/App.integration.test.tsx @@ -65,13 +65,35 @@ vi.mock("@capacitor-firebase/authentication", () => ({ }, })); -vi.mock("@/lib/store", () => ({ - useStatusStore: vi.fn(() => ({ +vi.mock("@/lib/store", () => { + const mockState = { connectionState: "CONNECTED", gamesIndex: { exists: true, indexing: false }, mediaActiveUpdate: null, - })), -})); + runQueue: null, + setRunQueue: vi.fn(), + writeQueue: null, + setWriteQueue: vi.fn(), + setLastToken: vi.fn(), + setProPurchaseModalOpen: vi.fn(), + connected: true, + playing: { mediaName: "", systemId: "", mediaPath: "" }, + }; + + const useStatusStore: any = vi.fn((selector) => { + if (typeof selector === 'function') { + return selector(mockState); + } + return mockState; + }); + + // Add getState method for direct access + useStatusStore.getState = () => mockState; + + return { + useStatusStore, + }; +}); vi.mock("@/hooks/useDataCache", () => ({ useDataCache: vi.fn(() => ({})), diff --git a/src/__tests__/unit/components/CoreApiWebSocket.gracePeriod.test.tsx b/src/__tests__/unit/components/CoreApiWebSocket.gracePeriod.test.tsx deleted file mode 100644 index 3bc61581..00000000 --- a/src/__tests__/unit/components/CoreApiWebSocket.gracePeriod.test.tsx +++ /dev/null @@ -1,276 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render } from "@testing-library/react"; -import { useStatusStore, ConnectionState } from "../../../lib/store"; -import { CoreApiWebSocket } from "../../../components/CoreApiWebSocket"; - -// Mock the coreApi functions -const { mockGetDeviceAddress, mockGetWsUrl } = vi.hoisted(() => ({ - mockGetDeviceAddress: vi.fn(() => "192.168.1.100:7497"), - mockGetWsUrl: vi.fn(() => "ws://192.168.1.100:7497") -})); - -// Mock WebSocket -const mockWebSocket = { - onerror: vi.fn(), - onopen: vi.fn(), - onclose: vi.fn(), - onmessage: vi.fn(), - send: vi.fn(), - close: vi.fn() -}; - -vi.mock("websocket-heartbeat-js", () => ({ - default: vi.fn().mockImplementation(() => mockWebSocket) -})); - -// Mock WebSocketManager -const mockWebSocketManager = { - connect: vi.fn(), - destroy: vi.fn(), - send: vi.fn(), - callbacks: {} as import("../../../lib/websocketManager").WebSocketManagerCallbacks -}; - -vi.mock("../../../lib/websocketManager", () => ({ - WebSocketManager: vi.fn().mockImplementation((_, callbacks) => { - // Store callbacks for testing - mockWebSocketManager.callbacks = callbacks; - - // Update connect mock to trigger onStateChange - mockWebSocketManager.connect.mockImplementation(() => { - if (callbacks.onStateChange) { - callbacks.onStateChange("CONNECTING"); - } - }); - - return mockWebSocketManager; - }), - WebSocketState: { - CONNECTING: "CONNECTING", - CONNECTED: "CONNECTED", - RECONNECTING: "RECONNECTING", - ERROR: "ERROR", - DISCONNECTED: "DISCONNECTED" - } -})); - -vi.mock("../../../lib/coreApi", () => ({ - getDeviceAddress: mockGetDeviceAddress, - getWsUrl: mockGetWsUrl, - CoreAPI: { - setSend: vi.fn(), - setWsInstance: vi.fn(), - flushQueue: vi.fn(), - processReceived: vi.fn().mockResolvedValue(null), - media: vi.fn().mockResolvedValue({ database: {}, active: [] }), - tokens: vi.fn().mockResolvedValue({ last: null }) - } -})); - -// Mock Preferences -vi.mock("@capacitor/preferences", () => ({ - Preferences: { - get: vi.fn().mockResolvedValue({ value: null }) - } -})); - -// Mock react-hot-toast -vi.mock("react-hot-toast", () => ({ - default: { - error: vi.fn() - } -})); - -// Mock i18next -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => key - }) -})); - -// Mock the store -vi.mock("../../../lib/store", () => ({ - useStatusStore: vi.fn(), - ConnectionState: { - IDLE: "IDLE", - CONNECTING: "CONNECTING", - CONNECTED: "CONNECTED", - RECONNECTING: "RECONNECTING", - ERROR: "ERROR", - DISCONNECTED: "DISCONNECTED" - } -})); - -// Test helper factory for creating mock store state -const createMockStoreState = (overrides = {}) => ({ - retryCount: 0, - setConnected: vi.fn(), - setConnectionState: vi.fn(), - setConnectionStateWithGracePeriod: vi.fn(), - clearGracePeriod: vi.fn(), - setConnectionError: vi.fn(), - setPlaying: vi.fn(), - setGamesIndex: vi.fn(), - setLastToken: vi.fn(), - runQueue: null, - setRunQueue: vi.fn(), - writeQueue: "", - setWriteQueue: vi.fn(), - addDeviceHistory: vi.fn(), - setDeviceHistory: vi.fn(), - ...overrides -}); - -describe("CoreApiWebSocket Grace Period", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.clearAllMocks(); - // Reset WebSocket mock handlers - mockWebSocket.onerror = vi.fn(); - mockWebSocket.onopen = vi.fn(); - mockWebSocket.onclose = vi.fn(); - mockWebSocket.onmessage = vi.fn(); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.clearAllTimers(); - }); - - it("should use grace period for onclose events", () => { - const mockSetConnectionStateWithGracePeriod = vi.fn(); - const mockClearGracePeriod = vi.fn(); - - const mockState = createMockStoreState({ - setConnectionStateWithGracePeriod: mockSetConnectionStateWithGracePeriod, - clearGracePeriod: mockClearGracePeriod - }); - - vi.mocked(useStatusStore).mockImplementation((selector: any) => - selector(mockState) - ); - - render(); - - // Simulate WebSocket close event via WebSocketManager callback - if (mockWebSocketManager.callbacks && mockWebSocketManager.callbacks.onClose) { - mockWebSocketManager.callbacks.onClose(); - } - - expect(mockSetConnectionStateWithGracePeriod).toHaveBeenCalledWith(ConnectionState.RECONNECTING); - expect(mockClearGracePeriod).not.toHaveBeenCalled(); - }); - - it("should clear grace period on successful connection", () => { - const mockSetConnectionStateWithGracePeriod = vi.fn(); - const mockClearGracePeriod = vi.fn(); - const mockSetConnectionError = vi.fn(); - - const mockState = createMockStoreState({ - setConnectionStateWithGracePeriod: mockSetConnectionStateWithGracePeriod, - clearGracePeriod: mockClearGracePeriod, - setConnectionError: mockSetConnectionError - }); - - vi.mocked(useStatusStore).mockImplementation((selector: any) => - selector(mockState) - ); - - render(); - - // Simulate WebSocket open event via WebSocketManager callback - if (mockWebSocketManager.callbacks && mockWebSocketManager.callbacks.onOpen) { - mockWebSocketManager.callbacks.onOpen(); - } - - expect(mockClearGracePeriod).toHaveBeenCalled(); - expect(mockSetConnectionStateWithGracePeriod).toHaveBeenCalledWith(ConnectionState.CONNECTED); - expect(mockSetConnectionError).toHaveBeenCalledWith(""); - }); - - it("should bypass grace period for error states", () => { - const mockSetConnectionState = vi.fn(); - const mockSetConnectionError = vi.fn(); - - const mockState = createMockStoreState({ - setConnectionState: mockSetConnectionState, - setConnectionError: mockSetConnectionError - }); - - vi.mocked(useStatusStore).mockImplementation((selector: any) => - selector(mockState) - ); - - render(); - - // Simulate WebSocket error event via WebSocketManager callback - const errorEvent = new Event("error"); - if (mockWebSocketManager.callbacks && mockWebSocketManager.callbacks.onError) { - mockWebSocketManager.callbacks.onError(errorEvent); - } - - expect(mockSetConnectionState).toHaveBeenCalledWith(ConnectionState.ERROR); - expect(mockSetConnectionError).toHaveBeenCalledWith( - expect.stringContaining("Error communicating with server") - ); - }); - - it("should clear grace period on component cleanup", () => { - const mockClearGracePeriod = vi.fn(); - const mockSetConnectionState = vi.fn(); - - const mockState = createMockStoreState({ - clearGracePeriod: mockClearGracePeriod, - setConnectionState: mockSetConnectionState - }); - - vi.mocked(useStatusStore).mockImplementation((selector: any) => - selector(mockState) - ); - - const { unmount } = render(); - - unmount(); - - expect(mockClearGracePeriod).toHaveBeenCalled(); - expect(mockSetConnectionState).toHaveBeenCalledWith(ConnectionState.DISCONNECTED); - }); - - it("should use regular setConnectionState for initial connecting state", () => { - const mockSetConnectionState = vi.fn(); - - const mockState = createMockStoreState({ - setConnectionState: mockSetConnectionState - }); - - vi.mocked(useStatusStore).mockImplementation((selector: any) => - selector(mockState) - ); - - render(); - - expect(mockSetConnectionState).toHaveBeenCalledWith(ConnectionState.CONNECTING); - }); - - it("should use regular setConnectionState for configuration errors", () => { - const mockSetConnectionState = vi.fn(); - const mockSetConnectionError = vi.fn(); - - // Mock no device address configured - mockGetDeviceAddress.mockReturnValueOnce(""); - - const mockState = createMockStoreState({ - setConnectionState: mockSetConnectionState, - setConnectionError: mockSetConnectionError - }); - - vi.mocked(useStatusStore).mockImplementation((selector: any) => - selector(mockState) - ); - - render(); - - expect(mockSetConnectionState).toHaveBeenCalledWith(ConnectionState.ERROR); - expect(mockSetConnectionError).toHaveBeenCalledWith("No device address configured"); - }); -}); \ No newline at end of file diff --git a/src/__tests__/unit/components/CoreApiWebSocket.hotReload.test.tsx b/src/__tests__/unit/components/CoreApiWebSocket.hotReload.test.tsx new file mode 100644 index 00000000..5d9626bc --- /dev/null +++ b/src/__tests__/unit/components/CoreApiWebSocket.hotReload.test.tsx @@ -0,0 +1,306 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useStatusStore } from "@/lib/store.ts"; +import { CoreApiWebSocket } from "@/components/CoreApiWebSocket.tsx"; + +// Mock the coreApi functions +const mockGetDeviceAddress = vi.fn(); +const mockGetWsUrl = vi.fn(); + +vi.mock("../../../lib/coreApi", () => { + const mockCoreAPI = { + setWsInstance: vi.fn(), + media: vi.fn(() => Promise.resolve({ database: {}, active: [] })), + tokens: vi.fn(() => Promise.resolve({ last: null })), + processReceived: vi.fn(), + flushQueue: vi.fn() + }; + + return { + CoreAPI: mockCoreAPI, + getDeviceAddress: () => mockGetDeviceAddress(), + getWsUrl: () => mockGetWsUrl() + }; +}); + +// Mock WebSocketManager +const mockWebSocketManager = { + connect: vi.fn(), + destroy: vi.fn(), + send: vi.fn(), + callbacks: {} as any +}; + +vi.mock("../../../lib/websocketManager", () => ({ + WebSocketManager: vi.fn().mockImplementation((_config, callbacks) => { + mockWebSocketManager.callbacks = callbacks; + return mockWebSocketManager; + }), + WebSocketState: { + IDLE: "idle", + CONNECTING: "connecting", + CONNECTED: "connected", + RECONNECTING: "reconnecting", + DISCONNECTED: "disconnected", + ERROR: "error" + } +})); + +// Mock Preferences +vi.mock("@capacitor/preferences", () => ({ + Preferences: { + get: vi.fn(() => Promise.resolve({ value: null })), + set: vi.fn(() => Promise.resolve()) + } +})); + +// Mock react-hot-toast +vi.mock("react-hot-toast", () => ({ + default: { + success: vi.fn(), + error: vi.fn() + } +})); + +// Mock i18next +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key + }) +})); + +// Mock Zustand store +vi.mock("../../../lib/store", () => { + const mockStore = { + setConnectionState: vi.fn(), + setConnectionStateWithGracePeriod: vi.fn(), + clearGracePeriod: vi.fn(), + setConnectionError: vi.fn(), + setPlaying: vi.fn(), + setGamesIndex: vi.fn(), + setLastToken: vi.fn(), + addDeviceHistory: vi.fn(), + setDeviceHistory: vi.fn() + }; + + return { + useStatusStore: vi.fn((selector: any) => selector(mockStore)), + ConnectionState: { + DISCONNECTED: "disconnected", + CONNECTING: "connecting", + CONNECTED: "connected", + RECONNECTING: "reconnecting", + ERROR: "error" + } + }; +}); + +const renderWithQueryClient = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } } + }); + return render( + + + + ); +}; + +describe("CoreApiWebSocket Hot Reload", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should retry fetching device address when initially empty", async () => { + // Simulate hot reload: localStorage empty on first calls, available after retry + let callCount = 0; + mockGetDeviceAddress.mockImplementation(() => { + callCount++; + // First call: useState initialization (empty) + // Second call: first retry attempt (empty) + // Third call: second retry attempt (now available) + return callCount <= 2 ? "" : "192.168.1.100"; + }); + mockGetWsUrl.mockImplementation(() => { + return callCount <= 2 ? "" : "ws://192.168.1.100:7497"; + }); + + const mockSetConnectionError = vi.fn(); + const mockSetConnectionState = vi.fn(); + vi.mocked(useStatusStore).mockImplementation((selector: any) => + selector({ + setConnectionState: mockSetConnectionState, + setConnectionStateWithGracePeriod: vi.fn(), + clearGracePeriod: vi.fn(), + setConnectionError: mockSetConnectionError, + setPlaying: vi.fn(), + setGamesIndex: vi.fn(), + setLastToken: vi.fn(), + addDeviceHistory: vi.fn(), + setDeviceHistory: vi.fn() + }) + ); + + await act(async () => { + renderWithQueryClient(); + }); + + // Initially should have error + expect(mockSetConnectionError).toHaveBeenCalledWith( + "No device address configured" + ); + + // Advance timers to trigger retries + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + + // After retry, should have attempted to connect + expect(mockWebSocketManager.connect).toHaveBeenCalled(); + }); + + it("should stop retrying after max attempts", async () => { + // Always return empty address + mockGetDeviceAddress.mockReturnValue(""); + mockGetWsUrl.mockReturnValue(""); + + const mockSetConnectionError = vi.fn(); + vi.mocked(useStatusStore).mockImplementation((selector: any) => + selector({ + setConnectionState: vi.fn(), + setConnectionStateWithGracePeriod: vi.fn(), + clearGracePeriod: vi.fn(), + setConnectionError: mockSetConnectionError, + setPlaying: vi.fn(), + setGamesIndex: vi.fn(), + setLastToken: vi.fn(), + addDeviceHistory: vi.fn(), + setDeviceHistory: vi.fn() + }) + ); + + renderWithQueryClient(); + + // Fast-forward through all retry attempts (5 attempts * 100ms = 500ms) + await vi.advanceTimersByTimeAsync(600); + + // Should have called: 1 (useState) + 5 (retry attempts) = 6 times + expect(mockGetDeviceAddress).toHaveBeenCalledTimes(6); + + // Should never have attempted to connect + expect(mockWebSocketManager.connect).not.toHaveBeenCalled(); + }); + + it("should stop retrying once valid address is obtained", async () => { + // Return empty first few times, then valid address + let callCount = 0; + mockGetDeviceAddress.mockImplementation(() => { + callCount++; + // First 3 calls: empty (useState + first retry attempts) + // Fourth call onwards: valid + return callCount <= 3 ? "" : "192.168.1.100"; + }); + mockGetWsUrl.mockImplementation(() => { + return callCount <= 3 ? "" : "ws://192.168.1.100:7497"; + }); + + vi.mocked(useStatusStore).mockImplementation((selector: any) => + selector({ + setConnectionState: vi.fn(), + setConnectionStateWithGracePeriod: vi.fn(), + clearGracePeriod: vi.fn(), + setConnectionError: vi.fn(), + setPlaying: vi.fn(), + setGamesIndex: vi.fn(), + setLastToken: vi.fn(), + addDeviceHistory: vi.fn(), + setDeviceHistory: vi.fn() + }) + ); + + await act(async () => { + renderWithQueryClient(); + }); + + // Advance through multiple retries until address becomes valid + await act(async () => { + await vi.advanceTimersByTimeAsync(400); + }); + + // Should have eventually connected since we got a valid address + expect(mockWebSocketManager.connect).toHaveBeenCalled(); + + // Should have called getDeviceAddress multiple times (but not all 5 maxAttempts) + expect(mockGetDeviceAddress).toHaveBeenCalled(); + expect(mockGetDeviceAddress.mock.calls.length).toBeLessThan(6); // Less than maxAttempts + 1 + }); + + it("should connect immediately if address is available on mount", () => { + mockGetDeviceAddress.mockReturnValue("192.168.1.100"); + mockGetWsUrl.mockReturnValue("ws://192.168.1.100:7497"); + + vi.mocked(useStatusStore).mockImplementation((selector: any) => + selector({ + setConnectionState: vi.fn(), + setConnectionStateWithGracePeriod: vi.fn(), + clearGracePeriod: vi.fn(), + setConnectionError: vi.fn(), + setPlaying: vi.fn(), + setGamesIndex: vi.fn(), + setLastToken: vi.fn(), + addDeviceHistory: vi.fn(), + setDeviceHistory: vi.fn() + }) + ); + + renderWithQueryClient(); + + // Should have connected immediately without needing retries + expect(mockWebSocketManager.connect).toHaveBeenCalled(); + // Only called once in useState, retry logic doesn't run since address is valid + expect(mockGetDeviceAddress).toHaveBeenCalledTimes(1); + }); + + it("should cleanup retry timer on unmount", async () => { + // Return empty to keep retrying + mockGetDeviceAddress.mockReturnValue(""); + mockGetWsUrl.mockReturnValue(""); + + vi.mocked(useStatusStore).mockImplementation((selector: any) => + selector({ + setConnectionState: vi.fn(), + setConnectionStateWithGracePeriod: vi.fn(), + clearGracePeriod: vi.fn(), + setConnectionError: vi.fn(), + setPlaying: vi.fn(), + setGamesIndex: vi.fn(), + setLastToken: vi.fn(), + addDeviceHistory: vi.fn(), + setDeviceHistory: vi.fn() + }) + ); + + const { unmount } = renderWithQueryClient(); + + // Start retry cycle + await vi.advanceTimersByTimeAsync(50); + + // Called once in useState, once in first retry attempt + expect(mockGetDeviceAddress).toHaveBeenCalledTimes(2); + + // Unmount before next retry + unmount(); + + // Advance past when next retry would have happened + await vi.advanceTimersByTimeAsync(100); + + // Should not have made additional calls after unmount + expect(mockGetDeviceAddress).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/__tests__/unit/components/MediaDatabaseCard.test.tsx b/src/__tests__/unit/components/MediaDatabaseCard.test.tsx index eb888b6b..e8d1862d 100644 --- a/src/__tests__/unit/components/MediaDatabaseCard.test.tsx +++ b/src/__tests__/unit/components/MediaDatabaseCard.test.tsx @@ -7,6 +7,7 @@ import { CoreAPI } from '../../../lib/coreApi'; vi.mock('../../../lib/coreApi', () => ({ CoreAPI: { mediaGenerate: vi.fn(), + mediaGenerateCancel: vi.fn(), media: vi.fn() } })); @@ -15,7 +16,7 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, options?: any) => { if (key === 'toast.filesFound' && options?.count) { - return `${options.count} media found`; + return `${options.count} items scanned`; } return key; } @@ -72,9 +73,12 @@ describe('MediaDatabaseCard', () => { it('should render update button when not indexing', () => { render(); - const button = screen.getByRole('button', { name: /settings\.updateDb/i }); - expect(button).toBeInTheDocument(); - expect(button).not.toBeDisabled(); + // Get all buttons with the updateDb text and find the one that's the main update button (not the system selector) + const buttons = screen.getAllByRole('button', { name: /settings\.updateDb/i }); + const updateButton = buttons.find(button => !button.textContent?.includes('settings.updateDb.allSystems')); + + expect(updateButton).toBeInTheDocument(); + expect(updateButton).not.toBeDisabled(); }); it('should disable button when not connected', () => { @@ -82,8 +86,9 @@ describe('MediaDatabaseCard', () => { render(); - const button = screen.getByRole('button', { name: /settings\.updateDb/i }); - expect(button).toBeDisabled(); + const buttons = screen.getAllByRole('button', { name: /settings\.updateDb/i }); + const updateButton = buttons.find(button => !button.textContent?.includes('settings.updateDb.allSystems')); + expect(updateButton).toBeDisabled(); }); it('should disable button when indexing', () => { @@ -91,8 +96,9 @@ describe('MediaDatabaseCard', () => { render(); - const button = screen.getByRole('button', { name: /settings\.updateDb/i }); - expect(button).toBeDisabled(); + const buttons = screen.getAllByRole('button', { name: /settings\.updateDb/i }); + const updateButton = buttons.find(button => !button.textContent?.includes('settings.updateDb.allSystems')); + expect(updateButton).toBeDisabled(); }); it('should call CoreAPI.mediaGenerate when button is clicked', async () => { @@ -100,8 +106,9 @@ describe('MediaDatabaseCard', () => { render(); - const button = screen.getByRole('button', { name: /settings\.updateDb/i }); - fireEvent.click(button); + const buttons = screen.getAllByRole('button', { name: /settings\.updateDb/i }); + const updateButton = buttons.find(button => !button.textContent?.includes('settings.updateDb.allSystems')); + fireEvent.click(updateButton!); expect(CoreAPI.mediaGenerate).toHaveBeenCalledOnce(); }); @@ -119,7 +126,7 @@ describe('MediaDatabaseCard', () => { render(); - expect(screen.queryByText('250 media found')).not.toBeInTheDocument(); + expect(screen.queryByText('250 items scanned')).not.toBeInTheDocument(); // Wait for the query to resolve expect(await screen.findByText('settings.updateDb.status.ready')).toBeInTheDocument(); }); @@ -133,7 +140,7 @@ describe('MediaDatabaseCard', () => { render(); // Wait for the query to resolve - expect(await screen.findByText('create.search.gamesDbUpdate')).toBeInTheDocument(); + expect(await screen.findByText('No database found')).toBeInTheDocument(); }); it('should show checking status when loading', async () => { @@ -231,4 +238,77 @@ describe('MediaDatabaseCard', () => { const pulsingElements = container.querySelectorAll('.animate-pulse'); expect(pulsingElements.length).toBeGreaterThan(0); }); + + it('should show cancel button when indexing', () => { + mockStore.gamesIndex.indexing = true; + + render(); + + const cancelButton = screen.getByRole('button', { name: /settings\.updateDb\.cancel/i }); + expect(cancelButton).toBeInTheDocument(); + expect(cancelButton).not.toBeDisabled(); + }); + + it('should call CoreAPI.mediaGenerateCancel when cancel button is clicked', async () => { + mockStore.gamesIndex.indexing = true; + const { CoreAPI } = await import('../../../lib/coreApi'); + + render(); + + const cancelButton = screen.getByRole('button', { name: /settings\.updateDb\.cancel/i }); + fireEvent.click(cancelButton); + + expect(CoreAPI.mediaGenerateCancel).toHaveBeenCalledOnce(); + }); + + it('should not show cancel button when not indexing', () => { + mockStore.gamesIndex.indexing = false; + + render(); + + const cancelButton = screen.queryByRole('button', { name: /settings\.updateDb\.cancel/i }); + expect(cancelButton).not.toBeInTheDocument(); + }); + + it('should keep cancel button in "Cancelling..." state after API call completes (regression test)', async () => { + // REGRESSION TEST: This test prevents re-introducing a bug where the cancel button + // immediately reverts to "Cancel" state after the API call completes, even though + // the actual cancellation is still happening in the background on zaparoo-core. + // + // The bug was caused by a `finally` block that reset `isCancelling` state immediately + // after the API call. The correct behavior is to keep the button in "Cancelling..." + // state until a WebSocket notification confirms that indexing has stopped. + + mockStore.gamesIndex.indexing = true; + const { CoreAPI } = await import('../../../lib/coreApi'); + + // Mock the cancel API to resolve successfully + vi.mocked(CoreAPI.mediaGenerateCancel).mockResolvedValue(undefined); + + render(); + + // Find and click the cancel button + const cancelButton = screen.getByRole('button', { name: /settings\.updateDb\.cancel/i }); + fireEvent.click(cancelButton); + + // Wait for the API call to complete + await vi.waitFor(() => { + expect(CoreAPI.mediaGenerateCancel).toHaveBeenCalledOnce(); + }); + + // CRITICAL ASSERTION: After the API call completes, the button should STILL + // be in the "Cancelling..." state (disabled with "cancelling" text), + // NOT reverted back to "Cancel" state. + // + // This is because the actual cancellation is happening in the background, + // and we need to wait for the WebSocket notification (indexing: false) to confirm. + const buttonAfterApiCall = screen.getByRole('button', { name: /cancelling/i }); + expect(buttonAfterApiCall).toBeInTheDocument(); + expect(buttonAfterApiCall).toBeDisabled(); + + // Verify it's NOT showing the normal "Cancel" text + expect(screen.queryByRole('button', { name: /^settings\.updateDb\.cancel$/i })).not.toBeInTheDocument(); + }); + + // TODO: Add tests for optimization progress and total media count when query mocking is fixed }); \ No newline at end of file diff --git a/src/__tests__/unit/components/MediaSearchModal.test.tsx b/src/__tests__/unit/components/MediaSearchModal.test.tsx index 4f7586b5..16cafd01 100644 --- a/src/__tests__/unit/components/MediaSearchModal.test.tsx +++ b/src/__tests__/unit/components/MediaSearchModal.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MediaSearchModal } from "@/components/MediaSearchModal"; @@ -45,6 +45,11 @@ vi.mock("@/lib/coreApi", () => ({ { path: "/games/mario.sfc", name: "Super Mario World", systemName: "Super Nintendo" }, ], total: 1, + pagination: { + nextCursor: null, + hasNextPage: false, + pageSize: 1, + }, }), }, })); @@ -72,20 +77,33 @@ vi.mock("@/components/wui/TextInput", () => ({ ), })); -vi.mock("@/components/SearchResults", () => ({ - SearchResults: ({ resp, setSelectedResult }: any) => ( -
- {resp?.results?.map((result: any, index: number) => ( +vi.mock("@/components/VirtualSearchResults", () => ({ + VirtualSearchResults: ({ query: _query, systems: _systems, selectedResult: _selectedResult, setSelectedResult, hasSearched }: any) => { + if (!hasSearched) { + return ( +
+

create.search.startSearching

+

create.search.startSearchingHint

+
+ ); + } + + return ( +
- ))} -
- ), +
+ ); + }, })); vi.mock("@/components/BackToTop", () => ({ @@ -94,6 +112,19 @@ vi.mock("@/components/BackToTop", () => ({ ), })); +vi.mock("@/components/SystemSelector", () => ({ + SystemSelector: ({ isOpen }: any) => + isOpen ?
: null, + SystemSelectorTrigger: ({ selectedSystems, placeholder, onClick }: any) => ( + + ), +})); + describe("MediaSearchModal", () => { const mockClose = vi.fn(); const mockOnSelect = vi.fn(); @@ -139,10 +170,21 @@ describe("MediaSearchModal", () => { expect(searchInput).toHaveAttribute("placeholder", "create.search.gameInputPlaceholder"); }); - it("should render search results component", () => { + it("should render search results component", async () => { renderComponent(); - expect(screen.getByTestId("search-results")).toBeInTheDocument(); + // Enter search query + const searchInput = screen.getByTestId("search-input"); + fireEvent.change(searchInput, { target: { value: "mario" } }); + + // Click search button to trigger search + const searchButton = screen.getByRole("button", { name: /create.search.searchButton/i }); + fireEvent.click(searchButton); + + // Wait for search results to appear + await waitFor(() => { + expect(screen.getByTestId("search-results")).toBeInTheDocument(); + }); }); it("should render back to top component", () => { @@ -169,6 +211,10 @@ describe("MediaSearchModal", () => { const searchInput = screen.getByTestId("search-input"); fireEvent.change(searchInput, { target: { value: "mario" } }); + // Click search button to trigger search + const searchButton = screen.getByRole("button", { name: /create.search.searchButton/i }); + fireEvent.click(searchButton); + // Wait for search results to load and contain results await waitFor(() => { expect(screen.getByTestId("result-0")).toBeInTheDocument(); @@ -178,7 +224,7 @@ describe("MediaSearchModal", () => { const resultButton = screen.getByTestId("result-0"); fireEvent.click(resultButton); - expect(mockOnSelect).toHaveBeenCalledWith("/games/mario.sfc"); + expect(mockOnSelect).toHaveBeenCalledWith("**launch:/games/mario.sfc"); }); it("should not render when closed", () => { @@ -187,20 +233,4 @@ describe("MediaSearchModal", () => { const modal = screen.getByTestId("slide-modal"); expect(modal).toHaveAttribute("data-open", "false"); }); - - it("should focus input when modal opens", async () => { - const focusSpy = vi.fn(); - - // Mock the input ref focus method - vi.spyOn(HTMLElement.prototype, 'focus').mockImplementation(focusSpy); - - renderComponent({ isOpen: true }); - - // Use act to properly handle React state updates and async operations - await act(async () => { - await new Promise(resolve => setTimeout(resolve, 350)); - }); - - expect(focusSpy).toHaveBeenCalled(); - }); }); \ No newline at end of file diff --git a/src/__tests__/unit/components/ProPurchase.test.tsx b/src/__tests__/unit/components/ProPurchase.test.tsx index 37d207ce..abceff88 100644 --- a/src/__tests__/unit/components/ProPurchase.test.tsx +++ b/src/__tests__/unit/components/ProPurchase.test.tsx @@ -91,7 +91,7 @@ describe('RestorePuchasesButton', () => { const button = screen.getByTestId('button'); expect(button).toBeInTheDocument(); - expect(button).toHaveTextContent('settings.advanced.restorePurchases'); + expect(button).toHaveTextContent('settings.app.restorePurchases'); }); it('should handle successful restore with active entitlement', async () => { @@ -120,10 +120,12 @@ describe('RestorePuchasesButton', () => { }); const { Preferences } = await import('@capacitor/preferences'); - expect(Preferences.set).toHaveBeenCalledWith({ - key: 'launcherAccess', - value: 'true' - }); + // Preferences.set is now called with app-preferences key and full state + expect(Preferences.set).toHaveBeenCalled(); + const setCall = vi.mocked(Preferences.set).mock.calls[0][0]; + expect(setCall.key).toBe('app-preferences'); + const state = JSON.parse(setCall.value); + expect(state.state.launcherAccess).toBe(true); expect(window.location.reload).toHaveBeenCalled(); }); @@ -253,10 +255,12 @@ describe('useProPurchase', () => { }); const { Preferences } = await import('@capacitor/preferences'); - expect(Preferences.set).toHaveBeenCalledWith({ - key: 'launcherAccess', - value: 'true' - }); + // Preferences.set is now called with app-preferences key and full state + expect(Preferences.set).toHaveBeenCalled(); + const setCall = vi.mocked(Preferences.set).mock.calls[0][0]; + expect(setCall.key).toBe('app-preferences'); + const state = JSON.parse(setCall.value); + expect(state.state.launcherAccess).toBe(true); }); it('should render PurchaseModal component', async () => { diff --git a/src/__tests__/unit/components/SimpleSystemSelect.test.tsx b/src/__tests__/unit/components/SimpleSystemSelect.test.tsx new file mode 100644 index 00000000..b69828df --- /dev/null +++ b/src/__tests__/unit/components/SimpleSystemSelect.test.tsx @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { SimpleSystemSelect } from "@/components/SimpleSystemSelect"; +import { CoreAPI } from "@/lib/coreApi"; +import { useStatusStore } from "@/lib/store"; + +// Mock CoreAPI +vi.mock("@/lib/coreApi", () => ({ + CoreAPI: { + systems: vi.fn() + } +})); + +// Mock store +vi.mock("@/lib/store", () => ({ + useStatusStore: vi.fn() +})); + +const mockSystems = { + systems: [ + { id: "snes", name: "Super Nintendo", category: "Nintendo" }, + { id: "nes", name: "Nintendo Entertainment System", category: "Nintendo" }, + { id: "genesis", name: "Sega Genesis", category: "Sega" }, + { id: "ps1", name: "PlayStation", category: "Sony" }, + { id: "n64", name: "Nintendo 64", category: "Nintendo" }, + { id: "saturn", name: "Sega Saturn", category: "Sega" } + ] +}; + +describe("SimpleSystemSelect", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false } + } + }); + + vi.mocked(CoreAPI.systems).mockResolvedValue(mockSystems); + vi.mocked(useStatusStore).mockReturnValue({ + gamesIndex: { indexing: false, exists: true } + }); + }); + + const renderComponent = (props: { + value: string; + onSelect: (systemId: string) => void; + placeholder?: string; + includeAllOption?: boolean; + className?: string; + }) => { + return render( + + + + ); + }; + + it("renders a select element", () => { + const onSelect = vi.fn(); + renderComponent({ value: "", onSelect }); + + const select = screen.getByRole("combobox"); + expect(select).toBeInTheDocument(); + }); + + it("displays grouped systems by category", async () => { + const onSelect = vi.fn(); + renderComponent({ value: "", onSelect }); + + await waitFor(() => { + // Check optgroups exist + const nintendoGroup = screen.getByRole("group", { name: "Nintendo" }); + const segaGroup = screen.getByRole("group", { name: "Sega" }); + const sonyGroup = screen.getByRole("group", { name: "Sony" }); + + expect(nintendoGroup).toBeInTheDocument(); + expect(segaGroup).toBeInTheDocument(); + expect(sonyGroup).toBeInTheDocument(); + }); + }); + + it("sorts systems alphabetically within categories", async () => { + const onSelect = vi.fn(); + renderComponent({ value: "", onSelect }); + + await waitFor(() => { + const options = screen.getAllByRole("option"); + const nintendoOptions = options.filter( + (opt) => + opt.textContent === "Nintendo Entertainment System" || + opt.textContent === "Nintendo 64" || + opt.textContent === "Super Nintendo" + ); + + // NES comes before N64 which comes before SNES alphabetically + expect(nintendoOptions[0].textContent).toBe("Nintendo 64"); + expect(nintendoOptions[1].textContent).toBe("Nintendo Entertainment System"); + expect(nintendoOptions[2].textContent).toBe("Super Nintendo"); + }); + }); + + it("calls onSelect when a system is selected", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + renderComponent({ value: "", onSelect }); + + await waitFor(() => { + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + const select = screen.getByRole("combobox"); + await user.selectOptions(select, "snes"); + + expect(onSelect).toHaveBeenCalledWith("snes"); + }); + + it("displays placeholder option when provided", () => { + const onSelect = vi.fn(); + renderComponent({ value: "", onSelect, placeholder: "Select a system" }); + + const placeholder = screen.getByRole("option", { name: "Select a system" }); + expect(placeholder).toBeInTheDocument(); + }); + + it("includes 'All Systems' option when includeAllOption is true", async () => { + const onSelect = vi.fn(); + renderComponent({ value: "", onSelect, includeAllOption: true }); + + await waitFor(() => { + const allOption = screen.getByRole("option", { name: "systemSelector.allSystems" }); + expect(allOption).toBeInTheDocument(); + }); + }); + + it("does not include 'All Systems' option when includeAllOption is false", async () => { + const onSelect = vi.fn(); + renderComponent({ value: "", onSelect, includeAllOption: false }); + + await waitFor(() => { + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + const allOption = screen.queryByRole("option", { name: "systemSelector.allSystems" }); + expect(allOption).not.toBeInTheDocument(); + }); + + it("displays selected value", async () => { + const onSelect = vi.fn(); + renderComponent({ value: "snes", onSelect }); + + await waitFor(() => { + const select = screen.getByRole("combobox") as HTMLSelectElement; + expect(select.value).toBe("snes"); + }); + }); + + it("disables select when indexing", () => { + vi.mocked(useStatusStore).mockReturnValue({ + gamesIndex: { indexing: true, exists: true } + }); + + const onSelect = vi.fn(); + renderComponent({ value: "", onSelect }); + + const select = screen.getByRole("combobox"); + expect(select).toBeDisabled(); + }); + + it("disables select when loading", () => { + vi.mocked(CoreAPI.systems).mockReturnValue(new Promise(() => {})); // Never resolves + + const onSelect = vi.fn(); + renderComponent({ value: "", onSelect }); + + const select = screen.getByRole("combobox"); + expect(select).toBeDisabled(); + }); + + it("shows loading option while data is being fetched", () => { + vi.mocked(CoreAPI.systems).mockReturnValue(new Promise(() => {})); + + const onSelect = vi.fn(); + renderComponent({ value: "", onSelect }); + + const loadingOption = screen.getByRole("option", { name: "loading" }); + expect(loadingOption).toBeInTheDocument(); + }); + + it("applies custom className", () => { + const onSelect = vi.fn(); + renderComponent({ value: "", onSelect, className: "custom-class" }); + + const select = screen.getByRole("combobox"); + expect(select).toHaveClass("custom-class"); + }); +}); diff --git a/src/__tests__/unit/components/SlideModal.test.tsx b/src/__tests__/unit/components/SlideModal.test.tsx index 9993833f..fd76af10 100644 --- a/src/__tests__/unit/components/SlideModal.test.tsx +++ b/src/__tests__/unit/components/SlideModal.test.tsx @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from "vitest"; +import React from "react"; import { render, screen, fireEvent } from "../../../test-utils"; import { SlideModal } from "../../../components/SlideModal"; @@ -13,7 +14,10 @@ vi.mock("../../../hooks/useSlideModalManager", () => ({ registerModal: vi.fn(), unregisterModal: vi.fn(), closeAllExcept: vi.fn() - })) + })), + SlideModalContext: { + Provider: ({ children }: { children: React.ReactNode }) => children + } })); // Mock store diff --git a/src/__tests__/unit/components/SystemsSearchModal.test.tsx b/src/__tests__/unit/components/SystemsSearchModal.test.tsx deleted file mode 100644 index efb6dc8f..00000000 --- a/src/__tests__/unit/components/SystemsSearchModal.test.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import { render, screen, waitFor, fireEvent } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { SystemsSearchModal } from '@/components/SystemsSearchModal'; -import { CoreAPI } from '@/lib/coreApi'; -import { vi } from 'vitest'; - -// Mock the translation hook -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key - }) -})); - -// Mock CoreAPI -vi.mock('@/lib/coreApi', () => ({ - CoreAPI: { - systems: vi.fn() - } -})); - -// Mock SlideModal -vi.mock('@/components/SlideModal', () => ({ - SlideModal: ({ children, isOpen, title }: any) => - isOpen ? ( -
-

{title}

- {children} -
- ) : null -})); - -// Mock TextInput -vi.mock('@/components/wui/TextInput', () => ({ - TextInput: ({ value, setValue, placeholder, type, className }: any) => ( - setValue(e.target.value)} - placeholder={placeholder} - className={className} - /> - ) -})); - -// Mock Button -vi.mock('@/components/wui/Button', () => ({ - Button: ({ label, onClick, variant, className }: any) => ( - - ) -})); - -const mockSystems = { - systems: [ - { id: 'nes', name: 'Nintendo Entertainment System', category: 'Nintendo' }, - { id: 'snes', name: 'Super Nintendo', category: 'Nintendo' }, - { id: 'genesis', name: 'Sega Genesis', category: 'Sega' }, - { id: 'arcade', name: 'Arcade Games', category: 'Arcade' }, - { id: 'other', name: 'Other System', category: 'Other' } - ] -}; - -const renderWithQueryClient = (component: React.ReactElement) => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false } - } - }); - - return render( - - {component} - - ); -}; - -describe('SystemsSearchModal', () => { - const mockProps = { - isOpen: true, - close: vi.fn(), - onSelect: vi.fn() - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should render when open', async () => { - vi.mocked(CoreAPI.systems).mockResolvedValue(mockSystems); - - renderWithQueryClient(); - - expect(screen.getByTestId('slide-modal')).toBeInTheDocument(); - expect(screen.getByText('create.custom.selectSystem')).toBeInTheDocument(); - }); - - it('should not render when closed', () => { - vi.mocked(CoreAPI.systems).mockResolvedValue(mockSystems); - - renderWithQueryClient(); - - expect(screen.queryByTestId('slide-modal')).not.toBeInTheDocument(); - }); - - it('should render search input', async () => { - vi.mocked(CoreAPI.systems).mockResolvedValue(mockSystems); - - renderWithQueryClient(); - - const input = screen.getByTestId('text-input'); - expect(input).toBeInTheDocument(); - expect(input).toHaveAttribute('placeholder', 'create.search.systemInput'); - }); - - it('should show loading state', async () => { - vi.mocked(CoreAPI.systems).mockImplementation(() => - new Promise(() => {}) // Never resolves - ); - - renderWithQueryClient(); - - expect(screen.getByText('loading')).toBeInTheDocument(); - }); - - it('should show error state', async () => { - vi.mocked(CoreAPI.systems).mockRejectedValue(new Error('API Error')); - - renderWithQueryClient(); - - await waitFor(() => { - expect(screen.getByText('error')).toBeInTheDocument(); - }); - }); - - it('should display systems grouped by category', async () => { - vi.mocked(CoreAPI.systems).mockResolvedValue(mockSystems); - - renderWithQueryClient(); - - await waitFor(() => { - expect(screen.getByText('Nintendo')).toBeInTheDocument(); - expect(screen.getByText('Sega')).toBeInTheDocument(); - expect(screen.getByText('Arcade')).toBeInTheDocument(); - expect(screen.getByText('Other')).toBeInTheDocument(); - }); - - expect(screen.getByText('Nintendo Entertainment System')).toBeInTheDocument(); - expect(screen.getByText('Super Nintendo')).toBeInTheDocument(); - expect(screen.getByText('Sega Genesis')).toBeInTheDocument(); - }); - - it('should filter systems by search text', async () => { - vi.mocked(CoreAPI.systems).mockResolvedValue(mockSystems); - - renderWithQueryClient(); - - await waitFor(() => { - expect(screen.getByText('Nintendo Entertainment System')).toBeInTheDocument(); - }); - - const input = screen.getByTestId('text-input'); - fireEvent.change(input, { target: { value: 'nintendo' } }); - - await waitFor(() => { - expect(screen.getByText('Nintendo Entertainment System')).toBeInTheDocument(); - expect(screen.getByText('Super Nintendo')).toBeInTheDocument(); - expect(screen.queryByText('Sega Genesis')).not.toBeInTheDocument(); - }); - }); - - it('should handle system selection', async () => { - vi.mocked(CoreAPI.systems).mockResolvedValue(mockSystems); - - renderWithQueryClient(); - - await waitFor(() => { - expect(screen.getByText('Nintendo Entertainment System')).toBeInTheDocument(); - }); - - const systemButtons = screen.getAllByTestId('system-button'); - const nesButton = systemButtons.find(button => - button.textContent === 'Nintendo Entertainment System' - ); - - expect(nesButton).toBeInTheDocument(); - fireEvent.click(nesButton!); - - expect(mockProps.onSelect).toHaveBeenCalledWith('nes'); - expect(mockProps.close).toHaveBeenCalled(); - }); - - it('should handle empty filter results', async () => { - vi.mocked(CoreAPI.systems).mockResolvedValue(mockSystems); - - renderWithQueryClient(); - - await waitFor(() => { - expect(screen.getByText('Nintendo Entertainment System')).toBeInTheDocument(); - }); - - const input = screen.getByTestId('text-input'); - fireEvent.change(input, { target: { value: 'nonexistent' } }); - - await waitFor(() => { - expect(screen.queryByText('Nintendo Entertainment System')).not.toBeInTheDocument(); - expect(screen.queryByText('Nintendo')).not.toBeInTheDocument(); - }); - }); - - it('should handle systems without category', async () => { - vi.mocked(CoreAPI.systems).mockResolvedValue(mockSystems); - - renderWithQueryClient(); - - await waitFor(() => { - expect(screen.getByText('Other')).toBeInTheDocument(); - expect(screen.getByText('Other System')).toBeInTheDocument(); - }); - }); - - it('should sort categories alphabetically', async () => { - const systemsWithMultipleCategories = { - systems: [ - { id: 'zx', name: 'ZX Spectrum', category: 'ZX Systems' }, - { id: 'apple', name: 'Apple II', category: 'Apple' }, - { id: 'commodore', name: 'Commodore 64', category: 'Commodore' } - ] - }; - - vi.mocked(CoreAPI.systems).mockResolvedValue(systemsWithMultipleCategories); - - renderWithQueryClient(); - - await waitFor(() => { - expect(screen.getByText('Apple')).toBeInTheDocument(); - expect(screen.getByText('Commodore')).toBeInTheDocument(); - expect(screen.getByText('ZX Systems')).toBeInTheDocument(); - }); - - // Check that categories appear in alphabetical order by looking for category headers specifically - const categories = ['Apple', 'Commodore', 'ZX Systems']; - categories.forEach(category => { - expect(screen.getByText(category)).toBeInTheDocument(); - }); - - // Verify the HTML structure has categories in the right order - const categoryContainers = screen.getAllByRole('generic').filter(el => - el.id && el.id.startsWith('category-') - ); - - expect(categoryContainers[0]).toHaveAttribute('id', 'category-Apple'); - expect(categoryContainers[1]).toHaveAttribute('id', 'category-Commodore'); - expect(categoryContainers[2]).toHaveAttribute('id', 'category-ZX Systems'); - }); - - it('should handle case-insensitive filtering', async () => { - vi.mocked(CoreAPI.systems).mockResolvedValue(mockSystems); - - renderWithQueryClient(); - - await waitFor(() => { - expect(screen.getByText('Nintendo Entertainment System')).toBeInTheDocument(); - }); - - const input = screen.getByTestId('text-input'); - fireEvent.change(input, { target: { value: 'NINTENDO' } }); - - await waitFor(() => { - expect(screen.getByText('Nintendo Entertainment System')).toBeInTheDocument(); - expect(screen.getByText('Super Nintendo')).toBeInTheDocument(); - expect(screen.queryByText('Sega Genesis')).not.toBeInTheDocument(); - }); - }); -}); \ No newline at end of file diff --git a/src/__tests__/unit/components/TourInitializer.test.tsx b/src/__tests__/unit/components/TourInitializer.test.tsx new file mode 100644 index 00000000..358eeeab --- /dev/null +++ b/src/__tests__/unit/components/TourInitializer.test.tsx @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { TourInitializer } from "../../../components/TourInitializer"; +import { usePreferencesStore } from "../../../lib/preferencesStore"; +import { useStatusStore } from "../../../lib/store"; + +// Mock dependencies +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => vi.fn() +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key + }) +})); + +vi.mock("../../../lib/tourService", () => ({ + createAppTour: vi.fn(() => ({ + start: vi.fn(), + on: vi.fn((event, callback) => { + // Store callbacks for testing + if (!mockTourCallbacks[event]) { + mockTourCallbacks[event] = []; + } + mockTourCallbacks[event].push(callback); + }) + })) +})); + +let mockTourCallbacks: Record void>> = {}; + +describe("TourInitializer", () => { + let mockSetTourCompleted: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockTourCallbacks = {}; + vi.useFakeTimers(); + + mockSetTourCompleted = vi.fn(); + + // Reset stores + usePreferencesStore.setState({ + tourCompleted: false, + setTourCompleted: mockSetTourCompleted + }); + + useStatusStore.setState({ + connected: false + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("should not start tour when tourCompleted is true", async () => { + usePreferencesStore.setState({ tourCompleted: true }); + + const { createAppTour } = await import("../../../lib/tourService"); + + renderHook(() => TourInitializer()); + + // Fast-forward timers + vi.advanceTimersByTime(1000); + + expect(createAppTour).not.toHaveBeenCalled(); + }); + + it("should start tour when tourCompleted is false", async () => { + const { createAppTour } = await import("../../../lib/tourService"); + + renderHook(() => TourInitializer()); + + // Fast-forward timers + vi.runAllTimers(); + + expect(createAppTour).toHaveBeenCalled(); + }); + + it("should pass connected state to createAppTour", async () => { + useStatusStore.setState({ connected: true }); + + const { createAppTour } = await import("../../../lib/tourService"); + + renderHook(() => TourInitializer()); + + vi.runAllTimers(); + + expect(createAppTour).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Function), + true // connected = true + ); + }); + + it("should mark tour as completed when tour completes", async () => { + const { createAppTour } = await import("../../../lib/tourService"); + + renderHook(() => TourInitializer()); + + vi.runAllTimers(); + + expect(createAppTour).toHaveBeenCalled(); + + // Trigger the complete callback + const completeCallback = mockTourCallbacks["complete"]?.[0]; + expect(completeCallback).toBeDefined(); + completeCallback?.(); + + expect(mockSetTourCompleted).toHaveBeenCalledWith(true); + }); + + it("should mark tour as completed when tour is cancelled", async () => { + const { createAppTour } = await import("../../../lib/tourService"); + + renderHook(() => TourInitializer()); + + vi.runAllTimers(); + + expect(createAppTour).toHaveBeenCalled(); + + // Trigger the cancel callback + const cancelCallback = mockTourCallbacks["cancel"]?.[0]; + expect(cancelCallback).toBeDefined(); + cancelCallback?.(); + + expect(mockSetTourCompleted).toHaveBeenCalledWith(true); + }); + + it("should cleanup timer on unmount", async () => { + const { createAppTour } = await import("../../../lib/tourService"); + + const { unmount } = renderHook(() => TourInitializer()); + + unmount(); + + vi.advanceTimersByTime(1000); + + // Tour should not have been created since component unmounted + expect(createAppTour).not.toHaveBeenCalled(); + }); + + it("should wait 1 second before starting tour", async () => { + const { createAppTour } = await import("../../../lib/tourService"); + + renderHook(() => TourInitializer()); + + // Before 1 second + vi.advanceTimersByTime(999); + expect(createAppTour).not.toHaveBeenCalled(); + + // After 1 second + vi.advanceTimersByTime(1); + + expect(createAppTour).toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/unit/components/VirtualSearchResults.test.tsx b/src/__tests__/unit/components/VirtualSearchResults.test.tsx new file mode 100644 index 00000000..d2a8537b --- /dev/null +++ b/src/__tests__/unit/components/VirtualSearchResults.test.tsx @@ -0,0 +1,323 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { vi, beforeEach, describe, it, expect } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { VirtualSearchResults } from "@/components/VirtualSearchResults"; +import { CoreAPI } from "@/lib/coreApi"; +import "@/test-setup"; + +// Mock dependencies +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("@/lib/store", () => ({ + useStatusStore: vi.fn((selector) => { + const state = { + connected: true, + gamesIndex: { exists: true, indexing: false }, + }; + return selector ? selector(state) : state; + }), +})); + +vi.mock("@tanstack/react-router", () => ({ + Link: ({ children, to }: any) => {children}, +})); + +// Create mock for virtualizer +const mockGetVirtualItems = vi.fn(); +const mockMeasureElement = vi.fn(); + +vi.mock("@tanstack/react-virtual", () => ({ + useVirtualizer: () => ({ + getVirtualItems: mockGetVirtualItems, + getTotalSize: () => 5000, + measureElement: mockMeasureElement, + }), +})); + +describe("VirtualSearchResults - Infinite Scrolling Regression Tests", () => { + let queryClient: QueryClient; + + // Helper to create mock search results + const createMockResults = (count: number, startIndex: number = 0) => { + return Array.from({ length: count }, (_, i) => ({ + name: `Game ${startIndex + i}`, + path: `/games/game${startIndex + i}.rom`, + system: { + id: "snes", + name: "Super Nintendo", + category: "Console", + manufacturer: "Nintendo", + releaseDate: "1990-11-21", + }, + tags: [], + })); + }; + + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + mockGetVirtualItems.mockReturnValue([]); + }); + + const renderComponent = (props = {}) => { + const defaultProps = { + query: "test", + systems: [], + tags: [], + selectedResult: null, + setSelectedResult: vi.fn(), + hasSearched: true, + scrollContainerRef: { current: document.createElement("div") }, + ...props, + }; + + return render( + + + + ); + }; + + it("should trigger fetchNextPage when scrolling near the end of current results", async () => { + // Mock first page response + const firstPageResults = createMockResults(100); + const mediaSearchSpy = vi.spyOn(CoreAPI, "mediaSearch"); + + mediaSearchSpy.mockResolvedValueOnce({ + results: firstPageResults, + total: 100, + pagination: { + nextCursor: "cursor-page-2", + hasNextPage: true, + pageSize: 100, + }, + }); + + const defaultProps = { + query: "test", + systems: [], + tags: [], + selectedResult: null, + setSelectedResult: vi.fn(), + hasSearched: true, + scrollContainerRef: { current: document.createElement("div") }, + }; + + const { rerender } = render( + + + + ); + + // Wait for initial data to load + await waitFor(() => { + expect(mediaSearchSpy).toHaveBeenCalledTimes(1); + }); + + // Simulate scrolling to position 96 (within 5 items of the end at index 99) + mockGetVirtualItems.mockReturnValue([ + { index: 96, key: 96, start: 9600, size: 100 }, + { index: 97, key: 97, start: 9700, size: 100 }, + { index: 98, key: 98, start: 9800, size: 100 }, + { index: 99, key: 99, start: 9900, size: 100 }, + ]); + + // Mock second page response + const secondPageResults = createMockResults(100, 100); + mediaSearchSpy.mockResolvedValueOnce({ + results: secondPageResults, + total: 100, + pagination: { + nextCursor: "cursor-page-3", + hasNextPage: true, + pageSize: 100, + }, + }); + + // Re-render to trigger the effect with new virtualItems + rerender( + + + + ); + + // Wait for second page to be fetched + await waitFor( + () => { + expect(mediaSearchSpy).toHaveBeenCalledTimes(2); + }, + { timeout: 3000 } + ); + + // Verify the second call included the cursor + expect(mediaSearchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + cursor: "cursor-page-2", + }) + ); + }); + + it("should NOT fetch next page when scrolled but not near the end", async () => { + const firstPageResults = createMockResults(100); + const mediaSearchSpy = vi.spyOn(CoreAPI, "mediaSearch"); + + mediaSearchSpy.mockResolvedValueOnce({ + results: firstPageResults, + total: 100, + pagination: { + nextCursor: "cursor-page-2", + hasNextPage: true, + pageSize: 100, + }, + }); + + const defaultProps = { + query: "test", + systems: [], + tags: [], + selectedResult: null, + setSelectedResult: vi.fn(), + hasSearched: true, + scrollContainerRef: { current: document.createElement("div") }, + }; + + const { rerender } = render( + + + + ); + + await waitFor(() => { + expect(mediaSearchSpy).toHaveBeenCalledTimes(1); + }); + + // Simulate scrolling to position 50 (not near the end) + mockGetVirtualItems.mockReturnValue([ + { index: 50, key: 50, start: 5000, size: 100 }, + { index: 51, key: 51, start: 5100, size: 100 }, + { index: 52, key: 52, start: 5200, size: 100 }, + ]); + + rerender( + + + + ); + + // Wait a bit to ensure no additional calls are made + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Should still only be 1 call (the initial one) + expect(mediaSearchSpy).toHaveBeenCalledTimes(1); + }); + + it("should NOT fetch next page when hasNextPage is false", async () => { + const firstPageResults = createMockResults(50); + const mediaSearchSpy = vi.spyOn(CoreAPI, "mediaSearch"); + + // Mock response with NO next page + mediaSearchSpy.mockResolvedValueOnce({ + results: firstPageResults, + total: 50, + pagination: { + nextCursor: null, + hasNextPage: false, + pageSize: 100, + }, + }); + + renderComponent(); + + await waitFor(() => { + expect(mediaSearchSpy).toHaveBeenCalledTimes(1); + }); + + // Simulate scrolling to the end + mockGetVirtualItems.mockReturnValue([ + { index: 48, key: 48, start: 4800, size: 100 }, + { index: 49, key: 49, start: 4900, size: 100 }, + ]); + + renderComponent(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Should still only be 1 call + expect(mediaSearchSpy).toHaveBeenCalledTimes(1); + }); + + it("should show loading indicator at the end when hasNextPage is true", async () => { + const firstPageResults = createMockResults(100); + const mediaSearchSpy = vi.spyOn(CoreAPI, "mediaSearch"); + + mediaSearchSpy.mockResolvedValueOnce({ + results: firstPageResults, + total: 100, + pagination: { + nextCursor: "cursor-page-2", + hasNextPage: true, + pageSize: 100, + }, + }); + + // Mock virtual items to include the loading sentinel + mockGetVirtualItems.mockReturnValue([ + { index: 98, key: 98, start: 9800, size: 100 }, + { index: 99, key: 99, start: 9900, size: 100 }, + { index: 100, key: 100, start: 10000, size: 60 }, // Loading sentinel + ]); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByTestId("search-results")).toBeInTheDocument(); + }); + + // The loading indicator should be present (it's at index >= totalCount) + const loadingIndicators = screen.getAllByText("create.search.loading"); + expect(loadingIndicators.length).toBeGreaterThan(0); + }); + + it("should NOT show loading indicator when hasNextPage is false", async () => { + const firstPageResults = createMockResults(50); + const mediaSearchSpy = vi.spyOn(CoreAPI, "mediaSearch"); + + mediaSearchSpy.mockResolvedValueOnce({ + results: firstPageResults, + total: 50, + pagination: { + nextCursor: null, + hasNextPage: false, + pageSize: 100, + }, + }); + + // Mock virtual items (no loading sentinel should be added) + mockGetVirtualItems.mockReturnValue([ + { index: 48, key: 48, start: 4800, size: 100 }, + { index: 49, key: 49, start: 4900, size: 100 }, + ]); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByTestId("search-results")).toBeInTheDocument(); + }); + + // Should not have a loading indicator in the results + const loadingTexts = screen.queryAllByText("create.search.loading"); + // Filter out the one in the aria-live region if present + const visibleLoading = loadingTexts.filter( + (el) => !el.classList.contains("sr-only") + ); + expect(visibleLoading.length).toBe(0); + }); +}); diff --git a/src/__tests__/unit/components/home/ConnectionStatus.test.tsx b/src/__tests__/unit/components/home/ConnectionStatus.test.tsx index c3a3ee53..f177610d 100644 --- a/src/__tests__/unit/components/home/ConnectionStatus.test.tsx +++ b/src/__tests__/unit/components/home/ConnectionStatus.test.tsx @@ -54,8 +54,8 @@ describe("ConnectionStatus", () => { it("renders reconnecting state with loading indicator", () => { render(); - - expect(screen.getByText("scan.reconnecting")).toBeInTheDocument(); + + expect(screen.getByText("scan.connecting")).toBeInTheDocument(); expect(screen.getByRole("button")).toBeInTheDocument(); }); diff --git a/src/__tests__/unit/components/home/ScanControls.test.tsx b/src/__tests__/unit/components/home/ScanControls.test.tsx index 5465f5b3..b814fe96 100644 --- a/src/__tests__/unit/components/home/ScanControls.test.tsx +++ b/src/__tests__/unit/components/home/ScanControls.test.tsx @@ -18,6 +18,13 @@ vi.mock("react-i18next", () => ({ }) })); +// Mock preferences store +vi.mock("../../../../lib/preferencesStore", () => ({ + usePreferencesStore: vi.fn(() => ({ + cameraAvailable: true + })) +})); + describe("ScanControls", () => { const mockProps = { scanSession: false, diff --git a/src/__tests__/unit/components/nfc/ReadTab.test.tsx b/src/__tests__/unit/components/nfc/ReadTab.test.tsx index 87b48fda..e062e2d3 100644 --- a/src/__tests__/unit/components/nfc/ReadTab.test.tsx +++ b/src/__tests__/unit/components/nfc/ReadTab.test.tsx @@ -38,13 +38,13 @@ describe('ReadTab', () => { it('should render scan button', () => { render(); - expect(screen.getByRole('button', { name: /scan tag/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /scanTag/i })).toBeInTheDocument(); }); it('should render tag information section', () => { render(); - expect(screen.getByText('Tag Information')).toBeInTheDocument(); + expect(screen.getByText('create.nfc.readTab.tagInformation')).toBeInTheDocument(); }); it('should show empty state when no result', () => { @@ -77,7 +77,7 @@ describe('ReadTab', () => { it('should call onScan when scan button is clicked', () => { render(); - fireEvent.click(screen.getByRole('button', { name: /scan tag/i })); + fireEvent.click(screen.getByRole('button', { name: /scanTag/i })); expect(mockOnScan).toHaveBeenCalledTimes(1); }); @@ -225,7 +225,7 @@ describe('ReadTab', () => { // Find share button by looking for the small outline variant button in header const buttons = screen.getAllByRole('button'); const shareButton = buttons.find(btn => - btn.className.includes('outline') && btn.className.includes('px-3') + btn.className.includes('outline') ); expect(shareButton).toBeDefined(); @@ -233,10 +233,12 @@ describe('ReadTab', () => { if (shareButton) { fireEvent.click(shareButton); await new Promise(resolve => setTimeout(resolve, 0)); // Allow async to complete - expect(mockShare).toHaveBeenCalledWith({ - title: "NFC Tag Data", - text: "UID: 04:12:34:56\nText: Test message" - }); + expect(mockShare).toHaveBeenCalledWith( + expect.objectContaining({ + title: "create.nfc.readTab.shareTitle", + text: expect.any(String) + }) + ); } }); @@ -259,7 +261,7 @@ describe('ReadTab', () => { // Find share button by class names const buttons = screen.getAllByRole('button'); const shareButton = buttons.find(btn => - btn.className.includes('outline') && btn.className.includes('px-3') + btn.className.includes('outline') ); expect(shareButton).toBeDefined(); @@ -267,7 +269,7 @@ describe('ReadTab', () => { if (shareButton) { fireEvent.click(shareButton); await new Promise(resolve => setTimeout(resolve, 0)); // Allow async to complete - expect(mockWriteText).toHaveBeenCalledWith("UID: 04:12:34:56\nText: Test message"); + expect(mockWriteText).toHaveBeenCalledWith(expect.any(String)); } }); @@ -294,7 +296,7 @@ describe('ReadTab', () => { // Find share button by class names const buttons = screen.getAllByRole('button'); const shareButton = buttons.find(btn => - btn.className.includes('outline') && btn.className.includes('px-3') + btn.className.includes('outline') ); expect(shareButton).toBeDefined(); @@ -302,7 +304,7 @@ describe('ReadTab', () => { if (shareButton) { fireEvent.click(shareButton); await new Promise(resolve => setTimeout(resolve, 0)); // Allow async to complete - expect(mockWriteText).toHaveBeenCalledWith("UID: 04:12:34:56\nText: Test message"); + expect(mockWriteText).toHaveBeenCalledWith(expect.any(String)); } // Restore share API @@ -393,7 +395,7 @@ describe('ReadTab', () => { it('should handle null result gracefully', () => { render(); - expect(screen.getByText('Tag Information')).toBeInTheDocument(); + expect(screen.getByText('create.nfc.readTab.tagInformation')).toBeInTheDocument(); // Component shows many placeholder fields when no data expect(screen.getAllByText('-').length).toBeGreaterThan(10); }); @@ -409,7 +411,7 @@ describe('ReadTab', () => { render(); - expect(screen.getByText('Tag Information')).toBeInTheDocument(); + expect(screen.getByText('create.nfc.readTab.tagInformation')).toBeInTheDocument(); // Component shows many placeholder fields when no tag data expect(screen.getAllByText('-').length).toBeGreaterThan(10); }); @@ -450,12 +452,12 @@ describe('ReadTab', () => { render(); // Scan button should be accessible - expect(screen.getByRole('button', { name: /scan tag/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /scanTag/i })).toBeInTheDocument(); // Share button should be accessible - find by class since it's icon-only const buttons = screen.getAllByRole('button'); const shareButton = buttons.find(btn => - btn.className.includes('outline') && btn.className.includes('px-3') + btn.className.includes('outline') ); expect(shareButton).toBeDefined(); }); diff --git a/src/__tests__/unit/components/wui/TextInput.test.tsx b/src/__tests__/unit/components/wui/TextInput.test.tsx index c8426fd5..cb6cbd86 100644 --- a/src/__tests__/unit/components/wui/TextInput.test.tsx +++ b/src/__tests__/unit/components/wui/TextInput.test.tsx @@ -37,21 +37,41 @@ describe('TextInput', () => { it('renders save button when saveValue prop is provided', async () => { const mockSaveValue = vi.fn(); const user = userEvent.setup(); - + render( - ); - + const input = screen.getByDisplayValue('initial'); await user.clear(input); await user.type(input, 'modified text'); - + const saveButton = screen.getByRole('button'); await user.click(saveButton); - + expect(mockSaveValue).toHaveBeenCalledWith('modified text'); }); + + it('calls onKeyUp handler when Enter key is pressed', async () => { + const mockOnKeyUp = vi.fn(); + const user = userEvent.setup(); + + render( + + ); + + const input = screen.getByPlaceholderText('Enter text'); + await user.type(input, 'test{Enter}'); + + expect(mockOnKeyUp).toHaveBeenCalled(); + const lastCall = mockOnKeyUp.mock.calls[mockOnKeyUp.mock.calls.length - 1][0]; + expect(lastCall.key).toBe('Enter'); + }); }); \ No newline at end of file diff --git a/src/__tests__/unit/hooks/useAppSettings.test.ts b/src/__tests__/unit/hooks/useAppSettings.test.ts index c064c2d4..2aad2880 100644 --- a/src/__tests__/unit/hooks/useAppSettings.test.ts +++ b/src/__tests__/unit/hooks/useAppSettings.test.ts @@ -13,10 +13,10 @@ describe("useAppSettings", () => { }); it("should initialize with provided initData", () => { - const initData = { restartScan: true, launchOnScan: false, launcherAccess: false, preferRemoteWriter: false }; + const initData = { restartScan: true, launchOnScan: false, launcherAccess: false, preferRemoteWriter: false, shakeEnabled: false, shakeMode: "random" as const, shakeZapscript: "" }; const { result } = renderHook(() => useAppSettings({ initData })); - + expect(result.current.restartScan).toBe(true); expect(result.current.launchOnScan).toBe(false); expect(result.current.launcherAccess).toBe(false); @@ -27,7 +27,10 @@ describe("useAppSettings", () => { restartScan: true, launchOnScan: false, launcherAccess: true, - preferRemoteWriter: true + preferRemoteWriter: true, + shakeEnabled: false, + shakeMode: "random" as const, + shakeZapscript: "" }; const { result } = renderHook(() => useAppSettings({ initData })); @@ -40,7 +43,7 @@ describe("useAppSettings", () => { }); it("should persist restartScan setting when changed", async () => { - const initData = { restartScan: false, launchOnScan: true, launcherAccess: false, preferRemoteWriter: false }; + const initData = { restartScan: false, launchOnScan: true, launcherAccess: false, preferRemoteWriter: false, shakeEnabled: false, shakeMode: "random" as const, shakeZapscript: "" }; const { result } = renderHook(() => useAppSettings({ initData })); @@ -57,7 +60,7 @@ describe("useAppSettings", () => { }); it("should persist launchOnScan setting when changed", async () => { - const initData = { restartScan: false, launchOnScan: true, launcherAccess: false, preferRemoteWriter: false }; + const initData = { restartScan: false, launchOnScan: true, launcherAccess: false, preferRemoteWriter: false, shakeEnabled: false, shakeMode: "random" as const, shakeZapscript: "" }; const { result } = renderHook(() => useAppSettings({ initData })); @@ -74,7 +77,7 @@ describe("useAppSettings", () => { }); it("should persist preferRemoteWriter setting when changed", async () => { - const initData = { restartScan: false, launchOnScan: true, launcherAccess: false, preferRemoteWriter: false }; + const initData = { restartScan: false, launchOnScan: true, launcherAccess: false, preferRemoteWriter: false, shakeEnabled: false, shakeMode: "random" as const, shakeZapscript: "" }; const { result } = renderHook(() => useAppSettings({ initData })); diff --git a/src/__tests__/unit/hooks/useWriteQueueProcessor.test.tsx b/src/__tests__/unit/hooks/useWriteQueueProcessor.test.tsx index 9be96b46..29d82c4c 100644 --- a/src/__tests__/unit/hooks/useWriteQueueProcessor.test.tsx +++ b/src/__tests__/unit/hooks/useWriteQueueProcessor.test.tsx @@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; import { useWriteQueueProcessor } from "../../../hooks/useWriteQueueProcessor"; import { useStatusStore } from "../../../lib/store"; +import { usePreferencesStore } from "../../../lib/preferencesStore"; +import { useNfcWriter } from "../../../lib/writeNfcHook"; import { Status } from "../../../lib/nfc"; import { Capacitor } from "@capacitor/core"; import { Nfc } from "@capawesome-team/capacitor-nfc"; @@ -13,6 +15,16 @@ vi.mock("../../../lib/store", () => ({ useStatusStore: vi.fn() })); +vi.mock("../../../lib/preferencesStore", () => ({ + usePreferencesStore: vi.fn() +})); + +vi.mock("../../../lib/writeNfcHook", () => ({ + useNfcWriter: vi.fn(), + WriteMethod: { Auto: "auto" }, + WriteAction: { Write: "write" } +})); + vi.mock("@capacitor/core", () => ({ Capacitor: { isNativePlatform: vi.fn() @@ -49,6 +61,8 @@ describe("useWriteQueueProcessor", () => { write: ReturnType; end: ReturnType; status: Status | null; + writing: boolean; + result: any; }; let mockSetWriteOpen: ReturnType; let mockSetWriteQueue: ReturnType; @@ -60,21 +74,38 @@ describe("useWriteQueueProcessor", () => { mockNfcWriter = { write: vi.fn().mockResolvedValue(undefined), end: vi.fn().mockResolvedValue(undefined), - status: null + status: null, + writing: false, + result: null }; mockSetWriteOpen = vi.fn(); mockSetWriteQueue = vi.fn(); - // Mock the store with empty queue initially - vi.mocked(useStatusStore).mockImplementation((selector: any) => { + // Mock useNfcWriter to return the mock writer + vi.mocked(useNfcWriter).mockReturnValue(mockNfcWriter); + + // Mock the preferences store + vi.mocked(usePreferencesStore).mockImplementation((selector: any) => { if (typeof selector === 'function') { return selector({ - writeQueue: "", - setWriteQueue: mockSetWriteQueue + preferRemoteWriter: false }); } - return mockSetWriteQueue; + return false; + }); + + // Mock the status store with empty queue initially + vi.mocked(useStatusStore).mockImplementation((selector: any) => { + const mockState = { + writeQueue: "", + setWriteQueue: mockSetWriteQueue, + setWriteOpen: mockSetWriteOpen + }; + if (typeof selector === 'function') { + return selector(mockState); + } + return mockState; }); // Reset default mock behaviors @@ -92,10 +123,7 @@ describe("useWriteQueueProcessor", () => { }); it("should not process when queue is empty", () => { - renderHook(() => useWriteQueueProcessor({ - nfcWriter: mockNfcWriter, - setWriteOpen: mockSetWriteOpen - })); + renderHook(() => useWriteQueueProcessor()); // Should not call any NFC writer methods when queue is empty expect(mockNfcWriter.write).not.toHaveBeenCalled(); @@ -106,19 +134,18 @@ describe("useWriteQueueProcessor", () => { it("should process write queue when NFC is available on native platform", async () => { // Setup: queue has content vi.mocked(useStatusStore).mockImplementation((selector: any) => { + const mockState = { + writeQueue: "test-write-content", + setWriteQueue: mockSetWriteQueue, + setWriteOpen: mockSetWriteOpen + }; if (typeof selector === 'function') { - return selector({ - writeQueue: "test-write-content", - setWriteQueue: mockSetWriteQueue - }); + return selector(mockState); } - return mockSetWriteQueue; + return mockState; }); - renderHook(() => useWriteQueueProcessor({ - nfcWriter: mockNfcWriter, - setWriteOpen: mockSetWriteOpen - })); + renderHook(() => useWriteQueueProcessor()); // Skip the 1000ms initial delay and flush async operations await act(async () => { @@ -137,19 +164,18 @@ describe("useWriteQueueProcessor", () => { vi.mocked(CoreAPI.hasWriteCapableReader).mockResolvedValue(true); vi.mocked(useStatusStore).mockImplementation((selector: any) => { + const mockState = { + writeQueue: "test-content", + setWriteQueue: mockSetWriteQueue, + setWriteOpen: mockSetWriteOpen + }; if (typeof selector === 'function') { - return selector({ - writeQueue: "test-content", - setWriteQueue: mockSetWriteQueue - }); + return selector(mockState); } - return mockSetWriteQueue; + return mockState; }); - renderHook(() => useWriteQueueProcessor({ - nfcWriter: mockNfcWriter, - setWriteOpen: mockSetWriteOpen - })); + renderHook(() => useWriteQueueProcessor()); // Skip the 1000ms initial delay and flush async operations await act(async () => { @@ -176,10 +202,7 @@ describe("useWriteQueueProcessor", () => { return mockSetWriteQueue; }); - renderHook(() => useWriteQueueProcessor({ - nfcWriter: mockNfcWriter, - setWriteOpen: mockSetWriteOpen - })); + renderHook(() => useWriteQueueProcessor()); // Skip the 1000ms initial delay and flush async operations await act(async () => { @@ -198,19 +221,18 @@ describe("useWriteQueueProcessor", () => { vi.mocked(CoreAPI.hasWriteCapableReader).mockResolvedValue(true); vi.mocked(useStatusStore).mockImplementation((selector: any) => { + const mockState = { + writeQueue: "web-content", + setWriteQueue: mockSetWriteQueue, + setWriteOpen: mockSetWriteOpen + }; if (typeof selector === 'function') { - return selector({ - writeQueue: "web-content", - setWriteQueue: mockSetWriteQueue - }); + return selector(mockState); } - return mockSetWriteQueue; + return mockState; }); - renderHook(() => useWriteQueueProcessor({ - nfcWriter: mockNfcWriter, - setWriteOpen: mockSetWriteOpen - })); + renderHook(() => useWriteQueueProcessor()); // Skip the 1000ms initial delay and flush async operations await act(async () => { @@ -237,10 +259,7 @@ describe("useWriteQueueProcessor", () => { return mockSetWriteQueue; }); - renderHook(() => useWriteQueueProcessor({ - nfcWriter: mockNfcWriter, - setWriteOpen: mockSetWriteOpen - })); + renderHook(() => useWriteQueueProcessor()); // Skip the 1000ms initial delay and flush async operations await act(async () => { @@ -253,10 +272,7 @@ describe("useWriteQueueProcessor", () => { }); it("should return reset function", () => { - const { result } = renderHook(() => useWriteQueueProcessor({ - nfcWriter: mockNfcWriter, - setWriteOpen: mockSetWriteOpen - })); + const { result } = renderHook(() => useWriteQueueProcessor()); expect(result.current.reset).toBeInstanceOf(Function); }); diff --git a/src/__tests__/unit/lib/preferencesStore.test.ts b/src/__tests__/unit/lib/preferencesStore.test.ts new file mode 100644 index 00000000..7a94fc31 --- /dev/null +++ b/src/__tests__/unit/lib/preferencesStore.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { usePreferencesStore } from "../../../lib/preferencesStore"; +import { act, renderHook } from "@testing-library/react"; + +// Mock Capacitor Preferences +vi.mock("@capacitor/preferences", () => ({ + Preferences: { + get: vi.fn().mockResolvedValue({ value: null }), + set: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined), + }, +})); + +// Mock sessionManager +vi.mock("../../../lib/nfc", () => ({ + sessionManager: { + setShouldRestart: vi.fn(), + setLaunchOnScan: vi.fn(), + }, +})); + +describe("usePreferencesStore", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset store state between tests + usePreferencesStore.setState({ + restartScan: false, + launchOnScan: true, + launcherAccess: false, + preferRemoteWriter: false, + shakeEnabled: false, + shakeMode: "random", + shakeZapscript: "", + _hasHydrated: true, // Pretend it's hydrated for tests + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should have correct default values", () => { + const { result } = renderHook(() => usePreferencesStore()); + + expect(result.current.restartScan).toBe(false); + expect(result.current.launchOnScan).toBe(true); + expect(result.current.launcherAccess).toBe(false); + expect(result.current.preferRemoteWriter).toBe(false); + expect(result.current.shakeEnabled).toBe(false); + expect(result.current.shakeMode).toBe("random"); + expect(result.current.shakeZapscript).toBe(""); + }); + + it("should update restartScan when setter is called", () => { + const { result } = renderHook(() => usePreferencesStore()); + + act(() => { + result.current.setRestartScan(true); + }); + + expect(result.current.restartScan).toBe(true); + }); + + it("should update launchOnScan when setter is called", () => { + const { result } = renderHook(() => usePreferencesStore()); + + act(() => { + result.current.setLaunchOnScan(false); + }); + + expect(result.current.launchOnScan).toBe(false); + }); + + it("should update preferRemoteWriter when setter is called", () => { + const { result } = renderHook(() => usePreferencesStore()); + + act(() => { + result.current.setPreferRemoteWriter(true); + }); + + expect(result.current.preferRemoteWriter).toBe(true); + }); + + it("should update shakeEnabled when setter is called", () => { + const { result } = renderHook(() => usePreferencesStore()); + + act(() => { + result.current.setShakeEnabled(true); + }); + + expect(result.current.shakeEnabled).toBe(true); + }); + + it("should clear zapscript when shakeMode changes", () => { + const { result } = renderHook(() => usePreferencesStore()); + + // Set some zapscript first + act(() => { + result.current.setShakeZapscript("**launch.system:snes"); + }); + + expect(result.current.shakeZapscript).toBe("**launch.system:snes"); + + // Change mode - should clear zapscript + act(() => { + result.current.setShakeMode("custom"); + }); + + expect(result.current.shakeMode).toBe("custom"); + expect(result.current.shakeZapscript).toBe(""); + }); + + it("should track hydration state", () => { + const { result } = renderHook(() => usePreferencesStore()); + + // In tests it starts as true (we set it in beforeEach) + expect(result.current._hasHydrated).toBe(true); + + // Test setting it + act(() => { + result.current.setHasHydrated(false); + }); + + expect(result.current._hasHydrated).toBe(false); + }); + + it("should expose app settings selector", () => { + const { result } = renderHook(() => usePreferencesStore()); + + // Update some values + act(() => { + result.current.setRestartScan(true); + result.current.setLaunchOnScan(false); + result.current.setPreferRemoteWriter(true); + }); + + expect(result.current.restartScan).toBe(true); + expect(result.current.launchOnScan).toBe(false); + expect(result.current.preferRemoteWriter).toBe(true); + }); + + it("should expose shake settings selector", () => { + const { result } = renderHook(() => usePreferencesStore()); + + // Update shake settings + act(() => { + result.current.setShakeEnabled(true); + result.current.setShakeMode("custom"); + result.current.setShakeZapscript("**some.command"); + }); + + expect(result.current.shakeEnabled).toBe(true); + expect(result.current.shakeMode).toBe("custom"); + expect(result.current.shakeZapscript).toBe("**some.command"); + }); +}); diff --git a/src/__tests__/unit/lib/store.gracePeriod.test.ts b/src/__tests__/unit/lib/store.gracePeriod.test.ts deleted file mode 100644 index f5b9fb94..00000000 --- a/src/__tests__/unit/lib/store.gracePeriod.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { useStatusStore, ConnectionState } from "../../../lib/store"; - -describe("Store Grace Period Logic", () => { - beforeEach(() => { - vi.useFakeTimers(); - // Reset store to initial state - useStatusStore.setState({ - connectionState: ConnectionState.IDLE, - connected: false, - pendingDisconnection: false - }); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.clearAllTimers(); - }); - - describe("setConnectionStateWithGracePeriod", () => { - it("should immediately set CONNECTED state without grace period", () => { - const store = useStatusStore.getState(); - - store.setConnectionStateWithGracePeriod(ConnectionState.CONNECTED); - - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.CONNECTED); - expect(useStatusStore.getState().connected).toBe(true); - expect(useStatusStore.getState().pendingDisconnection).toBe(false); - }); - - it("should immediately set ERROR state without grace period", () => { - const store = useStatusStore.getState(); - - store.setConnectionStateWithGracePeriod(ConnectionState.ERROR); - - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.ERROR); - expect(useStatusStore.getState().connected).toBe(false); - expect(useStatusStore.getState().pendingDisconnection).toBe(false); - }); - - it("should immediately set CONNECTING state without grace period", () => { - const store = useStatusStore.getState(); - - store.setConnectionStateWithGracePeriod(ConnectionState.CONNECTING); - - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.CONNECTING); - expect(useStatusStore.getState().connected).toBe(false); - expect(useStatusStore.getState().pendingDisconnection).toBe(false); - }); - - it("should delay RECONNECTING state during grace period when previously connected", () => { - // First set to connected - useStatusStore.setState({ - connectionState: ConnectionState.CONNECTED, - connected: true - }); - - const store = useStatusStore.getState(); - store.setConnectionStateWithGracePeriod(ConnectionState.RECONNECTING); - - // Should not change connection state immediately - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.CONNECTED); - expect(useStatusStore.getState().connected).toBe(true); - expect(useStatusStore.getState().pendingDisconnection).toBe(true); - }); - - it("should apply RECONNECTING state after grace period expires", () => { - // First set to connected - useStatusStore.setState({ - connectionState: ConnectionState.CONNECTED, - connected: true - }); - - const store = useStatusStore.getState(); - store.setConnectionStateWithGracePeriod(ConnectionState.RECONNECTING); - - // Fast-forward past grace period - vi.advanceTimersByTime(2000); - - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.RECONNECTING); - expect(useStatusStore.getState().connected).toBe(false); - expect(useStatusStore.getState().pendingDisconnection).toBe(false); - }); - - it("should cancel grace period if reconnected before expiration", () => { - // First set to connected - useStatusStore.setState({ - connectionState: ConnectionState.CONNECTED, - connected: true - }); - - const store = useStatusStore.getState(); - store.setConnectionStateWithGracePeriod(ConnectionState.RECONNECTING); - - // Before grace period expires, reconnect - vi.advanceTimersByTime(1000); - store.setConnectionStateWithGracePeriod(ConnectionState.CONNECTED); - - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.CONNECTED); - expect(useStatusStore.getState().connected).toBe(true); - expect(useStatusStore.getState().pendingDisconnection).toBe(false); - - // Even after original grace period would have expired - vi.advanceTimersByTime(2000); - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.CONNECTED); - }); - - it("should delay DISCONNECTED state during grace period when previously connected", () => { - // First set to connected - useStatusStore.setState({ - connectionState: ConnectionState.CONNECTED, - connected: true - }); - - const store = useStatusStore.getState(); - store.setConnectionStateWithGracePeriod(ConnectionState.DISCONNECTED); - - // Should not change connection state immediately - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.CONNECTED); - expect(useStatusStore.getState().connected).toBe(true); - expect(useStatusStore.getState().pendingDisconnection).toBe(true); - }); - - it("should immediately set RECONNECTING if not previously connected", () => { - // Start from non-connected state - useStatusStore.setState({ - connectionState: ConnectionState.IDLE, - connected: false - }); - - const store = useStatusStore.getState(); - store.setConnectionStateWithGracePeriod(ConnectionState.RECONNECTING); - - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.RECONNECTING); - expect(useStatusStore.getState().connected).toBe(false); - expect(useStatusStore.getState().pendingDisconnection).toBe(false); - }); - - it("should use fixed 2 second grace period", () => { - // First set to connected - useStatusStore.setState({ - connectionState: ConnectionState.CONNECTED, - connected: true - }); - - const store = useStatusStore.getState(); - store.setConnectionStateWithGracePeriod(ConnectionState.RECONNECTING); - - // Should still be connected after 1 second - vi.advanceTimersByTime(1000); - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.CONNECTED); - - // Should disconnect after 2 second grace period - vi.advanceTimersByTime(1000); - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.RECONNECTING); - }); - }); - - describe("clearGracePeriod", () => { - it("should cancel pending disconnection", () => { - // First set to connected, then trigger grace period - useStatusStore.setState({ - connectionState: ConnectionState.CONNECTED, - connected: true - }); - - const store = useStatusStore.getState(); - store.setConnectionStateWithGracePeriod(ConnectionState.RECONNECTING); - - expect(useStatusStore.getState().pendingDisconnection).toBe(true); - - store.clearGracePeriod(); - - expect(useStatusStore.getState().pendingDisconnection).toBe(false); - - // Timer should be cancelled - advancing time should not trigger state change - vi.advanceTimersByTime(3000); - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.CONNECTED); - }); - }); - -}); \ No newline at end of file diff --git a/src/__tests__/unit/lib/store.test.ts b/src/__tests__/unit/lib/store.test.ts index 8715e19f..18d7da37 100644 --- a/src/__tests__/unit/lib/store.test.ts +++ b/src/__tests__/unit/lib/store.test.ts @@ -153,8 +153,6 @@ describe("StatusStore", () => { expect(resetState.lastConnectionTime).toBe(null); expect(resetState.connectionError).toBe(""); expect(resetState.retryCount).toBe(0); - expect(resetState.pendingDisconnection).toBe(false); - expect(resetState.gracePeriodTimer).toBeUndefined(); expect(resetState.runQueue).toBe(null); expect(resetState.writeQueue).toBe(""); @@ -169,6 +167,7 @@ describe("StatusStore", () => { expect(resetState.gamesIndex).toEqual({ exists: true, indexing: false, + optimizing: false, totalSteps: 0, currentStep: 0, currentStepDisplay: "", @@ -182,22 +181,6 @@ describe("StatusStore", () => { }); }); - it("should clear grace period timer if one exists", () => { - const store = useStatusStore.getState(); - - // Simulate a grace period timer being set - const mockTimer = setTimeout(() => {}, 1000); - useStatusStore.setState({ gracePeriodTimer: mockTimer }); - - // Reset connection state - store.resetConnectionState(); - - // Verify timer is cleared - const state = useStatusStore.getState(); - expect(state.gracePeriodTimer).toBeUndefined(); - expect(state.pendingDisconnection).toBe(false); - }); - it("should not affect non-connection-related state", () => { const store = useStatusStore.getState(); diff --git a/src/__tests__/unit/lib/tourService.test.ts b/src/__tests__/unit/lib/tourService.test.ts new file mode 100644 index 00000000..73095598 --- /dev/null +++ b/src/__tests__/unit/lib/tourService.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { TourNavigate } from "../../../lib/tourService"; +import type { TFunction } from "i18next"; + +// Create reusable mock tour instance at module level +let mockTourInstance: any; +let mockTourConstructor: any; + +// Mock Shepherd +vi.mock("shepherd.js", () => { + mockTourInstance = { + addStep: vi.fn(), + start: vi.fn(), + next: vi.fn(), + back: vi.fn(), + cancel: vi.fn(), + complete: vi.fn(), + hide: vi.fn(), + on: vi.fn() + }; + + mockTourConstructor = vi.fn(() => mockTourInstance); + + return { + default: { + Tour: mockTourConstructor + } + }; +}); + +// Import after mock is set up +const { createAppTour } = await import("../../../lib/tourService"); + +describe("tourService - createAppTour", () => { + let mockNavigate: TourNavigate; + let mockT: TFunction; + + beforeEach(() => { + vi.clearAllMocks(); + mockTourConstructor.mockClear(); + mockTourInstance.addStep.mockClear(); + + mockNavigate = vi.fn(() => Promise.resolve()); + mockT = vi.fn((key: string, options?: any) => { + if (options) { + return `${key}:${JSON.stringify(options)}`; + } + return key; + }) as any as TFunction; + }); + + it("should create tour with 5 steps when not connected", () => { + const tour = createAppTour(mockNavigate, mockT, false); + + expect(mockTourConstructor).toHaveBeenCalledWith({ + useModalOverlay: true, + defaultStepOptions: { + cancelIcon: { enabled: true }, + classes: "zaparoo-tour-step", + scrollTo: { behavior: "smooth", block: "center" } + } + }); + + expect(tour.addStep).toHaveBeenCalledTimes(5); + }); + + it("should create tour with 4 steps when connected", () => { + const tour = createAppTour(mockNavigate, mockT, true); + + expect(tour.addStep).toHaveBeenCalledTimes(4); + }); + + it("should include device address step when not connected", () => { + const tour = createAppTour(mockNavigate, mockT, false); + + const calls = vi.mocked(tour.addStep).mock.calls; + const stepIds = calls.map(call => call[0].id); + + expect(stepIds).toContain("device-address"); + }); + + it("should skip device address step when connected", () => { + const tour = createAppTour(mockNavigate, mockT, true); + + const calls = vi.mocked(tour.addStep).mock.calls; + const stepIds = calls.map(call => call[0].id); + + expect(stepIds).not.toContain("device-address"); + }); + + it("should always include core steps", () => { + const tour = createAppTour(mockNavigate, mockT, false); + + const calls = vi.mocked(tour.addStep).mock.calls; + const stepIds = calls.map(call => call[0].id); + + expect(stepIds).toContain("welcome"); + expect(stepIds).toContain("media-database"); + expect(stepIds).toContain("create-cards"); + expect(stepIds).toContain("complete"); + }); + + it("should use correct step numbering when not connected", () => { + createAppTour(mockNavigate, mockT, false); + + expect(mockT).toHaveBeenCalledWith("tour.stepIndicator", { current: 1, total: 5 }); + expect(mockT).toHaveBeenCalledWith("tour.stepIndicator", { current: 2, total: 5 }); + expect(mockT).toHaveBeenCalledWith("tour.stepIndicator", { current: 3, total: 5 }); + expect(mockT).toHaveBeenCalledWith("tour.stepIndicator", { current: 4, total: 5 }); + expect(mockT).toHaveBeenCalledWith("tour.stepIndicator", { current: 5, total: 5 }); + }); + + it("should use correct step numbering when connected", () => { + createAppTour(mockNavigate, mockT, true); + + expect(mockT).toHaveBeenCalledWith("tour.stepIndicator", { current: 1, total: 4 }); + expect(mockT).toHaveBeenCalledWith("tour.stepIndicator", { current: 2, total: 4 }); + expect(mockT).toHaveBeenCalledWith("tour.stepIndicator", { current: 3, total: 4 }); + expect(mockT).toHaveBeenCalledWith("tour.stepIndicator", { current: 4, total: 4 }); + }); + + it("should configure navigation for device address step", () => { + const tour = createAppTour(mockNavigate, mockT, false); + + const calls = vi.mocked(tour.addStep).mock.calls; + const deviceAddressStep = calls.find(call => call[0].id === "device-address")?.[0] as any; + + expect(deviceAddressStep).toBeDefined(); + expect(deviceAddressStep?.beforeShowPromise).toBeDefined(); + expect(deviceAddressStep?.attachTo).toEqual({ + element: '[data-tour="device-address"]', + on: "bottom" + }); + }); + + it("should configure navigation for media database step when connected", () => { + const tour = createAppTour(mockNavigate, mockT, true); + + const calls = vi.mocked(tour.addStep).mock.calls; + const mediaDatabaseStep = calls.find(call => call[0].id === "media-database")?.[0] as any; + + expect(mediaDatabaseStep).toBeDefined(); + expect(mediaDatabaseStep?.beforeShowPromise).toBeDefined(); + }); + + it("should not configure navigation for media database step when not connected", () => { + const tour = createAppTour(mockNavigate, mockT, false); + + const calls = vi.mocked(tour.addStep).mock.calls; + const mediaDatabaseStep = calls.find(call => call[0].id === "media-database")?.[0] as any; + + expect(mediaDatabaseStep).toBeDefined(); + expect(mediaDatabaseStep?.beforeShowPromise).toBeUndefined(); + }); + + it("should configure back button navigation for device address step", async () => { + const tour = createAppTour(mockNavigate, mockT, false); + + const calls = vi.mocked(tour.addStep).mock.calls; + const deviceAddressStep = calls.find(call => call[0].id === "device-address")?.[0] as any; + + expect(deviceAddressStep?.buttons).toBeDefined(); + const backButton = deviceAddressStep?.buttons?.find((b: any) => b.secondary === true); + + expect(backButton).toBeDefined(); + expect(typeof backButton?.action).toBe("function"); + }); + + it("should configure back button navigation for create cards step", async () => { + const tour = createAppTour(mockNavigate, mockT, false); + + const calls = vi.mocked(tour.addStep).mock.calls; + const createCardsStep = calls.find(call => call[0].id === "create-cards")?.[0] as any; + + expect(createCardsStep?.buttons).toBeDefined(); + const backButton = createCardsStep?.buttons?.find((b: any) => b.secondary === true); + + expect(backButton).toBeDefined(); + expect(typeof backButton?.action).toBe("function"); + }); + + it("should use translation keys for all text content", () => { + createAppTour(mockNavigate, mockT, false); + + expect(mockT).toHaveBeenCalledWith("tour.welcome.title"); + expect(mockT).toHaveBeenCalledWith("tour.welcome.text"); + expect(mockT).toHaveBeenCalledWith("tour.deviceAddress.title"); + expect(mockT).toHaveBeenCalledWith("tour.deviceAddress.text"); + expect(mockT).toHaveBeenCalledWith("tour.mediaDatabase.title"); + expect(mockT).toHaveBeenCalledWith("tour.mediaDatabase.text"); + expect(mockT).toHaveBeenCalledWith("tour.createCards.title"); + expect(mockT).toHaveBeenCalledWith("tour.createCards.text"); + expect(mockT).toHaveBeenCalledWith("tour.complete.title"); + expect(mockT).toHaveBeenCalledWith("tour.complete.text"); + }); + + it("should use translation keys for all button labels", () => { + createAppTour(mockNavigate, mockT, false); + + expect(mockT).toHaveBeenCalledWith("tour.buttons.skip"); + expect(mockT).toHaveBeenCalledWith("tour.buttons.getStarted"); + expect(mockT).toHaveBeenCalledWith("tour.buttons.back"); + expect(mockT).toHaveBeenCalledWith("tour.buttons.next"); + expect(mockT).toHaveBeenCalledWith("tour.buttons.finish"); + }); + + it("should attach steps to correct DOM elements", () => { + const tour = createAppTour(mockNavigate, mockT, false); + + const calls = vi.mocked(tour.addStep).mock.calls; + + const deviceAddressStep = calls.find(call => call[0].id === "device-address")?.[0] as any; + expect(deviceAddressStep?.attachTo?.element).toBe('[data-tour="device-address"]'); + + const mediaDatabaseStep = calls.find(call => call[0].id === "media-database")?.[0] as any; + expect(mediaDatabaseStep?.attachTo?.element).toBe('[data-tour="update-database"]'); + + const createCardsStep = calls.find(call => call[0].id === "create-cards")?.[0] as any; + expect(createCardsStep?.attachTo?.element).toBe('[data-tour="create-search"]'); + }); + + it("should configure welcome step with skip and get started buttons", () => { + const tour = createAppTour(mockNavigate, mockT, false); + + const calls = vi.mocked(tour.addStep).mock.calls; + const welcomeStep = calls.find(call => call[0].id === "welcome")?.[0] as any; + + expect(welcomeStep?.buttons).toBeDefined(); + expect(welcomeStep?.buttons).toHaveLength(2); + + const skipButton = welcomeStep?.buttons?.[0]; + const getStartedButton = welcomeStep?.buttons?.[1]; + + expect(skipButton?.secondary).toBe(true); + expect(skipButton?.action).toBe(tour.cancel); + expect(getStartedButton?.action).toBe(tour.next); + }); + + it("should configure complete step with only finish button", () => { + const tour = createAppTour(mockNavigate, mockT, false); + + const calls = vi.mocked(tour.addStep).mock.calls; + const completeStep = calls.find(call => call[0].id === "complete")?.[0] as any; + + expect(completeStep?.buttons).toBeDefined(); + expect(completeStep?.buttons).toHaveLength(1); + + const finishButton = completeStep?.buttons?.[0]; + expect(finishButton?.action).toBe(tour.complete); + }); + + it("should include progress bar in formatted text", () => { + createAppTour(mockNavigate, mockT, false); + + const formattedTexts = vi.mocked(mockT).mock.results + .map(result => result.value) + .filter((value): value is string => typeof value === 'string'); + + // Check that some text contains the step indicator pattern + const hasStepIndicator = formattedTexts.some(text => + text.includes('tour.stepIndicator') + ); + + expect(hasStepIndicator).toBe(true); + }); +}); diff --git a/src/__tests__/unit/lib/websocketManager.test.ts b/src/__tests__/unit/lib/websocketManager.test.ts index ba036a35..97877a10 100644 --- a/src/__tests__/unit/lib/websocketManager.test.ts +++ b/src/__tests__/unit/lib/websocketManager.test.ts @@ -279,8 +279,13 @@ describe('WebSocketManager', () => { expect(mockWs.send).toHaveBeenCalledWith('test message'); }); - it('should throw error when not connected', () => { - expect(() => manager.send('test message')).toThrow(/Cannot send message: WebSocket is not open/); + it('should queue message when not connected (default behavior)', () => { + // Should not throw when queuing is enabled (default) + expect(() => manager.send('test message')).not.toThrow(); + }); + + it('should throw error when not connected and queuing disabled', () => { + expect(() => manager.send('test message', { queue: false })).toThrow(/Cannot send message: WebSocket is not open/); }); }); diff --git a/src/__tests__/unit/routes/create.custom.test.tsx b/src/__tests__/unit/routes/create.custom.test.tsx index 931bf75f..8d2270b5 100644 --- a/src/__tests__/unit/routes/create.custom.test.tsx +++ b/src/__tests__/unit/routes/create.custom.test.tsx @@ -104,25 +104,11 @@ describe("Create Custom Route", () => { vi.restoreAllMocks(); }); - it("should load custom text preference from loader", async () => { + it("should not have a loader (uses store directly)", async () => { const { Route } = await import("../../../routes/create.custom"); - const loaderData = await Route.options.loader!({} as any); - expect(loaderData).toEqual({ - customText: "test custom text" - }); - }); - - it("should handle empty custom text preference", async () => { - const { Preferences } = await import("@capacitor/preferences"); - vi.mocked(Preferences.get).mockResolvedValueOnce({ value: null }); - - const { Route } = await import("../../../routes/create.custom"); - const loaderData = await Route.options.loader!({} as any); - - expect(loaderData).toEqual({ - customText: "" - }); + // Route should not have a loader since it uses usePreferencesStore directly + expect(Route.options.loader).toBeUndefined(); }); it("should render custom text page with all components", async () => { diff --git a/src/__tests__/unit/routes/create.index.test.tsx b/src/__tests__/unit/routes/create.index.test.tsx index bfa4e8db..0496569e 100644 --- a/src/__tests__/unit/routes/create.index.test.tsx +++ b/src/__tests__/unit/routes/create.index.test.tsx @@ -322,6 +322,10 @@ describe("Create Index Route", () => { setLoggedInUser: vi.fn(), nfcModalOpen: false, setNfcModalOpen: vi.fn(), + proPurchaseModalOpen: false, + setProPurchaseModalOpen: vi.fn(), + writeOpen: false, + setWriteOpen: vi.fn(), safeInsets: {} as any, setSafeInsets: vi.fn(), deviceHistory: [], @@ -454,6 +458,10 @@ describe("Create Index Route", () => { setLoggedInUser: vi.fn(), nfcModalOpen: false, setNfcModalOpen: vi.fn(), + proPurchaseModalOpen: false, + setProPurchaseModalOpen: vi.fn(), + writeOpen: false, + setWriteOpen: vi.fn(), safeInsets: {} as any, setSafeInsets: vi.fn(), deviceHistory: [], diff --git a/src/__tests__/unit/routes/create.search.loader.test.ts b/src/__tests__/unit/routes/create.search.loader.test.ts index 03e477b1..ea0ccd19 100644 --- a/src/__tests__/unit/routes/create.search.loader.test.ts +++ b/src/__tests__/unit/routes/create.search.loader.test.ts @@ -41,30 +41,37 @@ describe("Create Search Route Loader", () => { ] }; - mockPreferencesGet.mockResolvedValue({ value: "snes" }); + mockPreferencesGet + .mockResolvedValueOnce({ value: "snes" }) // searchSystem + .mockResolvedValueOnce({ value: '[]' }); // searchTags (empty array) mockCoreApiSystems.mockResolvedValue(mockSystemsResponse); const result = await Route.options?.loader?.({} as any); expect(result).toEqual({ systemQuery: "snes", + tagQuery: [], systems: mockSystemsResponse }); expect(mockPreferencesGet).toHaveBeenCalledWith({ key: "searchSystem" }); + expect(mockPreferencesGet).toHaveBeenCalledWith({ key: "searchTags" }); expect(mockCoreApiSystems).toHaveBeenCalledWith(); }); it("should use default 'all' when no search system preference exists", async () => { const mockSystemsResponse = { systems: [] }; - mockPreferencesGet.mockResolvedValue({ value: null }); + mockPreferencesGet + .mockResolvedValueOnce({ value: null }) // searchSystem + .mockResolvedValueOnce({ value: null }); // searchTags mockCoreApiSystems.mockResolvedValue(mockSystemsResponse); const result = await Route.options?.loader?.({} as any); expect(result).toEqual({ systemQuery: "all", + tagQuery: [], systems: mockSystemsResponse }); }); @@ -72,13 +79,16 @@ describe("Create Search Route Loader", () => { it("should use default 'all' when search system preference is undefined", async () => { const mockSystemsResponse = { systems: [] }; - mockPreferencesGet.mockResolvedValue({ value: undefined }); + mockPreferencesGet + .mockResolvedValueOnce({ value: undefined }) // searchSystem + .mockResolvedValueOnce({ value: undefined }); // searchTags mockCoreApiSystems.mockResolvedValue(mockSystemsResponse); const result = await Route.options?.loader?.({} as any); expect(result).toEqual({ systemQuery: "all", + tagQuery: [], systems: mockSystemsResponse }); }); @@ -86,13 +96,16 @@ describe("Create Search Route Loader", () => { it("should handle empty string preference", async () => { const mockSystemsResponse = { systems: [] }; - mockPreferencesGet.mockResolvedValue({ value: "" }); + mockPreferencesGet + .mockResolvedValueOnce({ value: "" }) // searchSystem + .mockResolvedValueOnce({ value: "" }); // searchTags mockCoreApiSystems.mockResolvedValue(mockSystemsResponse); const result = await Route.options?.loader?.({} as any); expect(result).toEqual({ systemQuery: "all", // empty string should default to "all" + tagQuery: [], systems: mockSystemsResponse }); }); @@ -108,7 +121,9 @@ describe("Create Search Route Loader", () => { }); it("should handle CoreAPI systems failure", async () => { - mockPreferencesGet.mockResolvedValue({ value: "snes" }); + mockPreferencesGet + .mockResolvedValueOnce({ value: "snes" }) // searchSystem + .mockResolvedValueOnce({ value: '[]' }); // searchTags mockCoreApiSystems.mockRejectedValue(new Error("Core API unavailable")); await expect(Route.options?.loader?.({} as any)).rejects.toThrow("Core API unavailable"); @@ -123,13 +138,16 @@ describe("Create Search Route Loader", () => { }); it("should handle malformed systems response", async () => { - mockPreferencesGet.mockResolvedValue({ value: "snes" }); + mockPreferencesGet + .mockResolvedValueOnce({ value: "snes" }) // searchSystem + .mockResolvedValueOnce({ value: '[]' }); // searchTags mockCoreApiSystems.mockResolvedValue(null); const result = await Route.options?.loader?.({} as any); expect(result).toEqual({ systemQuery: "snes", + tagQuery: [], systems: null }); }); @@ -138,26 +156,34 @@ describe("Create Search Route Loader", () => { const mockSystemsResponse = { systems: [] }; // Test with a custom system ID - mockPreferencesGet.mockResolvedValue({ value: "custom-system-123" }); + mockPreferencesGet + .mockResolvedValueOnce({ value: "custom-system-123" }) // searchSystem + .mockResolvedValueOnce({ value: '["action", "rpg"]' }); // searchTags (JSON array) mockCoreApiSystems.mockResolvedValue(mockSystemsResponse); const result = await Route.options?.loader?.({} as any); expect(result).toEqual({ systemQuery: "custom-system-123", + tagQuery: ["action", "rpg"], systems: mockSystemsResponse }); }); it("should execute both API calls in parallel", async () => { - const preferencesPromise = new Promise(resolve => + const systemPreferencesPromise = new Promise(resolve => setTimeout(() => resolve({ value: "snes" }), 10) ); + const tagPreferencesPromise = new Promise(resolve => + setTimeout(() => resolve({ value: '[]' }), 5) + ); const systemsPromise = new Promise(resolve => setTimeout(() => resolve({ systems: [] }), 10) ); - mockPreferencesGet.mockReturnValue(preferencesPromise); + mockPreferencesGet + .mockReturnValueOnce(systemPreferencesPromise) + .mockReturnValueOnce(tagPreferencesPromise); mockCoreApiSystems.mockReturnValue(systemsPromise); const startTime = Date.now(); @@ -175,17 +201,22 @@ describe("Create Search Route Loader", () => { })); const mockSystemsResponse = { systems: largeSystems }; - mockPreferencesGet.mockResolvedValue({ value: "system-50" }); + mockPreferencesGet + .mockResolvedValueOnce({ value: "system-50" }) // searchSystem + .mockResolvedValueOnce({ value: '[]' }); // searchTags mockCoreApiSystems.mockResolvedValue(mockSystemsResponse); const result = await Route.options?.loader?.({} as any); expect(result.systems.systems).toHaveLength(100); expect(result.systemQuery).toBe("system-50"); + expect(result.tagQuery).toEqual([]); }); it("should handle CoreAPI timeout", async () => { - mockPreferencesGet.mockResolvedValue({ value: "snes" }); + mockPreferencesGet + .mockResolvedValueOnce({ value: "snes" }) // searchSystem + .mockResolvedValueOnce({ value: '[]' }); // searchTags // Simulate a timeout mockCoreApiSystems.mockImplementation(() => diff --git a/src/__tests__/unit/routes/index.loader.test.ts b/src/__tests__/unit/routes/index.loader.test.ts index 489a7ca2..45b48314 100644 --- a/src/__tests__/unit/routes/index.loader.test.ts +++ b/src/__tests__/unit/routes/index.loader.test.ts @@ -1,171 +1,167 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -// Mock Capacitor Preferences -vi.mock("@capacitor/preferences", () => ({ - Preferences: { - get: vi.fn() - } +// Mock the preferences store +vi.mock("../../../lib/preferencesStore", () => ({ + usePreferencesStore: { + getState: vi.fn() + }, + selectAppSettings: vi.fn(), + selectShakeSettings: vi.fn(), })); describe("Index Route Loader", () => { - let mockPreferencesGet: any; + let mockGetState: any; let Route: any; beforeEach(async () => { vi.clearAllMocks(); // Get the mock function - const { Preferences } = await import("@capacitor/preferences"); - mockPreferencesGet = vi.mocked(Preferences.get); + const { usePreferencesStore } = await import("../../../lib/preferencesStore"); + mockGetState = vi.mocked(usePreferencesStore.getState); // Import route after mocking const routeModule = await import("../../../routes/index"); Route = routeModule.Route; }); - it("should load all preferences with default values", async () => { - // Mock preferences returning default values - mockPreferencesGet - .mockResolvedValueOnce({ value: null }) // restartScan - .mockResolvedValueOnce({ value: null }) // launchOnScan - .mockResolvedValueOnce({ value: null }) // launcherAccess - .mockResolvedValueOnce({ value: null }); // preferRemoteWriter + it("should load all preferences with default values", () => { + // Mock store returning default values + mockGetState.mockReturnValue({ + restartScan: false, + launchOnScan: true, + launcherAccess: false, + preferRemoteWriter: false, + shakeEnabled: false, + shakeMode: "random", + shakeZapscript: "", + tourCompleted: false, + }); - const result = await Route.options?.loader?.(); + const result = Route.options?.loader?.(); + // Loader only returns specific fields, not all store state expect(result).toEqual({ - restartScan: false, // null converts to false - launchOnScan: true, // null converts to true (default) - launcherAccess: false, // null converts to false - preferRemoteWriter: false // null converts to false + restartScan: false, + launchOnScan: true, + launcherAccess: false, + preferRemoteWriter: false, + shakeMode: "random", + shakeZapscript: "", }); - expect(mockPreferencesGet).toHaveBeenCalledTimes(4); - expect(mockPreferencesGet).toHaveBeenCalledWith({ key: "restartScan" }); - expect(mockPreferencesGet).toHaveBeenCalledWith({ key: "launchOnScan" }); - expect(mockPreferencesGet).toHaveBeenCalledWith({ key: "launcherAccess" }); - expect(mockPreferencesGet).toHaveBeenCalledWith({ key: "preferRemoteWriter" }); + expect(mockGetState).toHaveBeenCalledTimes(1); }); - it("should load preferences with true values", async () => { - mockPreferencesGet - .mockResolvedValueOnce({ value: "true" }) - .mockResolvedValueOnce({ value: "true" }) - .mockResolvedValueOnce({ value: "true" }) - .mockResolvedValueOnce({ value: "true" }); + it("should load preferences with true values", () => { + mockGetState.mockReturnValue({ + restartScan: true, + launchOnScan: true, + launcherAccess: true, + preferRemoteWriter: true, + shakeEnabled: true, + shakeMode: "custom", + shakeZapscript: "**launch.system:snes", + tourCompleted: true, + }); - const result = await Route.options?.loader?.(); + const result = Route.options?.loader?.(); + // Loader only returns specific fields, not all store state expect(result).toEqual({ restartScan: true, launchOnScan: true, launcherAccess: true, - preferRemoteWriter: true + preferRemoteWriter: true, + shakeMode: "custom", + shakeZapscript: "**launch.system:snes", }); }); - it("should load preferences with false values", async () => { - mockPreferencesGet - .mockResolvedValueOnce({ value: "false" }) - .mockResolvedValueOnce({ value: "false" }) - .mockResolvedValueOnce({ value: "false" }) - .mockResolvedValueOnce({ value: "false" }); + it("should load preferences with false values", () => { + mockGetState.mockReturnValue({ + restartScan: false, + launchOnScan: false, + launcherAccess: false, + preferRemoteWriter: false, + shakeEnabled: false, + shakeMode: "random", + shakeZapscript: "", + tourCompleted: false, + }); - const result = await Route.options?.loader?.(); + const result = Route.options?.loader?.(); + // Loader only returns specific fields, not all store state expect(result).toEqual({ restartScan: false, - launchOnScan: false, // "false" string converts to false + launchOnScan: false, launcherAccess: false, - preferRemoteWriter: false + preferRemoteWriter: false, + shakeMode: "random", + shakeZapscript: "", }); }); - it("should handle mixed preference values", async () => { - mockPreferencesGet - .mockResolvedValueOnce({ value: "true" }) - .mockResolvedValueOnce({ value: "false" }) - .mockResolvedValueOnce({ value: null }) - .mockResolvedValueOnce({ value: "true" }); + it("should handle mixed preference values", () => { + mockGetState.mockReturnValue({ + restartScan: true, + launchOnScan: false, + launcherAccess: true, + preferRemoteWriter: false, + shakeEnabled: true, + shakeMode: "custom", + shakeZapscript: "**some.command", + tourCompleted: true, + }); - const result = await Route.options?.loader?.(); + const result = Route.options?.loader?.(); + // Loader only returns specific fields, not all store state expect(result).toEqual({ restartScan: true, launchOnScan: false, - launcherAccess: false, - preferRemoteWriter: true + launcherAccess: true, + preferRemoteWriter: false, + shakeMode: "custom", + shakeZapscript: "**some.command", }); }); - it("should handle preferences API failures gracefully", async () => { - mockPreferencesGet - .mockResolvedValueOnce({ value: "true" }) - .mockRejectedValueOnce(new Error("Preferences error")) - .mockResolvedValueOnce({ value: "true" }) - .mockResolvedValueOnce({ value: "false" }); - - await expect(Route.options?.loader?.()).rejects.toThrow("Preferences error"); - }); - - it("should handle partial preferences API failures", async () => { - // Test when some preferences fail but others succeed - mockPreferencesGet - .mockResolvedValueOnce({ value: "true" }) - .mockResolvedValueOnce({ value: "false" }) - .mockRejectedValueOnce(new Error("launcherAccess failed")) - .mockResolvedValueOnce({ value: "true" }); - - await expect(Route.options?.loader?.()).rejects.toThrow("launcherAccess failed"); - }); - - it("should handle undefined preference values", async () => { - mockPreferencesGet - .mockResolvedValueOnce({ value: undefined }) - .mockResolvedValueOnce({ value: undefined }) - .mockResolvedValueOnce({ value: undefined }) - .mockResolvedValueOnce({ value: undefined }); - - const result = await Route.options?.loader?.(); - - expect(result).toEqual({ + it("should be synchronous (not async)", () => { + mockGetState.mockReturnValue({ restartScan: false, - launchOnScan: true, // undefined should default to true for launchOnScan + launchOnScan: true, launcherAccess: false, - preferRemoteWriter: false + preferRemoteWriter: false, + shakeEnabled: false, + shakeMode: "random", + shakeZapscript: "", + tourCompleted: false, }); - }); - it("should handle non-boolean string values", async () => { - // Test edge case with unexpected string values - mockPreferencesGet - .mockResolvedValueOnce({ value: "yes" }) - .mockResolvedValueOnce({ value: "no" }) - .mockResolvedValueOnce({ value: "1" }) - .mockResolvedValueOnce({ value: "0" }); + const result = Route.options?.loader?.(); - const result = await Route.options?.loader?.(); - - // All non-"true" strings should be treated as false, except launchOnScan defaults to true - expect(result).toEqual({ - restartScan: false, // "yes" !== "true" - launchOnScan: true, // "no" !== "false" so defaults to true - launcherAccess: false, // "1" !== "true" - preferRemoteWriter: false // "0" !== "true" - }); + // Should return immediately, not a Promise + expect(result).not.toBeInstanceOf(Promise); + expect(typeof result).toBe("object"); }); - it("should call all preferences in parallel", async () => { - const promises: Promise[] = []; - mockPreferencesGet.mockImplementation(() => { - const promise = Promise.resolve({ value: "true" }); - promises.push(promise); - return promise; + it("should get state from store, not make async calls", () => { + mockGetState.mockReturnValue({ + restartScan: false, + launchOnScan: true, + launcherAccess: false, + preferRemoteWriter: false, + shakeEnabled: false, + shakeMode: "random", + shakeZapscript: "", + tourCompleted: false, }); - await Route.options?.loader?.(); + Route.options?.loader?.(); - // All 4 promises should be created before any resolve - expect(promises).toHaveLength(4); + // Should call getState, which is synchronous + expect(mockGetState).toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/src/__tests__/unit/routes/index.test.tsx b/src/__tests__/unit/routes/index.test.tsx index b2260b88..62449d4b 100644 --- a/src/__tests__/unit/routes/index.test.tsx +++ b/src/__tests__/unit/routes/index.test.tsx @@ -68,6 +68,20 @@ vi.mock("../../../lib/store", () => ({ }) })); +vi.mock("../../../lib/preferencesStore", () => ({ + usePreferencesStore: { + getState: vi.fn(() => ({ + restartScan: true, + launchOnScan: true, + launcherAccess: true, + preferRemoteWriter: true, + shakeEnabled: true, + shakeMode: "random" as const, + shakeZapscript: "" + })) + } +})); + // Mock Capacitor vi.mock("@capacitor/preferences", () => ({ Preferences: { @@ -242,7 +256,9 @@ describe("Index Route (Home Page)", () => { restartScan: true, // based on mock launchOnScan: true, launcherAccess: true, - preferRemoteWriter: true + preferRemoteWriter: true, + shakeMode: "random", + shakeZapscript: "" }); }); diff --git a/src/__tests__/unit/routes/settings.index.test.tsx b/src/__tests__/unit/routes/settings.index.test.tsx index 373ca83b..9c0c0468 100644 --- a/src/__tests__/unit/routes/settings.index.test.tsx +++ b/src/__tests__/unit/routes/settings.index.test.tsx @@ -184,7 +184,8 @@ describe("Settings Index Route", () => { expect(settingsSource).toMatch(/TextInput/); expect(settingsSource).toMatch(/settings\.device/); expect(settingsSource).toMatch(/settings\.designer/); - expect(settingsSource).toMatch(/settings\.advanced\.title/); + expect(settingsSource).toMatch(/settings\.app\.title/); + expect(settingsSource).toMatch(/settings\.core\.title/); expect(settingsSource).toMatch(/settings\.logs\.title/); expect(settingsSource).toMatch(/settings\.help\.title/); expect(settingsSource).toMatch(/settings\.about\.title/); @@ -201,8 +202,8 @@ describe("Settings Index Route", () => { // Should have the main flex column container expect(settingsSource).toMatch(/className="flex flex-col gap-5"/); - // ScanSettings component should still be present - expect(settingsSource).toMatch(/ { @@ -256,8 +257,8 @@ describe("Settings Index Route", () => { const settingsPath = resolve(__dirname, "../../../routes/settings.index.tsx"); const settingsSource = readFileSync(settingsPath, "utf-8"); - // Should use resetConnectionState from the store - expect(settingsSource).toMatch(/const resetConnectionState = useStatusStore\(\(state\) => state\.resetConnectionState\)/); + // Should use resetConnectionState from the store (handle multi-line formatting) + expect(settingsSource).toMatch(/const resetConnectionState = useStatusStore\(\s*\(state\) => state\.resetConnectionState\s*\)/); }); it("should have proper function sequence in handleDeviceAddressChange", () => { diff --git a/src/assets/lockup.png b/src/assets/lockup.png deleted file mode 100644 index 1cf913b7..00000000 Binary files a/src/assets/lockup.png and /dev/null differ diff --git a/src/assets/lockup.webp b/src/assets/lockup.webp new file mode 100644 index 00000000..525cc24b Binary files /dev/null and b/src/assets/lockup.webp differ diff --git a/src/components/BackToTop.tsx b/src/components/BackToTop.tsx index e5716214..864eb904 100644 --- a/src/components/BackToTop.tsx +++ b/src/components/BackToTop.tsx @@ -1,33 +1,38 @@ import { useEffect, useState, RefObject } from "react"; import { ChevronUp } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { debounce } from "lodash"; +import { useDebouncedCallback } from "use-debounce"; interface BackToTopProps { scrollContainerRef: RefObject; threshold?: number; + bottomOffset?: string; } export function BackToTop({ scrollContainerRef, - threshold = 300 + threshold = 300, + bottomOffset = "calc(1rem + 80px)" }: BackToTopProps) { const [isVisible, setIsVisible] = useState(false); const { t } = useTranslation(); - useEffect(() => { + const toggleVisibility = useDebouncedCallback(() => { const container = scrollContainerRef.current; if (!container) return; - const toggleVisibility = debounce(() => { - // Only update visibility when not at the top (prevents interference during bounce) - const scrollTop = container.scrollTop; - if (scrollTop <= 0) { - setIsVisible(false); - } else { - setIsVisible(scrollTop > threshold); - } - }, 10); + // Only update visibility when not at the top (prevents interference during bounce) + const scrollTop = container.scrollTop; + if (scrollTop <= 0) { + setIsVisible(false); + } else { + setIsVisible(scrollTop > threshold); + } + }, 10); + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; setIsVisible(container.scrollTop > threshold); @@ -36,7 +41,7 @@ export function BackToTop({ toggleVisibility.cancel(); container.removeEventListener("scroll", toggleVisibility); }; - }, [scrollContainerRef, threshold]); + }, [scrollContainerRef, threshold, toggleVisibility]); const scrollToTop = () => { if (scrollContainerRef.current) { @@ -49,7 +54,7 @@ export function BackToTop({ return (
diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx index 9304f1f3..296aa7c5 100644 --- a/src/components/CopyButton.tsx +++ b/src/components/CopyButton.tsx @@ -21,7 +21,8 @@ export const CopyButton = (props: { text: string }) => { return ( { + onClick={(e) => { + e.stopPropagation(); writeToClipboard(props.text).then(() => { setDisplay(t("copied")); setTimeout(() => { @@ -32,6 +33,7 @@ export const CopyButton = (props: { text: string }) => { onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); + e.stopPropagation(); writeToClipboard(props.text).then(() => { setDisplay(t("copied")); setTimeout(() => { diff --git a/src/components/CoreApiWebSocket.tsx b/src/components/CoreApiWebSocket.tsx index 78d7d9c5..397596f9 100644 --- a/src/components/CoreApiWebSocket.tsx +++ b/src/components/CoreApiWebSocket.tsx @@ -1,13 +1,17 @@ -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { Preferences } from "@capacitor/preferences"; +import { App } from "@capacitor/app"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; import { WebSocketManager, WebSocketState } from "../lib/websocketManager.ts"; import { IndexResponse, Notification, PlayingResponse, + PlaytimeLimitReachedParams, + PlaytimeLimitWarningParams, TokenResponse } from "../lib/models.ts"; import { @@ -17,14 +21,20 @@ import { NotificationRequest } from "../lib/coreApi.ts"; import { useStatusStore, ConnectionState } from "../lib/store.ts"; +import { formatDurationDisplay } from "../lib/utils.ts"; export function CoreApiWebSocket() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); const wsManagerRef = useRef(null); + const optimisticTimeoutRef = useRef | null>(null); + const resumeTimeoutRef = useRef | null>(null); + const isResumingRef = useRef(false); + const [deviceAddress, setDeviceAddress] = useState(getDeviceAddress()); + const [wsUrl, setWsUrl] = useState(getWsUrl()); const { setConnectionState, - setConnectionStateWithGracePeriod, - clearGracePeriod, setConnectionError, setPlaying, setGamesIndex, @@ -34,8 +44,6 @@ export function CoreApiWebSocket() { } = useStatusStore( useShallow((state) => ({ setConnectionState: state.setConnectionState, - setConnectionStateWithGracePeriod: state.setConnectionStateWithGracePeriod, - clearGracePeriod: state.clearGracePeriod, setConnectionError: state.setConnectionError, setPlaying: state.setPlaying, setGamesIndex: state.setGamesIndex, @@ -45,11 +53,119 @@ export function CoreApiWebSocket() { })) ); - const { t } = useTranslation(); + // Retry logic to handle hot reload scenarios where localStorage might be temporarily unavailable + useEffect(() => { + let attempts = 0; + const maxAttempts = 5; + const checkInterval = 100; // ms + let timer: ReturnType | null = null; + + const checkAddress = () => { + attempts++; + const addr = getDeviceAddress(); + const url = getWsUrl(); + + // Update state if values changed + if (addr !== deviceAddress) { + setDeviceAddress(addr); + } + + if (url !== wsUrl) { + setWsUrl(url); + } - // Get WebSocket URL and device address outside useEffect for early exit - const deviceAddress = getDeviceAddress(); - const wsUrl = getWsUrl(); + // Continue checking if we still don't have a valid address/URL and haven't exceeded max attempts + const stillEmpty = addr === "" || url === ""; + if (stillEmpty && attempts < maxAttempts) { + timer = setTimeout(checkAddress, checkInterval); + } + }; + + // Only start retry logic if we don't have an address yet + // This prevents unnecessary retries when address is already available + if (deviceAddress === "" || wsUrl === "") { + checkAddress(); + } + + return () => { + if (timer) { + clearTimeout(timer); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Run once on mount - deviceAddress/wsUrl are intentionally omitted as this bootstraps their initial values + + // Shared function to check and apply optimistic connection state + const applyOptimisticState = useCallback(async ( + timeoutRef: { current: ReturnType | null }, + logPrefix: string = "" + ) => { + try { + const [lastStateResult, lastTimestampResult] = await Promise.all([ + Preferences.get({ key: "lastConnectionState" }), + Preferences.get({ key: "lastConnectionTimestamp" }) + ]); + + const lastState = lastStateResult.value; + const lastTimestamp = lastTimestampResult.value; + + // Only be optimistic if: + // 1. Device address exists + // 2. Last state was CONNECTED + // 3. Last connection was within 10 minutes + const TEN_MINUTES = 10 * 60 * 1000; + const now = Date.now(); + + if ( + lastState === WebSocketState.CONNECTED && + lastTimestamp && + now - parseInt(lastTimestamp) < TEN_MINUTES + ) { + console.log(`${logPrefix}Showing optimistic connected state`); + setConnectionState(ConnectionState.CONNECTED); + setConnectionError(""); + + // Clear any existing timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + // Set a timeout to show real state if connection fails + timeoutRef.current = setTimeout(() => { + if (wsManagerRef.current) { + const actualState = wsManagerRef.current.currentState; + // If still not actually connected after 5 seconds, show real state + if (actualState !== WebSocketState.CONNECTED) { + console.log(`${logPrefix}Optimistic timeout: showing real connection state`); + switch (actualState) { + case WebSocketState.CONNECTING: + setConnectionState(ConnectionState.CONNECTING); + break; + case WebSocketState.RECONNECTING: + setConnectionState(ConnectionState.RECONNECTING); + break; + case WebSocketState.ERROR: + setConnectionState(ConnectionState.ERROR); + break; + case WebSocketState.DISCONNECTED: + setConnectionState(ConnectionState.DISCONNECTED); + break; + } + } + } + timeoutRef.current = null; + }, 5000); // 5 second timeout + + return true; // Optimistic state applied + } else { + console.log(`${logPrefix}Not showing optimistic state - conditions not met`); + return false; // Not optimistic + } + } catch (error) { + console.error(`${logPrefix}Error checking optimistic state:`, error); + return false; + } + }, [setConnectionState, setConnectionError]); useEffect(() => { // Early exit checks @@ -70,6 +186,13 @@ export function CoreApiWebSocket() { wsManagerRef.current = null; } + // Check if we should show optimistic connected state + applyOptimisticState(optimisticTimeoutRef).then((isOptimistic) => { + if (!isOptimistic) { + setConnectionState(ConnectionState.CONNECTING); + } + }); + // Helper functions for message processing const mediaStarted = (params: PlayingResponse) => { try { @@ -97,7 +220,24 @@ export function CoreApiWebSocket() { const mediaIndexing = (params: IndexResponse) => { try { console.log("mediaIndexing", params); + + // Get current state before updating + const currentState = useStatusStore.getState().gamesIndex; + + // Update the store with new state setGamesIndex(params); + + // Check for any meaningful state changes that require media query invalidation + const indexingStateChanged = currentState.indexing !== params.indexing; + const optimizingStateChanged = currentState.optimizing !== params.optimizing; + const existsStateChanged = currentState.exists !== params.exists; + const totalMediaChanged = currentState.totalMedia !== params.totalMedia; + + // Invalidate media query on any significant state change to keep UI in sync + if (indexingStateChanged || optimizingStateChanged || existsStateChanged || totalMediaChanged) { + console.log("Database state changed, invalidating media query"); + queryClient.invalidateQueries({ queryKey: ["media"] }); + } } catch (err) { console.error("Error processing mediaIndexing notification:", err); } @@ -122,12 +262,13 @@ export function CoreApiWebSocket() { maxReconnectAttempts: Infinity, reconnectBackoffMultiplier: 1.5, maxReconnectInterval: 2000, - pingMessage: "ping" + pingMessage: "ping", + connectionTimeout: 10000 }, { onOpen: () => { - clearGracePeriod(); - setConnectionStateWithGracePeriod(ConnectionState.CONNECTED); + isResumingRef.current = false; // Reset resume flag + setConnectionState(ConnectionState.CONNECTED); setConnectionError(""); // Flush any queued requests @@ -186,7 +327,7 @@ export function CoreApiWebSocket() { }); }, onClose: () => { - setConnectionStateWithGracePeriod(ConnectionState.RECONNECTING); + setConnectionState(ConnectionState.RECONNECTING); }, onError: (error) => { const errorMsg = "Error communicating with server: " + wsUrl; @@ -220,6 +361,35 @@ export function CoreApiWebSocket() { case Notification.TokensScanned: activeToken(v.params as TokenResponse); break; + case Notification.PlaytimeLimitWarning: { + const warningParams = v.params as PlaytimeLimitWarningParams; + const remainingTime = formatDurationDisplay(warningParams.remaining); + const limitType = warningParams.type === "daily" ? t("settings.core.playtime.dailyLimit") : t("settings.core.playtime.sessionLimit"); + toast( + t("settings.core.playtime.warningToast", { + remaining: remainingTime, + type: limitType + }), + { + icon: "⏰", + duration: 5000 + } + ); + break; + } + case Notification.PlaytimeLimitReached: { + const reachedParams = v.params as PlaytimeLimitReachedParams; + const limitType = reachedParams.reason === "daily" ? t("settings.core.playtime.dailyLimit") : t("settings.core.playtime.sessionLimit"); + toast.error( + t("settings.core.playtime.reachedToast", { + type: limitType + }), + { + duration: 6000 + } + ); + break; + } default: console.warn("Unknown notification method:", v.method); } @@ -238,18 +408,25 @@ export function CoreApiWebSocket() { // Map WebSocketState to ConnectionState switch (state) { case WebSocketState.CONNECTING: - setConnectionState(ConnectionState.CONNECTING); + // Don't show intermediate connecting state on app resume + if (!isResumingRef.current) { + setConnectionState(ConnectionState.CONNECTING); + } break; case WebSocketState.CONNECTED: + isResumingRef.current = false; // This is handled in onOpen callback break; case WebSocketState.RECONNECTING: - setConnectionStateWithGracePeriod(ConnectionState.RECONNECTING); + isResumingRef.current = false; + setConnectionState(ConnectionState.RECONNECTING); break; case WebSocketState.ERROR: + isResumingRef.current = false; setConnectionState(ConnectionState.ERROR); break; case WebSocketState.DISCONNECTED: + isResumingRef.current = false; setConnectionState(ConnectionState.DISCONNECTED); break; } @@ -268,25 +445,94 @@ export function CoreApiWebSocket() { // Cleanup function: close WebSocket on component unmount or dependency change return () => { console.log("CoreApiWebSocket cleanup: destroying WebSocket manager"); + + // Clear optimistic timeout if active + if (optimisticTimeoutRef.current) { + clearTimeout(optimisticTimeoutRef.current); + optimisticTimeoutRef.current = null; + } + wsManager.destroy(); wsManagerRef.current = null; - clearGracePeriod(); setConnectionState(ConnectionState.DISCONNECTED); }; }, [ deviceAddress, wsUrl, addDeviceHistory, - clearGracePeriod, setConnectionError, setConnectionState, - setConnectionStateWithGracePeriod, setDeviceHistory, setGamesIndex, setLastToken, setPlaying, - t + queryClient, + t, + applyOptimisticState ]); // Dependencies: re-create WebSocket if address or URL changes + // App lifecycle listeners for handling pause/resume + useEffect(() => { + let resumeListener: Awaited> | null = null; + let pauseListener: Awaited> | null = null; + + const setupListeners = async () => { + // Handle app resume - trigger immediate reconnection with optimistic state + resumeListener = await App.addListener("resume", async () => { + console.log("App resumed, triggering immediate reconnection"); + + // Only attempt reconnection if we have a valid device address + if (!deviceAddress || !wsManagerRef.current) { + return; + } + + // Set flag to prevent UI flicker during reconnection + isResumingRef.current = true; + + // Apply optimistic state using shared function + await applyOptimisticState(resumeTimeoutRef, "App resume: "); + + // Trigger immediate reconnection + wsManagerRef.current.immediateReconnect(); + }); + + // Handle app pause - persist connection state + pauseListener = await App.addListener("pause", async () => { + console.log("App paused"); + + if (wsManagerRef.current) { + const currentState = wsManagerRef.current.currentState; + const timestamp = Date.now(); + + // Persist connection state for optimistic UI on resume + await Preferences.set({ + key: "lastConnectionState", + value: currentState + }); + await Preferences.set({ + key: "lastConnectionTimestamp", + value: timestamp.toString() + }); + + console.log(`Persisted connection state: ${currentState} at ${timestamp}`); + } + }); + }; + + setupListeners(); + + // Cleanup listeners on unmount + return () => { + // Clear resume timeout if active + if (resumeTimeoutRef.current) { + clearTimeout(resumeTimeoutRef.current); + resumeTimeoutRef.current = null; + } + + resumeListener?.remove(); + pauseListener?.remove(); + }; + }, [deviceAddress, setConnectionState, setConnectionError, applyOptimisticState]); // Re-setup listeners if device address changes + return null; } \ No newline at end of file diff --git a/src/components/MediaDatabaseCard.tsx b/src/components/MediaDatabaseCard.tsx index d7f65f24..ede901df 100644 --- a/src/components/MediaDatabaseCard.tsx +++ b/src/components/MediaDatabaseCard.tsx @@ -1,16 +1,30 @@ import { useTranslation } from "react-i18next"; import classNames from "classnames"; -import { useQuery } from "@tanstack/react-query"; +import { useState, useEffect } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useStatusStore } from "@/lib/store"; import { CoreAPI } from "@/lib/coreApi"; import { DatabaseIcon } from "@/lib/images"; import { Card } from "./wui/Card"; import { Button } from "./wui/Button"; +import { SystemSelector, SystemSelectorTrigger } from "./SystemSelector"; +import { LoadingSpinner } from "./ui/loading-spinner"; export function MediaDatabaseCard() { const { t } = useTranslation(); + const queryClient = useQueryClient(); const connected = useStatusStore((state) => state.connected); const gamesIndex = useStatusStore((state) => state.gamesIndex); + const [isCancelling, setIsCancelling] = useState(false); + const [selectedSystems, setSelectedSystems] = useState([]); + const [systemSelectorOpen, setSystemSelectorOpen] = useState(false); + + // Reset cancelling state when indexing stops + useEffect(() => { + if (!gamesIndex.indexing && isCancelling) { + setIsCancelling(false); + } + }, [gamesIndex.indexing, isCancelling]); // Fetch real-time database status const { data: mediaStatus, isLoading } = useQuery({ @@ -21,49 +35,119 @@ export function MediaDatabaseCard() { retry: false }); + // Fetch systems data for selector + const { data: systemsData } = useQuery({ + queryKey: ["systems"], + queryFn: () => CoreAPI.systems(), + enabled: connected, + }); + const handleUpdateDatabase = () => { - CoreAPI.mediaGenerate(); + // If no systems selected or all systems selected, pass undefined (all systems) + const systemsToUpdate = selectedSystems.length === 0 || + (systemsData?.systems && selectedSystems.length === systemsData.systems.length) + ? undefined + : selectedSystems; + + CoreAPI.mediaGenerate(systemsToUpdate ? { systems: systemsToUpdate } : undefined); }; + const handleCancelUpdate = async () => { + setIsCancelling(true); + try { + await CoreAPI.mediaGenerateCancel(); + // Note: Don't reset isCancelling here - let the useEffect handle it + // when the indexing status updates from the WebSocket notification + queryClient.invalidateQueries({ queryKey: ["media"] }); + } catch (error) { + console.error("Failed to cancel media generation:", error); + // Only reset on error, since cancellation request failed + setIsCancelling(false); + } + }; + + // Check various states from both store and API + const isOptimizing = gamesIndex.optimizing || mediaStatus?.database?.optimizing; + const isIndexing = gamesIndex.indexing || mediaStatus?.database?.indexing; + const renderStatus = () => { - // Show progress when indexing - if (gamesIndex.indexing) { + + // Check optimization status first - this takes priority + if (isOptimizing) { return (
-
- {gamesIndex.currentStepDisplay - ? gamesIndex.currentStep === gamesIndex.totalSteps - ? t("toast.writingDb") - : gamesIndex.currentStepDisplay - : t("toast.preparingDb")} +
+ {t("settings.updateDb.status.optimizing")} + {/* No spinner for optimizing - only throbbing bar */}
-
+
); } + // Show progress when indexing (either from store or API) + if (isIndexing) { + // Prefer gamesIndex data if available (has detailed progress), otherwise use generic preparing state + const hasDetailedProgress = gamesIndex.indexing && gamesIndex.totalSteps && gamesIndex.totalSteps > 0; + + return ( +
+
+
+ + {hasDetailedProgress && gamesIndex.currentStepDisplay + ? gamesIndex.currentStep === gamesIndex.totalSteps + ? t("toast.writingDb") + : gamesIndex.currentStepDisplay + : t("toast.preparingDb")} + + {/* Only show spinner for system-specific steps (not preparing/writing) */} + {isIndexing && hasDetailedProgress && gamesIndex.currentStepDisplay && + gamesIndex.currentStep !== gamesIndex.totalSteps && ( + + )} +
+
+
+
+
+
+ ); + } + // Show connection status if (!connected) { return ( -
+
{t("settings.updateDb.status.noConnection")}
); @@ -72,7 +156,7 @@ export function MediaDatabaseCard() { // Show loading state while fetching database status if (isLoading) { return ( -
+
{t("settings.updateDb.status.checking")}
); @@ -81,35 +165,75 @@ export function MediaDatabaseCard() { // Use real database status from API const databaseExists = mediaStatus?.database?.exists ?? false; - if (!databaseExists) { + if (!databaseExists && !gamesIndex.indexing && !isOptimizing) { return ( -
- {t("create.search.gamesDbUpdate")} +
+ No database found
); } - // Database exists and is ready - never show file count here - return ( -
- {t("settings.updateDb.status.ready")} -
- ); + // Database exists and is ready - show media count if available + if (databaseExists) { + const totalMedia = mediaStatus?.database?.totalMedia; + if (totalMedia !== undefined && totalMedia > 0) { + const formattedCount = totalMedia.toLocaleString(); + return ( +
+ {t("settings.updateDb.status.mediaCount", { + count: totalMedia, + formattedCount + })} +
+ ); + } else { + return ( +
+ {t("settings.updateDb.status.ready")} +
+ ); + } + } + + return null; }; return ( - -
-
-
+ <> + +
+ {/* System selector for choosing which systems to update */} + setSystemSelectorOpen(true)} + /> + +
+
+ + {renderStatus()} +
+
+ + setSystemSelectorOpen(false)} + onSelect={setSelectedSystems} + selectedSystems={selectedSystems} + mode="multi" + title={t("settings.updateDb.selectSystemsTitle")} + includeAllOption={true} + /> + ); -} \ No newline at end of file +} diff --git a/src/components/MediaSearchModal.tsx b/src/components/MediaSearchModal.tsx index 7a833fdc..be79e917 100644 --- a/src/components/MediaSearchModal.tsx +++ b/src/components/MediaSearchModal.tsx @@ -1,15 +1,16 @@ import { useTranslation } from "react-i18next"; import { useEffect, useRef, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; import { Preferences } from "@capacitor/preferences"; -import { useDebounce } from "use-debounce"; +import classNames from "classnames"; import { useStatusStore } from "@/lib/store.ts"; -import { CoreAPI } from "@/lib/coreApi.ts"; import { SlideModal } from "@/components/SlideModal.tsx"; import { TextInput } from "@/components/wui/TextInput.tsx"; -import { SearchResults } from "@/components/SearchResults.tsx"; +import { Button } from "@/components/wui/Button.tsx"; +import { VirtualSearchResults } from "@/components/VirtualSearchResults.tsx"; import { SearchResultGame } from "@/lib/models.ts"; import { BackToTop } from "@/components/BackToTop.tsx"; +import { SearchIcon } from "@/lib/images.tsx"; +import { SimpleSystemSelect } from "@/components/SimpleSystemSelect.tsx"; export function MediaSearchModal(props: { isOpen: boolean; @@ -18,67 +19,71 @@ export function MediaSearchModal(props: { }) { const inputRef = useRef(null); - useEffect(() => { - if (props.isOpen) { - // wait for modal animation to complete - const timer = setTimeout(() => { - inputRef.current?.focus(); - }, 300); - - return () => clearTimeout(timer); - } - }, [props.isOpen]); - const { t } = useTranslation(); const [query, setQuery] = useState(""); - const [debouncedQuery] = useDebounce(query, 750); const [selectedSystem, setSelectedSystem] = useState("all"); const [selectedResult, setSelectedResult] = useState( null ); + + // State for tracking actual searched parameters + const [searchParams, setSearchParams] = useState<{ + query: string; + system: string; + } | null>(null); + const [isSearching, setIsSearching] = useState(false); const connected = useStatusStore((state) => state.connected); const { close, onSelect } = props; const scrollContainerRef = useRef(null); - const systems = useQuery({ - queryKey: ["systems"], - queryFn: () => CoreAPI.systems() - }); - const gamesIndex = useStatusStore((state) => state.gamesIndex); - const searchResults = useQuery({ - queryKey: ["mediaSearch", debouncedQuery, selectedSystem], - queryFn: () => - CoreAPI.mediaSearch({ - query: debouncedQuery, - systems: selectedSystem === "all" ? [] : [selectedSystem] - }), - enabled: - debouncedQuery.length >= 2 && - gamesIndex.exists && - !gamesIndex.indexing, - retry: 3, - retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000) - }); + // Manual search function + const performSearch = () => { + if ( + !connected || + !gamesIndex.exists || + gamesIndex.indexing + ) { + return; + } + + setIsSearching(true); + setSearchParams({ + query: query, + system: selectedSystem + }); + }; + + // Handle system selection + const handleSystemSelect = async (systemId: string) => { + setSelectedSystem(systemId); + await Preferences.set({ key: "searchSystem", value: systemId }); + }; useEffect(() => { if (selectedResult) { - onSelect(selectedResult.path); + // Use zapScript if available, otherwise fall back to path + const valueToInsert = selectedResult.zapScript || selectedResult.path; + onSelect(valueToInsert); close(); setSelectedResult(null); } }, [selectedResult, onSelect, close]); + const canSearch = connected && gamesIndex.exists && !gamesIndex.indexing; + return ( - -
-
+ <> + +
+
{ if (e.key === "Enter" || e.keyCode === 13) { e.currentTarget.blur(); + performSearch(); } }} />
-
+ +
- +
+ setIsSearching(false)} + scrollContainerRef={scrollContainerRef} + /> +
- -
+ + + ); } diff --git a/src/components/ProPurchase.tsx b/src/components/ProPurchase.tsx index a24b8d2e..dea55d12 100644 --- a/src/components/ProPurchase.tsx +++ b/src/components/ProPurchase.tsx @@ -1,4 +1,3 @@ -import { Preferences } from "@capacitor/preferences"; import { t } from "i18next"; import { Purchases, PurchasesPackage } from "@revenuecat/purchases-capacitor"; import { useEffect, useState } from "react"; @@ -10,12 +9,16 @@ import { DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { usePreferencesStore } from "../lib/preferencesStore"; +import { useStatusStore } from "../lib/store"; import { Button } from "./wui/Button"; export const RestorePuchasesButton = () => { + const setLauncherAccess = usePreferencesStore.getState().setLauncherAccess; + return (
+ + ))} +
+ +
+
+ + )} +
+ + ); +} \ No newline at end of file diff --git a/src/components/ScanSpinner.tsx b/src/components/ScanSpinner.tsx index 4aa2578f..2d21ac70 100644 --- a/src/components/ScanSpinner.tsx +++ b/src/components/ScanSpinner.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import { Capacitor } from "@capacitor/core"; import { ScanResult } from "../lib/models"; import { DownIcon, SettingsIcon, WarningIcon } from "../lib/images"; +import { usePreferencesStore } from "../lib/preferencesStore"; import { Card } from "./wui/Card"; import { Button } from "./wui/Button"; @@ -17,22 +18,17 @@ export function ScanSpinner(props: { spinning?: boolean; write?: boolean; }) { - const [nfcSupported, setNfcSupported] = useState(true); + const nfcSupported = usePreferencesStore((state) => state.nfcAvailable); const [nfcEnabled, setNfcEnabled] = useState(true); const { t } = useTranslation(); useEffect(() => { - if (import.meta.env.PROD) { - Nfc.isAvailable().then((available) => { - setNfcSupported(available.nfc); + // Only check if NFC is enabled on Android + if (import.meta.env.PROD && Capacitor.getPlatform() === "android") { + Nfc.isEnabled().then((enabled) => { + setNfcEnabled(enabled.isEnabled); }); - - if (Capacitor.getPlatform() === "android") { - Nfc.isEnabled().then((enabled) => { - setNfcEnabled(enabled.isEnabled); - }); - } } }, []); diff --git a/src/components/SearchResults.tsx b/src/components/SearchResults.tsx index 39a3a3a7..ad457113 100644 --- a/src/components/SearchResults.tsx +++ b/src/components/SearchResults.tsx @@ -4,7 +4,9 @@ import { SearchResultGame, SearchResultsResponse } from "@/lib/models.ts"; import { useStatusStore } from "@/lib/store.ts"; import { Card } from "@/components/wui/Card.tsx"; import { NextIcon, SettingsIcon, WarningIcon } from "@/lib/images.tsx"; +import { LoadingSpinner } from "@/components/ui/loading-spinner.tsx"; import { Button } from "@/components/wui/Button.tsx"; +import { TagList } from "@/components/TagList.tsx"; export function SearchResults(props: { loading: boolean; @@ -12,10 +14,28 @@ export function SearchResults(props: { resp: SearchResultsResponse | null; selectedResult: SearchResultGame | null; setSelectedResult: (game: SearchResultGame | null) => void; + hasSearched?: boolean; + searchQuery?: string; + searchSystem?: string; + searchTags?: string[]; + onClearFilters?: () => void; }) { const gamesIndex = useStatusStore((state) => state.gamesIndex); const { t } = useTranslation(); + // Screen reader announcement for search results + const getAriaLiveMessage = () => { + if (props.loading) return t("create.search.loading"); + if (props.error) return t("create.search.searchError"); + if (props.resp?.results) { + const count = props.resp.results.length; + return count === 0 + ? t("create.search.noResultsFoundSimple") + : `${count} ${count === 1 ? 'result' : 'results'} found`; + } + return ""; + }; + if (!gamesIndex.exists) { return ( @@ -41,45 +61,89 @@ export function SearchResults(props: { ); } + + // Show initial state when no search has been performed + if (!props.hasSearched && !props.resp) { + return ( +
+

{t("create.search.startSearching")}

+

{t("create.search.startSearchingHint")}

+
+ ); + } + + // Show loading spinner when searching if (props.loading) { return ( -
-
-
-
-
-
-
-
+
+ + {t("create.search.loading")}
); } if (props.error) { - return

{t("create.search.searchError")}

; + return

{t("create.search.searchError")}

; } if (!props.resp) { return <>; } - if (props.resp.total === 0) { + // Enhanced empty state with helpful suggestions + if (props.resp.results.length === 0) { + const hasActiveFilters = props.searchSystem !== "all" || (props.searchTags && props.searchTags.length > 0); + const hasQuery = props.searchQuery && props.searchQuery.trim().length > 0; + + let mainMessage: string; + let suggestionMessage: string; + + if (!hasQuery) { + // No search query - just show simple message + mainMessage = t("create.search.noResultsFoundSimple"); + suggestionMessage = hasActiveFilters + ? t("create.search.tryRemovingFiltersOnly") + : ""; + } else { + // Has search query + mainMessage = t("create.search.noResultsFound", { query: props.searchQuery }); + if (hasActiveFilters) { + suggestionMessage = t("create.search.tryDifferentSearch"); + } else { + suggestionMessage = t("create.search.tryDifferentTerms"); + } + } + return ( -

{t("create.search.noGamesFound")}

+
+

{mainMessage}

+ {suggestionMessage && ( +

+ {suggestionMessage} +

+ )} + {hasActiveFilters && props.onClearFilters && ( +
+
+ )} +
); } - if (props.resp.total > 0) { + if (props.resp.results.length > 0) { return ( <> -

- {props.resp.total > props.resp.results.length - ? t("create.search.gamesFoundMax", { - count: props.resp.total, - max: props.resp.results.length - }) - : t("create.search.gamesFound", { count: props.resp.total })} -

+ {/* Screen reader announcements */} +
+ {getAriaLiveMessage()} +
+ + {/* Results list */}
{props.resp.results.map((game, i) => { const handleGameSelect = () => { @@ -127,6 +191,7 @@ export function SearchResults(props: {

{game.name}

{game.system.name}

+
diff --git a/src/components/SimpleSystemSelect.tsx b/src/components/SimpleSystemSelect.tsx new file mode 100644 index 00000000..00a3824c --- /dev/null +++ b/src/components/SimpleSystemSelect.tsx @@ -0,0 +1,94 @@ +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import classNames from "classnames"; +import { CoreAPI } from "@/lib/coreApi"; +import { useStatusStore } from "@/lib/store"; + +interface SimpleSystemSelectProps { + value: string; // The currently selected system ID (or "all") + onSelect: (systemId: string) => void; + placeholder?: string; + includeAllOption?: boolean; + className?: string; +} + +export function SimpleSystemSelect({ + value, + onSelect, + placeholder, + includeAllOption = false, + className +}: SimpleSystemSelectProps) { + const { t } = useTranslation(); + + // Get indexing state to disable selector when indexing is in progress + const gamesIndex = useStatusStore((state) => state.gamesIndex); + + // Fetch systems data + const { data: systemsData, isLoading } = useQuery({ + queryKey: ["systems"], + queryFn: () => CoreAPI.systems() + }); + + // Group systems by category and sort + const groupedSystems = (systemsData?.systems || []).reduce( + (acc, system) => { + const category = system.category || "Other"; + if (!acc[category]) { + acc[category] = []; + } + acc[category].push(system); + return acc; + }, + {} as Record> + ); + + // Sort categories alphabetically and systems within each category + const sortedCategories = Object.entries(groupedSystems) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([category, systems]) => ({ + category, + systems: systems.sort((a, b) => a.name.localeCompare(b.name)) + })); + + const handleChange = (e: { target: { value: string } }) => { + onSelect(e.target.value); + }; + + return ( + + ); +} diff --git a/src/components/SlideModal.tsx b/src/components/SlideModal.tsx index 09e609a8..7ab4451f 100644 --- a/src/components/SlideModal.tsx +++ b/src/components/SlideModal.tsx @@ -13,6 +13,8 @@ export function SlideModal(props: { children: ReactNode; className?: string; scrollRef?: RefObject; + footer?: ReactNode; + fixedHeight?: string; }) { const modalId = useId(); const modalManager = useSlideModalManager(); @@ -26,7 +28,7 @@ export function SlideModal(props: { // Handle Android back button useBackButtonHandler( - 'slide-modal', + "slide-modal", () => { if (props.isOpen) { props.close(); @@ -69,7 +71,7 @@ export function SlideModal(props: { pointerEvents: props.isOpen ? "auto" : "none" }} onClick={props.close} - onKeyDown={(e) => e.key === 'Escape' && props.close()} + onKeyDown={(e) => e.key === "Escape" && props.close()} role="button" tabIndex={0} aria-label="Close modal" @@ -103,7 +105,9 @@ export function SlideModal(props: { style={{ bottom: props.isOpen ? "0" : "-100vh", transition: "bottom 0.2s ease-in-out", - maxHeight: `calc(100vh - ${safeInsets.top} - 75px)` + ...(props.fixedHeight + ? { height: props.fixedHeight } + : { maxHeight: `calc(100vh - ${safeInsets.top} - 75px)` }) }} >
e.key === 'Enter' && props.close()} + onKeyDown={(e) => e.key === "Enter" && props.close()} role="button" tabIndex={0} aria-label="Drag to close" className="h-[5px] w-[80px] rounded-full bg-[#00E0FF]" >
-
+

{props.title}

{/* Desktop close button */}
-
+
{props.children}
+ {props.footer &&
{props.footer}
}
); diff --git a/src/components/SystemSelector.tsx b/src/components/SystemSelector.tsx new file mode 100644 index 00000000..78ee3786 --- /dev/null +++ b/src/components/SystemSelector.tsx @@ -0,0 +1,544 @@ +import { useState, useMemo, useRef, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { Search, Check, X } from "lucide-react"; +import { useDebounce } from "use-debounce"; +import classNames from "classnames"; +import { CoreAPI } from "@/lib/coreApi"; +import { useStatusStore } from "@/lib/store"; +import { useSmartTabs } from "@/hooks/useSmartTabs"; +import { SlideModal } from "./SlideModal"; +import { Button } from "./wui/Button"; +import { BackToTop } from "./BackToTop"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "./ui/tabs"; + +export interface System { + id: string; + name: string; + category?: string; +} + +interface SystemSelectorProps { + isOpen: boolean; + onClose: () => void; + onSelect: (systems: string[]) => void; + selectedSystems: string[]; + mode: "single" | "multi" | "insert"; + title?: string; + includeAllOption?: boolean; + defaultSelection?: string; // When selectedSystems is empty, what should be shown as selected (e.g., "all" or undefined for nothing) +} + +interface GroupedSystems { + [category: string]: System[]; +} + +const ITEM_HEIGHT = 56; // Height of each system item in pixels + +export function SystemSelector({ + isOpen, + onClose, + onSelect, + selectedSystems, + mode, + title, + includeAllOption = false, + defaultSelection +}: SystemSelectorProps) { + const { t } = useTranslation(); + const scrollContainerRef = useRef(null); + const slideModalScrollRef = useRef(null); + + const [searchQuery, setSearchQuery] = useState(""); + const [selectedCategory, setSelectedCategory] = useState("all"); + const [debouncedSearchQuery] = useDebounce(searchQuery, 300); + const [showLeftGradient, setShowLeftGradient] = useState(false); + const [showRightGradient, setShowRightGradient] = useState(true); + + // Smart tabs hook for overflow detection and drag scrolling + const { hasOverflow, tabsProps } = useSmartTabs({ + onScrollChange: (scrollLeft, overflow) => { + if (!overflow) return; + + const container = tabsProps.ref.current; + if (!container) return; + + const { scrollWidth, clientWidth } = container; + setShowLeftGradient(scrollLeft > 0); + setShowRightGradient(scrollLeft < scrollWidth - clientWidth - 1); + } + }); + + // Get indexing state to disable selector when indexing is in progress + const gamesIndex = useStatusStore((state) => state.gamesIndex); + + // Fetch systems data + const { data: systemsData, isLoading } = useQuery({ + queryKey: ["systems"], + queryFn: () => CoreAPI.systems() + }); + + // Process and filter systems + const { filteredSystems, categories } = useMemo(() => { + if (!systemsData?.systems) { + return { filteredSystems: [], categories: [] }; + } + + const systems = systemsData.systems; + + // Group systems by category + const grouped: GroupedSystems = {}; + const categorySet = new Set(); + + systems.forEach((system) => { + const category = system.category || "Other"; + categorySet.add(category); + + if (!grouped[category]) { + grouped[category] = []; + } + grouped[category].push(system); + }); + + // Sort categories alphabetically, but put common ones first + const priorityCategories = ["Nintendo", "Sony", "Sega", "Atari"]; + const categories = Array.from(categorySet).sort((a, b) => { + const aPriority = priorityCategories.indexOf(a); + const bPriority = priorityCategories.indexOf(b); + + if (aPriority !== -1 && bPriority !== -1) { + return aPriority - bPriority; + } + if (aPriority !== -1) return -1; + if (bPriority !== -1) return 1; + return a.localeCompare(b); + }); + + // Filter systems based on search and category + let filtered: System[] = []; + + if (selectedCategory === "all") { + filtered = systems; + } else { + filtered = grouped[selectedCategory] || []; + } + + // Apply search filter + if (debouncedSearchQuery.trim()) { + const query = debouncedSearchQuery.toLowerCase(); + filtered = filtered.filter( + (system) => + system.name.toLowerCase().includes(query) || + system.id.toLowerCase().includes(query) + ); + } + + // Sort filtered systems by name + filtered.sort((a, b) => a.name.localeCompare(b.name)); + + return { filteredSystems: filtered, categories }; + }, [systemsData, debouncedSearchQuery, selectedCategory]); + + // Handle system selection + const handleSystemSelect = useCallback( + (systemId: string) => { + // Don't allow selection while indexing + if (gamesIndex.indexing) return; + + if (mode === "single" || mode === "insert") { + if (systemId === "all") { + onSelect([]); + } else { + onSelect([systemId]); + } + onClose(); + } else { + const newSelection = selectedSystems.includes(systemId) + ? selectedSystems.filter((id) => id !== systemId) + : [...selectedSystems, systemId]; + onSelect(newSelection); + } + }, + [mode, selectedSystems, onSelect, onClose, gamesIndex.indexing] + ); + + // Handle clear all + const handleClearAll = useCallback(() => { + // Don't allow clearing while indexing + if (gamesIndex.indexing) return; + onSelect([]); + }, [onSelect, gamesIndex.indexing]); + + // Handle apply (for multi-select) + const handleApply = useCallback(() => { + onClose(); + }, [onClose]); + + // Set up virtualizer + const virtualizer = useVirtualizer({ + count: filteredSystems.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => ITEM_HEIGHT, + overscan: 5 + }); + + // Footer for multi-select mode + const footer = + mode === "multi" ? ( +
+
+ + {t("systemSelector.selectedCount", { + count: selectedSystems.length + })} + +
+
+ {selectedSystems.length > 0 && ( + + )} +
+
+ ) : undefined; + + return ( + +
+ {/* Header with search */} +
+ {/* Search bar */} +
+ + setSearchQuery(e.target.value)} + className="border-input bg-background text-foreground w-full rounded-md border px-10 py-2 text-sm focus:ring-2 focus:ring-white/20 focus:outline-none" + /> + {searchQuery && ( + + )} +
+
+ + {/* Category tabs using shadcn tabs */} + +
+ {/* Left gradient - only show when scrolled and overflowing */} + {hasOverflow && ( +
+ )} + +
+ + + {t("systemSelector.allCategories")} + + {categories.map((category) => ( + + {category} + + ))} + +
+ + {/* Right gradient - only show when more content and overflowing */} + {hasOverflow && ( +
+ )} +
+ + + {isLoading ? ( +
+ {t("loading")} +
+ ) : filteredSystems.length === 0 ? ( +
+ + {debouncedSearchQuery + ? t("systemSelector.noResults") + : t("systemSelector.noSystems")} + +
+ ) : ( +
+ {/* Add "All Systems" option for single/insert mode */} + {(mode === "single" || mode === "insert") && includeAllOption && selectedCategory === "all" && !debouncedSearchQuery.trim() && ( +
+ +
+ )} +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const system = filteredSystems[virtualItem.index]; + const isSelected = selectedSystems.includes(system.id); + + return ( +
+ +
+ ); + })} +
+
+
+ )} +
+ + + {/* Scroll to top button */} + +
+ + ); +} + +// Helper component for displaying selected systems +export function SystemSelectorTrigger({ + selectedSystems, + systemsData, + placeholder = "Select systems", + mode = "multi", + className, + onClick +}: { + selectedSystems: string[]; + systemsData?: { systems: System[] }; + placeholder?: string; + mode?: "single" | "multi" | "insert"; + className?: string; + onClick: () => void; +}) { + const { t } = useTranslation(); + + // Get indexing state to disable trigger when indexing is in progress + const gamesIndex = useStatusStore((state) => state.gamesIndex); + + const displayText = useMemo(() => { + if (!systemsData?.systems) return placeholder; + + if (selectedSystems.length === 0) { + return mode === "single" || mode === "insert" ? t("systemSelector.allSystems") : placeholder; + } + + if (selectedSystems.length === systemsData.systems.length) { + return t("systemSelector.allSystems"); + } + + if ((mode === "single" || mode === "insert") && selectedSystems.length === 1) { + const system = systemsData.systems.find( + (s) => s.id === selectedSystems[0] + ); + return system?.name || selectedSystems[0]; + } + + if (selectedSystems.length <= 3) { + const systemNames = selectedSystems + .map((id) => systemsData.systems.find((s) => s.id === id)?.name || id) + .join(", "); + return systemNames; + } + + return t("systemSelector.multipleSelected", { + count: selectedSystems.length + }); + }, [selectedSystems, systemsData, placeholder, mode, t]); + + const handleClick = () => { + // Don't open selector while indexing + if (gamesIndex.indexing) return; + onClick(); + }; + + return ( + + ); +} diff --git a/src/components/SystemsSearchModal.tsx b/src/components/SystemsSearchModal.tsx deleted file mode 100644 index 550e8342..00000000 --- a/src/components/SystemsSearchModal.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { useQuery } from "@tanstack/react-query"; -import { useRef, useState } from "react"; -import { CoreAPI } from "@/lib/coreApi.ts"; -import { SlideModal } from "@/components/SlideModal.tsx"; -import { Button } from "@/components/wui/Button.tsx"; -import { TextInput } from "@/components/wui/TextInput.tsx"; - -export function SystemsSearchModal(props: { - isOpen: boolean; - close: () => void; - onSelect: (systemId: string) => void; -}) { - const { t } = useTranslation(); - const containerRef = useRef(null); - const [filterText, setFilterText] = useState(""); - - const systems = useQuery({ - queryKey: ["systems"], - queryFn: () => CoreAPI.systems() - }); - - // Group systems by category and filter by name - const groupedSystems = - systems.data?.systems.reduce( - (acc, system) => { - if (!system.name.toLowerCase().includes(filterText.toLowerCase())) { - return acc; - } - - const category = system.category || "Other"; - if (!acc[category]) { - acc[category] = []; - } - acc[category].push(system); - return acc; - }, - {} as Record - ) ?? {}; - - return ( - -
-
- -
- -
- {systems.isLoading ? ( -
- {t("loading")} -
- ) : systems.error ? ( -
- {t("error")} -
- ) : ( -
- {Object.entries(groupedSystems) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([category, categorySystems]) => ( -
-
- {category} -
-
- {categorySystems - .sort((a, b) => a.name.localeCompare(b.name)) - .map((system) => ( -
-
- ))} -
- )} -
-
-
- ); -} diff --git a/src/components/TagBadge.tsx b/src/components/TagBadge.tsx new file mode 100644 index 00000000..1dc82957 --- /dev/null +++ b/src/components/TagBadge.tsx @@ -0,0 +1,12 @@ +interface TagBadgeProps { + type: string; + tag: string; +} + +export function TagBadge({ type, tag }: TagBadgeProps) { + return ( + + {type}:{tag} + + ); +} diff --git a/src/components/TagList.tsx b/src/components/TagList.tsx new file mode 100644 index 00000000..2b28364b --- /dev/null +++ b/src/components/TagList.tsx @@ -0,0 +1,41 @@ +import { TagInfo } from "@/lib/models"; +import { TagBadge } from "@/components/TagBadge"; + +interface TagListProps { + tags: TagInfo[]; + maxMobile?: number; + maxDesktop?: number; +} + +export function TagList({ tags, maxMobile = 2, maxDesktop = 4 }: TagListProps) { + if (!tags || tags.length === 0) return null; + + const sortedTags = tags.sort((a, b) => { + // Prioritize region and lang tags first + const aPriority = (a.type === 'region' || a.type === 'lang') ? 0 : 1; + const bPriority = (b.type === 'region' || b.type === 'lang') ? 0 : 1; + return aPriority - bPriority; + }); + + return ( +
+ {sortedTags + .slice(0, maxDesktop) + .map((tag, tagIndex) => ( + = maxMobile ? "hidden sm:inline-block" : ""}> + + + ))} + {tags.length > maxMobile && ( + + +{tags.length - maxMobile} + + )} + {tags.length > maxDesktop && ( + + +{tags.length - maxDesktop} + + )} +
+ ); +} diff --git a/src/components/TagSelector.tsx b/src/components/TagSelector.tsx new file mode 100644 index 00000000..a730375f --- /dev/null +++ b/src/components/TagSelector.tsx @@ -0,0 +1,563 @@ +import { useState, useMemo, useRef, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { Search, Check, X, ChevronUp, ChevronDown } from "lucide-react"; +import { useDebounce } from "use-debounce"; +import classNames from "classnames"; +import { CoreAPI } from "@/lib/coreApi"; +import { useStatusStore } from "@/lib/store"; +import { TagInfo } from "@/lib/models"; +import { SlideModal } from "./SlideModal"; +import { Button } from "./wui/Button"; +import { BackToTop } from "./BackToTop"; +import { + Accordion, + AccordionItem, + AccordionTrigger, + AccordionContent +} from "./ui/accordion"; + +interface TagSelectorProps { + isOpen: boolean; + onClose: () => void; + onSelect: (tags: string[]) => void; + selectedTags: string[]; + systems?: string[]; + title?: string; +} + +interface GroupedTags { + [type: string]: TagInfo[]; +} + +const ITEM_HEIGHT = 64; // Height of each tag item in pixels (increased for spacing) + +export function TagSelector({ + isOpen, + onClose, + onSelect, + selectedTags, + systems = [], + title +}: TagSelectorProps) { + const { t } = useTranslation(); + const scrollContainerRef = useRef(null); + const slideModalScrollRef = useRef(null); + + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedSearchQuery] = useDebounce(searchQuery, 300); + const [expandedSections, setExpandedSections] = useState([]); + const [allExpanded, setAllExpanded] = useState(false); + + // Get indexing state to disable selector when indexing is in progress + const gamesIndex = useStatusStore((state) => state.gamesIndex); + + // Fetch tags data + const { + data: tagsData, + isLoading, + isError + } = useQuery({ + queryKey: ["tags", systems], + queryFn: () => CoreAPI.mediaTags(systems.length > 0 ? systems : undefined), + enabled: isOpen, // Only fetch when modal is open + retry: false // Don't retry on error for backwards compatibility + }); + + // Process and group tags + const { groupedTags, types, allTags } = useMemo(() => { + if (!tagsData?.tags) { + return { groupedTags: {}, types: [], allTags: [] }; + } + + const tags = tagsData.tags; + + // Group tags by type + const grouped: GroupedTags = {}; + const typeSet = new Set(); + + tags.forEach((tag) => { + typeSet.add(tag.type); + + if (!grouped[tag.type]) { + grouped[tag.type] = []; + } + grouped[tag.type].push(tag); + }); + + // Sort types alphabetically, but put common ones first + const priorityTypes = ["genre", "year", "series", "publisher"]; + const types = Array.from(typeSet).sort((a, b) => { + const aPriority = priorityTypes.indexOf(a); + const bPriority = priorityTypes.indexOf(b); + + if (aPriority !== -1 && bPriority !== -1) { + return aPriority - bPriority; + } + if (aPriority !== -1) return -1; + if (bPriority !== -1) return 1; + return a.localeCompare(b); + }); + + // Sort tags within each group + Object.keys(grouped).forEach((type) => { + grouped[type].sort((a, b) => a.tag.localeCompare(b.tag)); + }); + + // Apply search filter if needed + let filteredGrouped = grouped; + let filteredAllTags = tags; + + if (debouncedSearchQuery.trim()) { + const query = debouncedSearchQuery.toLowerCase(); + filteredGrouped = {}; + filteredAllTags = []; + + Object.keys(grouped).forEach((type) => { + const filteredTags = grouped[type].filter( + (tag) => + tag.tag.toLowerCase().includes(query) || + tag.type.toLowerCase().includes(query) + ); + + if (filteredTags.length > 0) { + filteredGrouped[type] = filteredTags; + filteredAllTags.push(...filteredTags); + } + }); + } + + return { + groupedTags: filteredGrouped, + types: types.filter((type) => filteredGrouped[type]?.length > 0), + allTags: filteredAllTags + }; + }, [tagsData, debouncedSearchQuery]); + + // Handle tag selection + const handleTagSelect = useCallback( + (tag: TagInfo) => { + // Don't allow selection while indexing + if (gamesIndex.indexing) return; + + // Format tag as ":" for the API + const formattedTag = `${tag.type}:${tag.tag}`; + const newSelection = selectedTags.includes(formattedTag) + ? selectedTags.filter((t) => t !== formattedTag) + : [...selectedTags, formattedTag]; + onSelect(newSelection); + }, + [selectedTags, onSelect, gamesIndex.indexing] + ); + + // Handle clear all + const handleClearAll = useCallback(() => { + // Don't allow clearing while indexing + if (gamesIndex.indexing) return; + onSelect([]); + }, [onSelect, gamesIndex.indexing]); + + // Handle apply + const handleApply = useCallback(() => { + onClose(); + }, [onClose]); + + // Handle expand/collapse all + const handleExpandCollapseAll = useCallback(() => { + if (allExpanded) { + setExpandedSections([]); + setAllExpanded(false); + } else { + setExpandedSections(types); + setAllExpanded(true); + } + }, [allExpanded, types]); + + // Handle accordion expand change + const handleAccordionChange = useCallback( + (expanded: string[]) => { + setExpandedSections(expanded); + setAllExpanded(expanded.length === types.length); + }, + [types.length] + ); + + // Set up virtualizer for all tags (used when search is active) + const virtualizer = useVirtualizer({ + count: allTags.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => ITEM_HEIGHT, + overscan: 5 + }); + + // Footer for multi-select mode + const footer = ( +
+
+ + {t("tagSelector.selectedCount", { + count: selectedTags.length + })} + +
+
+ {selectedTags.length > 0 && ( + + )} +
+
+ ); + + return ( + +
+ {/* Header with search */} +
+ {/* Search bar */} +
+ + setSearchQuery(e.target.value)} + className="border-input bg-background text-foreground w-full rounded-md border px-10 py-2 text-sm focus:ring-2 focus:ring-white/20 focus:outline-none" + /> + {searchQuery && ( + + )} +
+ + {/* Expand/Collapse all button */} + {types.length > 0 && !debouncedSearchQuery && ( + + )} +
+ + {/* Content area */} +
+ {isLoading ? ( +
+ {t("loading")} +
+ ) : isError ? ( +
+ + {t("tagSelector.unavailable", { + defaultValue: "Tags unavailable" + })} + +
+ ) : allTags.length === 0 ? ( +
+ + {debouncedSearchQuery + ? t("tagSelector.noResults") + : t("tagSelector.noTags")} + +
+ ) : debouncedSearchQuery ? ( + // Search results - show virtualized list of all matching tags +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const tag = allTags[virtualItem.index]; + const formattedTag = `${tag.type}:${tag.tag}`; + const isSelected = selectedTags.includes(formattedTag); + + return ( +
+ +
+ ); + })} +
+
+ ) : ( + // Accordion view for organized categories +
+ + {types.map((type) => { + const tagsInType = groupedTags[type] || []; + const selectedInType = tagsInType.filter((tag) => { + const formattedTag = `${tag.type}:${tag.tag}`; + return selectedTags.includes(formattedTag); + }).length; + + return ( + + +
+ + {t(`tagSelector.type.${type}`, { + defaultValue: type + })}{" "} + ({tagsInType.length}) + + {selectedInType > 0 && ( + + {selectedInType} + + )} +
+
+ +
+ {tagsInType.map((tag) => { + const formattedTag = `${tag.type}:${tag.tag}`; + const isSelected = + selectedTags.includes(formattedTag); + + return ( + + ); + })} +
+
+
+ ); + })} +
+
+ )} +
+ + {/* Scroll to top button */} + +
+
+ ); +} + +// Helper component for displaying selected tags +export function TagSelectorTrigger({ + selectedTags, + placeholder = "Select tags", + className, + onClick, + disabled = false +}: { + selectedTags: string[]; + placeholder?: string; + className?: string; + onClick: () => void; + disabled?: boolean; +}) { + const { t } = useTranslation(); + + // Get indexing state to disable trigger when indexing is in progress + const gamesIndex = useStatusStore((state) => state.gamesIndex); + + const displayText = useMemo(() => { + if (selectedTags.length === 0) { + return placeholder; + } + + // Show full canonical "type:value" format + if (selectedTags.length <= 3) { + return selectedTags.join(", "); + } + + return t("tagSelector.multipleSelected", { + count: selectedTags.length + }); + }, [selectedTags, placeholder, t]); + + const handleClick = () => { + // Don't open selector while indexing or if disabled + if (gamesIndex.indexing || disabled) return; + onClick(); + }; + + return ( + + ); +} diff --git a/src/components/TourInitializer.tsx b/src/components/TourInitializer.tsx new file mode 100644 index 00000000..8750844d --- /dev/null +++ b/src/components/TourInitializer.tsx @@ -0,0 +1,41 @@ +import { useEffect } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { useTranslation } from "react-i18next"; +import { usePreferencesStore } from "@/lib/preferencesStore"; +import { useStatusStore } from "@/lib/store"; +import { createAppTour } from "@/lib/tourService"; + +export function TourInitializer() { + const navigate = useNavigate(); + const { t } = useTranslation(); + const tourCompleted = usePreferencesStore((state) => state.tourCompleted); + const setTourCompleted = usePreferencesStore( + (state) => state.setTourCompleted + ); + + useEffect(() => { + // Only start tour if not completed and after a delay for DOM readiness + if (!tourCompleted) { + const timer = setTimeout(() => { + // Check connection state right before starting tour (after 1s delay) + const isConnected = useStatusStore.getState().connected; + const tour = createAppTour(navigate, t, isConnected); + + // Mark as completed when tour finishes or is cancelled + tour.on("complete", () => { + setTourCompleted(true); + }); + + tour.on("cancel", () => { + setTourCompleted(true); + }); + + tour.start(); + }, 1000); + + return () => clearTimeout(timer); + } + }, [tourCompleted, navigate, setTourCompleted, t]); + + return null; +} diff --git a/src/components/VirtualSearchResults.tsx b/src/components/VirtualSearchResults.tsx new file mode 100644 index 00000000..d4b0a2c2 --- /dev/null +++ b/src/components/VirtualSearchResults.tsx @@ -0,0 +1,345 @@ +import React, { useCallback, useEffect, RefObject } from "react"; +import { useTranslation } from "react-i18next"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { Link } from "@tanstack/react-router"; +import { SearchResultGame } from "@/lib/models.ts"; +import { useStatusStore } from "@/lib/store.ts"; +import { Card } from "@/components/wui/Card.tsx"; +import { NextIcon, SettingsIcon, WarningIcon } from "@/lib/images.tsx"; +import { LoadingSpinner } from "@/components/ui/loading-spinner.tsx"; +import { Button } from "@/components/wui/Button.tsx"; +import { useVirtualInfiniteSearch } from "@/hooks/useVirtualInfiniteSearch"; +import { TagList } from "@/components/TagList.tsx"; + + +export interface VirtualSearchResultsProps { + query: string; + systems: string[]; + tags?: string[]; + selectedResult: SearchResultGame | null; + setSelectedResult: (game: SearchResultGame | null) => void; + hasSearched?: boolean; + searchSystem?: string; + searchTags?: string[]; + onClearFilters?: () => void; + isSearching?: boolean; + onSearchComplete?: () => void; + scrollContainerRef?: RefObject; +} + +export function VirtualSearchResults({ + query, + systems, + tags = [], + selectedResult, + setSelectedResult, + hasSearched = false, + searchSystem = "all", + searchTags = [], + onClearFilters, + isSearching = false, + onSearchComplete, + scrollContainerRef +}: VirtualSearchResultsProps) { + const gamesIndex = useStatusStore((state) => state.gamesIndex); + const { t } = useTranslation(); + + const { + allItems, + totalCount, + isLoading, + isFetchingNextPage, + hasNextPage, + isError, + fetchNextPage, + refetch + } = useVirtualInfiniteSearch({ + query, + systems, + tags, + enabled: hasSearched && gamesIndex.exists && !gamesIndex.indexing + }); + + // Call onSearchComplete when loading finishes + useEffect(() => { + if (!isLoading && hasSearched && onSearchComplete) { + onSearchComplete(); + } + }, [isLoading, hasSearched, onSearchComplete]); + + // Calculate estimated item height based on whether tags are shown + const estimateSize = useCallback((index: number) => { + if (index >= allItems.length) return 60; // Loading item + const item = allItems[index]; + // Increased estimates to accommodate wrapped text and reduce layout shifts + // Base height: 32px (pt-3 pb-5) + content + 1px border = total (gap handled by virtualizer) + // Without tags: ~60px content + 32px padding + 1px border = 93px + // With tags: ~80px content + 32px padding + 1px border = 113px + return item.tags && item.tags.length > 0 ? 113 : 93; + }, [allItems]); + + const virtualizer = useVirtualizer({ + count: totalCount + (hasNextPage ? 1 : 0), // +1 for loading sentinel + getScrollElement: () => scrollContainerRef?.current || null, + estimateSize, + overscan: 5, + scrollMargin: 8, + gap: 8 + }); + + // Fetch next page when approaching the end + const virtualItems = virtualizer.getVirtualItems(); + + useEffect(() => { + const [lastItem] = [...virtualItems].reverse(); + + if (!lastItem) return; + + if ( + lastItem.index >= totalCount - 5 && + hasNextPage && + !isFetchingNextPage + ) { + fetchNextPage(); + } + }, [ + virtualItems, + hasNextPage, + fetchNextPage, + totalCount, + isFetchingNextPage + ]); + + // Screen reader announcement for search results + const getAriaLiveMessage = () => { + if (isLoading) return t("create.search.loading"); + if (isError) return t("create.search.searchError"); + if (totalCount > 0) { + return `${totalCount} ${totalCount === 1 ? 'result' : 'results'} found`; + } + return ""; + }; + + if (!gamesIndex.exists) { + return ( + +
+
+ +
+
+ + {t("create.search.gamesDbUpdate")} + +
+ +
+
+ ); + } + + // Show initial state when no search has been performed + if (!hasSearched) { + return ( +
+

{t("create.search.startSearching")}

+

{t("create.search.startSearchingHint")}

+
+ ); + } + + // Show loading spinner when searching initially + if (isLoading || isSearching) { + return ( +
+ + {t("create.search.loading")} +
+ ); + } + + if (isError) { + return ( +
+

{t("create.search.searchError")}

+
+ ); + } + + // Enhanced empty state with helpful suggestions + if (totalCount === 0) { + const hasActiveFilters = searchSystem !== "all" || (searchTags && searchTags.length > 0); + const hasQuery = query && query.trim().length > 0; + + let mainMessage: string; + let suggestionMessage: string; + + if (!hasQuery) { + // No search query - just show simple message + mainMessage = t("create.search.noResultsFoundSimple"); + suggestionMessage = hasActiveFilters + ? t("create.search.tryRemovingFiltersOnly") + : ""; + } else { + // Has search query + mainMessage = t("create.search.noResultsFound", { query: query }); + if (hasActiveFilters) { + suggestionMessage = t("create.search.tryDifferentSearch"); + } else { + suggestionMessage = t("create.search.tryDifferentTerms"); + } + } + + return ( +
+

{mainMessage}

+ {suggestionMessage && ( +

+ {suggestionMessage} +

+ )} + {hasActiveFilters && onClearFilters && ( +
+
+ )} +
+ ); + } + + return ( + <> + {/* Screen reader announcements */} +
+ {getAriaLiveMessage()} +
+ + {/* Virtual scrolling container */} +
+ {virtualItems.map((virtualItem) => { + const isLoading = virtualItem.index >= totalCount; + const game = allItems[virtualItem.index]; + + return ( +
+ {isLoading ? ( +
+ + {t("create.search.loading")} +
+ ) : ( + + )} +
+ ); + })} +
+ + ); +} + +interface SearchResultItemProps { + game: SearchResultGame; + selectedResult: SearchResultGame | null; + setSelectedResult: (game: SearchResultGame | null) => void; + isLast: boolean; + index: number; +} + +const SearchResultItem = React.memo(function SearchResultItem({ + game, + selectedResult, + setSelectedResult, + isLast, + index +}: SearchResultItemProps) { + const handleGameSelect = () => { + if ( + selectedResult && + selectedResult.path === game.path + ) { + setSelectedResult(null); + } else if ( + selectedResult && + selectedResult.path !== game.path + ) { + setSelectedResult(null); + setTimeout(() => { + setSelectedResult(game); + }, 150); + } else { + setSelectedResult(game); + } + }; + + return ( +
{ + e.preventDefault(); + handleGameSelect(); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleGameSelect(); + } + }} + > +
+

{game.name}

+

{game.system.name}

+ +
+
+ +
+
+ ); +}); \ No newline at end of file diff --git a/src/components/ZapScriptInput.tsx b/src/components/ZapScriptInput.tsx index cd19f4f6..1930595e 100644 --- a/src/components/ZapScriptInput.tsx +++ b/src/components/ZapScriptInput.tsx @@ -9,7 +9,7 @@ import { CoreAPI } from "@/lib/coreApi.ts"; import { Button } from "@/components/wui/Button.tsx"; import { MediaSearchModal } from "@/components/MediaSearchModal.tsx"; import { CommandsModal } from "@/components/CommandsModal.tsx"; -import { SystemsSearchModal } from "@/components/SystemsSearchModal.tsx"; +import { SystemSelector } from "@/components/SystemSelector.tsx"; import { PlayIcon } from "@/lib/images.tsx"; import { ConfirmClearModal } from "@/components/ConfirmClearModal.tsx"; @@ -183,12 +183,18 @@ export function ZapScriptInput(props: { props.setValue(""); }} /> - setSystemsOpen(false)} - onSelect={(systemId) => { - insertTextAtCursor(systemId); + onClose={() => setSystemsOpen(false)} + onSelect={(systems) => { + if (systems.length > 0) { + insertTextAtCursor(systems[0]); + } }} + selectedSystems={[]} + mode="insert" + title={t("create.custom.selectSystem")} + includeAllOption={false} /> -
-
- -
-
- {t("scan.reconnecting")} -
- -
- - ); - } - if (isError) { return ( diff --git a/src/components/home/ScanControls.tsx b/src/components/home/ScanControls.tsx index 0cbfee9d..3903462f 100644 --- a/src/components/home/ScanControls.tsx +++ b/src/components/home/ScanControls.tsx @@ -4,6 +4,7 @@ import { QrCodeIcon } from "lucide-react"; import { ScanSpinner } from "../ScanSpinner"; import { Button } from "../wui/Button"; import { ScanResult } from "../../lib/models"; +import { usePreferencesStore } from "../../lib/preferencesStore"; interface ScanControlsProps { scanSession: boolean; @@ -21,6 +22,7 @@ export function ScanControls({ onCameraScan }: ScanControlsProps) { const { t } = useTranslation(); + const cameraAvailable = usePreferencesStore((state) => state.cameraAvailable); return ( <> @@ -39,7 +41,7 @@ export function ScanControls({
)} - {connected && Capacitor.isNativePlatform() && ( + {connected && Capacitor.isNativePlatform() && cameraAvailable && (
); -} \ No newline at end of file +} diff --git a/src/components/nfc/ReadTab.tsx b/src/components/nfc/ReadTab.tsx index 4e62b1b0..d78fd2a7 100644 --- a/src/components/nfc/ReadTab.tsx +++ b/src/components/nfc/ReadTab.tsx @@ -4,13 +4,12 @@ import { EyeIcon, EyeOffIcon, ShareIcon, - WifiIcon, - GlobeIcon, - MessageCircleIcon, - FileTextIcon, NfcIcon } from "lucide-react"; import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; +import { Clipboard } from "@capacitor/clipboard"; +import { Share } from "@capacitor/share"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/wui/Button"; import { Result } from "@/lib/nfc"; @@ -21,46 +20,38 @@ interface ReadTabProps { } export function ReadTab({ result, onScan }: ReadTabProps) { + const { t } = useTranslation(); const [showRawData, setShowRawData] = useState(false); const copyToClipboard = async (text: string, label: string) => { try { - await navigator.clipboard.writeText(text); - toast.success(`${label} copied to clipboard`); + await Clipboard.write({ string: text }); + toast.success(t("create.nfc.readTab.copiedToClipboard", { label })); } catch { - toast.error("Failed to copy to clipboard"); + toast.error(t("create.nfc.readTab.copyFailed")); } }; const shareTagData = async () => { if (!result?.info?.tag) return; - const shareData = { - title: "NFC Tag Data", - text: `UID: ${result.info.tag.uid}\nText: ${result.info.tag.text}` - }; + const shareText = t("create.nfc.readTab.shareText", { + uid: result.info.tag.uid, + text: result.info.tag.text + }); - if (navigator.share) { - try { - await navigator.share(shareData); - } catch { - copyToClipboard(shareData.text, "Tag data"); - } - } else { - copyToClipboard(shareData.text, "Tag data"); + try { + await Share.share({ + title: t("create.nfc.readTab.shareTitle"), + text: shareText, + dialogTitle: t("create.nfc.readTab.shareTitle") + }); + } catch { + // Fallback to copy if share fails or is cancelled + copyToClipboard(shareText, t("create.nfc.readTab.tagData")); } }; - const getContentTypeIcon = (text: string) => { - if (text.startsWith("http")) - return ; - if (text.startsWith("wifi:")) - return ; - if (text.includes("@")) - return ; - return ; - }; - const { tag, rawTag } = result?.info || { tag: null, rawTag: null }; return ( @@ -69,28 +60,27 @@ export function ReadTab({ result, onScan }: ReadTabProps) {
- +
{tag?.uid || "-"} @@ -98,37 +88,20 @@ export function ReadTab({ result, onScan }: ReadTabProps) { {tag?.uid && (
- +
{tag?.text ? ( - <> -
- {getContentTypeIcon(tag.text)} - - {tag.text.startsWith("http") && "URL"} - {tag.text.startsWith("wifi:") && "WiFi Credentials"} - {tag.text.includes("@") && - !tag.text.startsWith("http") && - "Email"} - {!tag.text.startsWith("http") && - !tag.text.startsWith("wifi:") && - !tag.text.includes("@") && - "Text"} - -
-
{tag.text}
- +
{tag.text}
) : (
-
)} @@ -136,10 +109,9 @@ export function ReadTab({ result, onScan }: ReadTabProps) { {tag?.text && (
@@ -149,11 +121,11 @@ export function ReadTab({ result, onScan }: ReadTabProps) { {/* Technical Details Card */}
-

Technical Details

+

{t("create.nfc.readTab.technicalDetails")}

- +
{rawTag?.isWritable !== undefined ? ( - {rawTag.isWritable ? "Yes" : "No"} + {rawTag.isWritable ? t("create.nfc.readTab.yes") : t("create.nfc.readTab.no")} ) : ( - @@ -172,14 +144,14 @@ export function ReadTab({ result, onScan }: ReadTabProps) {
- +
- {rawTag?.maxSize ? `${rawTag.maxSize} bytes` : "-"} + {rawTag?.maxSize ? `${rawTag.maxSize} ${t("create.nfc.readTab.bytes")}` : "-"}
- +
{rawTag?.canMakeReadOnly !== undefined ? ( - {rawTag.canMakeReadOnly ? "Yes" : "No"} + {rawTag.canMakeReadOnly ? t("create.nfc.readTab.yes") : t("create.nfc.readTab.no")} ) : ( - @@ -198,7 +170,7 @@ export function ReadTab({ result, onScan }: ReadTabProps) {
- +
{rawTag?.manufacturerCode !== undefined ? rawTag.manufacturerCode @@ -207,7 +179,7 @@ export function ReadTab({ result, onScan }: ReadTabProps) {
- +
{rawTag?.techTypes ? ( rawTag.techTypes.map((tech, index) => ( @@ -226,28 +198,28 @@ export function ReadTab({ result, onScan }: ReadTabProps) {
- + {rawTag?.message?.records && rawTag.message.records.length > 0 ? ( <>
{rawTag.message.records.map((record, index) => (
- Record {index + 1} + {t("create.nfc.readTab.record", { index: index + 1 })}
- TNF: {record.tnf} + {t("create.nfc.readTab.tnf")} {record.tnf}
{record.type && record.type.length > 0 && (
- Type:{" "} + {t("create.nfc.readTab.type")}{" "} {record.type[0].toString(16)}
)} {record.payload && (
- Payload: + {t("create.nfc.readTab.payload")}
{showRawData ? record.payload @@ -269,7 +241,7 @@ export function ReadTab({ result, onScan }: ReadTabProps) { icon={ showRawData ? : } - label={showRawData ? "Hide Hex" : "Show Hex"} + label={showRawData ? t("create.nfc.readTab.hideHex") : t("create.nfc.readTab.showHex")} onClick={() => setShowRawData(!showRawData)} size="sm" variant="outline" @@ -284,12 +256,12 @@ export function ReadTab({ result, onScan }: ReadTabProps) { {/* Low-Level Tag Data Card */}
-

Low-Level Tag Data

+

{t("create.nfc.readTab.lowLevelTagData")}

{/* Tag ID */}
- +
{rawTag?.id @@ -310,12 +282,11 @@ export function ReadTab({ result, onScan }: ReadTabProps) { rawTag .id!.map((byte) => byte.toString(16).padStart(2, "0")) .join(""), - "Tag ID" + t("create.nfc.readTab.tagId") ) } size="sm" variant="outline" - className="!px-3" /> )}
@@ -323,7 +294,7 @@ export function ReadTab({ result, onScan }: ReadTabProps) { {/* ATQA bytes (Android NFC-A) */}
- +
{rawTag?.atqa ? showRawData @@ -337,7 +308,7 @@ export function ReadTab({ result, onScan }: ReadTabProps) { {/* SAK bytes (Android NFC-A) */}
- +
{rawTag?.sak ? showRawData @@ -351,7 +322,7 @@ export function ReadTab({ result, onScan }: ReadTabProps) { {/* Application Data (NFC-B) */}
- +
{rawTag?.applicationData ? showRawData @@ -365,7 +336,7 @@ export function ReadTab({ result, onScan }: ReadTabProps) { {/* Historical Bytes (ISO-DEP) */}
- +
{rawTag?.historicalBytes ? showRawData @@ -379,7 +350,7 @@ export function ReadTab({ result, onScan }: ReadTabProps) { {/* Manufacturer bytes (NFC-F) */}
- +
{rawTag?.manufacturer ? showRawData @@ -393,7 +364,7 @@ export function ReadTab({ result, onScan }: ReadTabProps) { {/* System Code (NFC-F) */}
- +
{rawTag?.systemCode ? showRawData diff --git a/src/components/nfc/ToolsTab.tsx b/src/components/nfc/ToolsTab.tsx index 56fdd192..8e4276cd 100644 --- a/src/components/nfc/ToolsTab.tsx +++ b/src/components/nfc/ToolsTab.tsx @@ -81,15 +81,6 @@ export function ToolsTab({ onToolAction, isProcessing }: ToolsTabProps) { {tool.description}

- {tool.dangerous && ( -
- -

- {tool.warning} -

-
- )} -
); diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 00000000..522e8555 --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,54 @@ +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/src/components/ui/loading-spinner.tsx b/src/components/ui/loading-spinner.tsx new file mode 100644 index 00000000..85f78a59 --- /dev/null +++ b/src/components/ui/loading-spinner.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +interface LoadingSpinnerProps { + className?: string; + size?: number; +} + +export const LoadingSpinner = React.forwardRef< + SVGSVGElement, + LoadingSpinnerProps +>(({ className, size = 24 }, ref) => { + return ( + + + + ); +}); + +LoadingSpinner.displayName = "LoadingSpinner"; \ No newline at end of file diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index ddaefccd..532c8ed8 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -11,7 +11,7 @@ const TabsList = React.forwardRef< void; + icon: ReactElement; + disabled?: boolean; + active?: boolean; + title?: string; + "aria-label"?: string; + className?: string; +} + +export const HeaderButton = memo(function HeaderButton(props: HeaderButtonProps) { + const [isPressed, setIsPressed] = useState(false); + const touchStartPos = useRef<{ x: number; y: number } | null>(null); + const hasMoved = useRef(false); + + return ( + + ); +}); \ No newline at end of file diff --git a/src/components/wui/TextInput.tsx b/src/components/wui/TextInput.tsx index edff62b6..070437ea 100644 --- a/src/components/wui/TextInput.tsx +++ b/src/components/wui/TextInput.tsx @@ -1,6 +1,6 @@ import React, { KeyboardEventHandler, useEffect, useState } from "react"; import classNames from "classnames"; -import { SaveIcon } from "../../lib/images"; +import { SaveIcon, ClearIcon } from "../../lib/images"; import { Button } from "./Button"; export function TextInput(props: { @@ -11,6 +11,7 @@ export function TextInput(props: { disabled?: boolean; className?: string; saveValue?: (value: string) => void; + clearable?: boolean; type?: string; onKeyUp?: KeyboardEventHandler; ref?: React.RefObject; @@ -29,40 +30,60 @@ export function TextInput(props: { return (
- {props.label && {props.label}} + {props.label && {props.label}}
- { - setValue(e.target.value); - setModified(true); +
+ 0 && !props.disabled, + "rounded-md": !props.saveValue, + "rounded-s-md": props.saveValue + } + )} + disabled={props.disabled} + placeholder={props.placeholder} + value={value} + onChange={(e) => { + setValue(e.target.value); + setModified(true); - if (props.setValue) { - props.setValue(e.target.value); - } - }} - onKeyUp={props.onKeyUp} - /> + if (props.setValue) { + props.setValue(e.target.value); + } + }} + onKeyUp={props.onKeyUp} + /> + {props.clearable && value && value.length > 0 && !props.disabled && ( + + )} +
{props.saveValue && (
- } + onClick={performSearch} + disabled={!canSearch || isSearching} + className="w-full" /> - +
+ + setIsSearching(false)} + scrollContainerRef={scrollContainerRef} + /> + + setSelectedResult(null)} title={selectedResult?.name || "Game Details"} > -
-

- {t("create.search.systemLabel")} {selectedResult?.system.name} -

-

- {t("create.search.pathLabel")} {selectedResult?.path}{" "} - {selectedResult?.path !== "" && ( - +

+ {/* Primary Info */} +
+
+
+ + + {t("create.search.systemLabel")} + +
+ + {selectedResult?.system.name} + +
+ {selectedResult?.tags && selectedResult.tags.length > 0 && ( +
+
+ + + {t("create.search.tagsLabel")} + +
+
+ {selectedResult.tags.map((tag, tagIndex) => ( + + ))} +
+
)} -

-
+
+ + {/* Technical Details - Selectable Options */} +
+ Select value to write to tag + + {/* Path Option */} +
+ setWriteMode("path")} + className="sr-only" + /> +
+ + {/* Actions */} +
- + + setSystemSelectorOpen(false)} + onSelect={handleSystemSelect} + selectedSystems={querySystem === "all" ? [] : [querySystem]} + mode="single" + title={t("create.search.selectSystem")} + includeAllOption={false} + defaultSelection="all" + /> + setTagSelectorOpen(false)} + onSelect={handleTagSelect} + selectedTags={queryTags} + systems={querySystem === "all" ? [] : [querySystem]} + title={t("create.search.selectTags")} + /> + setRecentSearchesOpen(false)} + recentSearches={recentSearches} + onSearchSelect={handleRecentSearchSelect} + onClearHistory={clearRecentSearches} + getSearchDisplayText={getSearchDisplayText} + /> ); } diff --git a/src/routes/index.tsx b/src/routes/index.tsx index fe8f4e2f..3a49abcf 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -2,14 +2,11 @@ import { createFileRoute } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { KeepAwake } from "@capacitor-community/keep-awake"; -import { Preferences } from "@capacitor/preferences"; import { useNfcWriter, WriteMethod } from "@/lib/writeNfcHook.tsx"; import { Status } from "@/lib/nfc.ts"; import { useProPurchase } from "@/components/ProPurchase.tsx"; import { WriteModal } from "@/components/WriteModal.tsx"; -import { useWriteQueueProcessor } from "@/hooks/useWriteQueueProcessor.tsx"; -import { useRunQueueProcessor } from "@/hooks/useRunQueueProcessor.tsx"; -import logoImage from "@/assets/lockup.png"; +import logoImage from "@/assets/lockup.webp"; import { cancelSession } from "../lib/nfc"; import { CoreAPI } from "../lib/coreApi.ts"; import { HistoryIcon } from "../lib/images"; @@ -23,30 +20,27 @@ import { NowPlayingInfo } from "../components/home/NowPlayingInfo"; import { HistoryModal } from "../components/home/HistoryModal"; import { StopConfirmModal } from "../components/home/StopConfirmModal"; import { useScanOperations } from "../hooks/useScanOperations"; -import { useAppSettings } from "../hooks/useAppSettings"; +import { usePreferencesStore } from "../lib/preferencesStore"; interface LoaderData { restartScan: boolean; launchOnScan: boolean; launcherAccess: boolean; preferRemoteWriter: boolean; + shakeMode: "random" | "custom"; + shakeZapscript: string; } export const Route = createFileRoute("/")({ - loader: async (): Promise => { - const [restartResult, launchResult, accessResult, remoteWriterResult] = - await Promise.all([ - Preferences.get({ key: "restartScan" }), - Preferences.get({ key: "launchOnScan" }), - Preferences.get({ key: "launcherAccess" }), - Preferences.get({ key: "preferRemoteWriter" }) - ]); - + loader: (): LoaderData => { + const state = usePreferencesStore.getState(); return { - restartScan: restartResult.value === "true", - launchOnScan: launchResult.value !== "false", - launcherAccess: accessResult.value === "true", - preferRemoteWriter: remoteWriterResult.value === "true" + restartScan: state.restartScan, + launchOnScan: state.launchOnScan, + launcherAccess: state.launcherAccess, + preferRemoteWriter: state.preferRemoteWriter, + shakeMode: state.shakeMode, + shakeZapscript: state.shakeZapscript }; }, ssr: false, @@ -55,26 +49,31 @@ export const Route = createFileRoute("/")({ function Index() { const initData = Route.useLoaderData(); - - const { launcherAccess, preferRemoteWriter } = useAppSettings({ initData }); + const launcherAccess = usePreferencesStore((state) => state.launcherAccess); + const preferRemoteWriter = usePreferencesStore( + (state) => state.preferRemoteWriter + ); const nfcWriter = useNfcWriter(WriteMethod.Auto, preferRemoteWriter); - const [writeOpen, setWriteOpen] = useState(false); + const writeOpen = useStatusStore((state) => state.writeOpen); + const setWriteOpen = useStatusStore((state) => state.setWriteOpen); + const setWriteQueue = useStatusStore((state) => state.setWriteQueue); const closeWriteModal = async () => { setWriteOpen(false); await nfcWriter.end(); - resetWriteQueue(); + setWriteQueue(""); }; useEffect(() => { // Only auto-close on successful write completion if (nfcWriter.status === Status.Success) { setWriteOpen(false); } - }, [nfcWriter.status]); + }, [nfcWriter.status, setWriteOpen]); const { PurchaseModal, proPurchaseModalOpen, setProPurchaseModalOpen } = useProPurchase(initData.launcherAccess); const connected = useStatusStore((state) => state.connected); + const connectionState = useStatusStore((state) => state.connectionState); const playing = useStatusStore((state) => state.playing); const lastToken = useStatusStore((state) => state.lastToken); const setLastToken = useStatusStore((state) => state.setLastToken); @@ -87,8 +86,7 @@ function Index() { scanStatus, handleScanButton, handleCameraScan, - handleStopConfirm, - runToken + handleStopConfirm } = useScanOperations({ connected, launcherAccess, @@ -97,18 +95,6 @@ function Index() { setWriteOpen }); - useRunQueueProcessor({ - launcherAccess, - setLastToken, - setProPurchaseModalOpen, - runToken - }); - - const { reset: resetWriteQueue } = useWriteQueueProcessor({ - nfcWriter, - setWriteOpen - }); - const history = useQuery({ queryKey: ["history"], queryFn: () => CoreAPI.history(), @@ -167,7 +153,7 @@ function Index() { />
- + diff --git a/src/routes/settings.advanced.tsx b/src/routes/settings.advanced.tsx deleted file mode 100644 index 2b45a2ee..00000000 --- a/src/routes/settings.advanced.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { useTranslation } from "react-i18next"; -import { Capacitor } from "@capacitor/core"; -import { useEffect, useState } from "react"; -import { Nfc } from "@capawesome-team/capacitor-nfc"; -import { Preferences } from "@capacitor/preferences"; -import classNames from "classnames"; -import { CoreAPI } from "../lib/coreApi.ts"; -import { ToggleSwitch } from "../components/wui/ToggleSwitch"; -import { useSmartSwipe } from "../hooks/useSmartSwipe"; -import { useStatusStore } from "../lib/store"; -import { PageFrame } from "../components/PageFrame"; -import { UpdateSettingsRequest } from "../lib/models.ts"; -import { useAppSettings } from "../hooks/useAppSettings"; -import { BackIcon, CheckIcon } from "../lib/images"; - -interface LoaderData { - restartScan: boolean; - launchOnScan: boolean; - launcherAccess: boolean; - preferRemoteWriter: boolean; -} - -export const Route = createFileRoute("/settings/advanced")({ - loader: async (): Promise => { - const [restartResult, launchResult, accessResult, remoteWriterResult] = - await Promise.all([ - Preferences.get({ key: "restartScan" }), - Preferences.get({ key: "launchOnScan" }), - Preferences.get({ key: "launcherAccess" }), - Preferences.get({ key: "preferRemoteWriter" }), - ]); - - return { - restartScan: restartResult.value === "true", - launchOnScan: launchResult.value !== "false", - launcherAccess: accessResult.value === "true", - preferRemoteWriter: remoteWriterResult.value === "true", - }; - }, - component: Advanced -}); - -function Advanced() { - const initData = Route.useLoaderData(); - const connected = useStatusStore((state) => state.connected); - const [hasLocalNFC, setHasLocalNFC] = useState(false); - - const { preferRemoteWriter, setPreferRemoteWriter } = useAppSettings({ initData }); - - useEffect(() => { - // Check if local NFC is available on native platforms - if (Capacitor.isNativePlatform()) { - Nfc.isAvailable() - .then((result) => setHasLocalNFC(result.nfc)) - .catch(() => setHasLocalNFC(false)); - } - }, []); - - const { data, refetch } = useQuery({ - queryKey: ["settings"], - queryFn: () => CoreAPI.settings() - }); - - const update = useMutation({ - mutationFn: (params: UpdateSettingsRequest) => - CoreAPI.settingsUpdate(params), - onSuccess: () => refetch() - }); - - const { t } = useTranslation(); - - const navigate = useNavigate(); - const swipeHandlers = useSmartSwipe({ - onSwipeRight: () => navigate({ to: "/settings" }), - preventScrollOnSwipe: false - }); - - return ( - navigate({ to: "/settings" })} className="cursor-pointer"> - - - } - headerCenter={ -

{t("settings.advanced.title")}

- } - > -
- update.mutate({ audioScanFeedback: v })} - disabled={!connected} - /> -
- -
- update.mutate({ readersAutoDetect: v })} - disabled={!connected} - /> -
- -
- update.mutate({ debugLogging: v })} - disabled={!connected} - /> -
- - {Capacitor.isNativePlatform() && hasLocalNFC && ( -
- -
- )} - -
- {t("settings.modeLabel")} -
- - -
- {data?.readersScanMode === "hold" && connected && ( -

{t("settings.insertHelp")}

- )} -
- - {/*
*/} - {/* */} - {/* update.mutate({ readersScanIgnoreSystems: v.split(",") })*/} - {/* }*/} - {/* disabled={!connected}*/} - {/* />*/} - {/*
*/} -
- ); -} diff --git a/src/routes/settings.app.tsx b/src/routes/settings.app.tsx new file mode 100644 index 00000000..82cfa2b0 --- /dev/null +++ b/src/routes/settings.app.tsx @@ -0,0 +1,284 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useTranslation } from "react-i18next"; +import { Capacitor } from "@capacitor/core"; +import { useState } from "react"; +import { useShallow } from "zustand/react/shallow"; +import classNames from "classnames"; +import { ToggleSwitch } from "../components/wui/ToggleSwitch"; +import { useSmartSwipe } from "../hooks/useSmartSwipe"; +import { useStatusStore } from "../lib/store"; +import { PageFrame } from "../components/PageFrame"; +import { + usePreferencesStore, + selectAppSettings, + selectShakeSettings +} from "../lib/preferencesStore"; +import { BackIcon, CheckIcon } from "../lib/images"; +import { ScanSettings } from "../components/home/ScanSettings.tsx"; +import { SystemSelector } from "../components/SystemSelector"; +import { Button } from "../components/wui/Button"; +import { useProPurchase } from "../components/ProPurchase"; +import { ZapScriptInput } from "../components/ZapScriptInput"; + +interface LoaderData { + restartScan: boolean; + launchOnScan: boolean; + launcherAccess: boolean; + preferRemoteWriter: boolean; + shakeEnabled: boolean; + shakeMode: "random" | "custom"; + shakeZapscript: string; +} + +export const Route = createFileRoute("/settings/app")({ + loader: (): LoaderData => { + const state = usePreferencesStore.getState(); + return { + restartScan: state.restartScan, + launchOnScan: state.launchOnScan, + launcherAccess: state.launcherAccess, + preferRemoteWriter: state.preferRemoteWriter, + shakeEnabled: state.shakeEnabled, + shakeMode: state.shakeMode, + shakeZapscript: state.shakeZapscript + }; + }, + component: AppSettings +}); + +function AppSettings() { + const initData = Route.useLoaderData(); + const connected = useStatusStore((state) => state.connected); + const [systemPickerOpen, setSystemPickerOpen] = useState(false); + const nfcAvailable = usePreferencesStore((state) => state.nfcAvailable); + const accelerometerAvailable = usePreferencesStore((state) => state.accelerometerAvailable); + + // Get app settings from store (useShallow prevents infinite re-renders) + const { + restartScan, + launchOnScan, + launcherAccess, + preferRemoteWriter, + setRestartScan, + setLaunchOnScan, + setPreferRemoteWriter + } = usePreferencesStore(useShallow(selectAppSettings)); + + // Get shake settings from store (useShallow prevents infinite re-renders) + const { + shakeEnabled, + shakeMode, + shakeZapscript, + setShakeEnabled, + setShakeMode, + setShakeZapscript + } = usePreferencesStore(useShallow(selectShakeSettings)); + + // Extract system name from zapscript for display + const getSystemFromZapscript = () => { + if ( + shakeMode === "random" && + shakeZapscript.startsWith("**launch.random:") + ) { + return shakeZapscript.replace("**launch.random:", ""); + } + return ""; + }; + + const shakeSystem = getSystemFromZapscript(); + + const { PurchaseModal, setProPurchaseModalOpen } = useProPurchase( + initData.launcherAccess + ); + + const { t } = useTranslation(); + + const navigate = useNavigate(); + const swipeHandlers = useSmartSwipe({ + onSwipeRight: () => navigate({ to: "/settings" }), + preventScrollOnSwipe: false + }); + + return ( + navigate({ to: "/settings" })} + className="cursor-pointer" + > + + + } + headerCenter={ +

{t("settings.app.title")}

+ } + > +
+ + + {Capacitor.isNativePlatform() && nfcAvailable && ( + + )} + + {Capacitor.isNativePlatform() && accelerometerAvailable && ( + + {t("settings.app.shakeToLaunch")} + {!launcherAccess && ( + + {" "} + ({t("settings.app.proFeature")}) + + )} + + } + value={shakeEnabled} + setValue={setShakeEnabled} + disabled={!launcherAccess || !connected} + onDisabledClick={() => { + if (!launcherAccess) { + setProPurchaseModalOpen(true); + } + }} + /> + )} + + {Capacitor.isNativePlatform() && accelerometerAvailable && shakeEnabled && launcherAccess && ( + <> +
+
+ + + +
+
+ + {shakeMode === "random" && ( +
+
+ {shakeSystem ? ( + + {shakeSystem === "all" + ? t("systemSelector.allSystems") + : shakeSystem} + + ) : ( + - + )} +
+
+ )} + + {shakeMode === "custom" && ( +
+ +
+ )} + + )} +
+ + setSystemPickerOpen(false)} + onSelect={(systems) => { + // If "all" is selected (systems array is empty or contains "all") + const selectedSystem = systems.length === 0 ? "all" : systems[0]; + setShakeZapscript(`**launch.random:${selectedSystem}`); + }} + selectedSystems={shakeSystem ? [shakeSystem] : []} + mode="single" + title={t("settings.app.shakeSelectSystem")} + includeAllOption={true} + /> + + +
+ ); +} diff --git a/src/routes/settings.core.tsx b/src/routes/settings.core.tsx new file mode 100644 index 00000000..2b6b2452 --- /dev/null +++ b/src/routes/settings.core.tsx @@ -0,0 +1,462 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { useState, useEffect, useRef } from "react"; +import classNames from "classnames"; +import { CoreAPI } from "../lib/coreApi.ts"; +import { ToggleSwitch } from "../components/wui/ToggleSwitch"; +import { useSmartSwipe } from "../hooks/useSmartSwipe"; +import { useStatusStore } from "../lib/store"; +import { PageFrame } from "../components/PageFrame"; +import { UpdateSettingsRequest } from "../lib/models.ts"; +import { BackIcon, CheckIcon } from "../lib/images"; +import { TextInput } from "../components/wui/TextInput"; +import { formatDuration, formatDurationDisplay, parseDuration } from "../lib/utils"; + +export const Route = createFileRoute("/settings/core")({ + component: CoreSettings +}); + +function CoreSettings() { + const connected = useStatusStore((state) => state.connected); + + const { data, refetch } = useQuery({ + queryKey: ["settings"], + queryFn: () => CoreAPI.settings() + }); + + const update = useMutation({ + mutationFn: (params: UpdateSettingsRequest) => + CoreAPI.settingsUpdate(params), + onSuccess: () => refetch() + }); + + const { t } = useTranslation(); + + const navigate = useNavigate(); + const swipeHandlers = useSmartSwipe({ + onSwipeRight: () => navigate({ to: "/settings" }), + preventScrollOnSwipe: false + }); + + return ( + navigate({ to: "/settings" })} className="cursor-pointer"> + + + } + headerCenter={ +

{t("settings.core.title")}

+ } + > +
+ update.mutate({ audioScanFeedback: v })} + disabled={!connected} + /> +
+ +
+ update.mutate({ readersAutoDetect: v })} + disabled={!connected} + /> +
+ +
+ update.mutate({ debugLogging: v })} + disabled={!connected} + /> +
+ +
+ {t("settings.modeLabel")} +
+ + +
+ {data?.readersScanMode === "hold" && connected && ( +

{t("settings.insertHelp")}

+ )} +
+ + {/*
*/} + {/* */} + {/* update.mutate({ readersScanIgnoreSystems: v.split(",") })*/} + {/* }*/} + {/* disabled={!connected}*/} + {/* />*/} + {/*
*/} + + {/* Playtime Limits Section */} + +
+ ); +} + +function PlaytimeLimitsSection({ connected }: { connected: boolean }) { + const { t } = useTranslation(); + + // Local state for form inputs + const [dailyHours, setDailyHours] = useState("0"); + const [dailyMinutes, setDailyMinutes] = useState("0"); + const [sessionHours, setSessionHours] = useState("0"); + const [sessionMinutes, setSessionMinutes] = useState("0"); + const [resetMinutes, setResetMinutes] = useState("0"); + + // Debounce timers + const dailyTimeoutRef = useRef | undefined>(undefined); + const sessionTimeoutRef = useRef | undefined>(undefined); + const resetTimeoutRef = useRef | undefined>(undefined); + + // Fetch playtime limits configuration + const { data: limitsConfig, refetch: refetchLimits } = useQuery({ + queryKey: ["playtime", "limits"], + queryFn: () => CoreAPI.playtimeLimits(), + enabled: connected, + refetchInterval: false + }); + + // Fetch playtime status (for display) + const { data: playtimeStatus } = useQuery({ + queryKey: ["playtime", "status"], + queryFn: () => CoreAPI.playtime(), + enabled: connected && limitsConfig?.enabled === true, + refetchInterval: limitsConfig?.enabled ? 30000 : false // Refresh every 30s when enabled + }); + + // Update mutation + const updateMutation = useMutation({ + mutationFn: CoreAPI.playtimeLimitsUpdate.bind(CoreAPI), + onSuccess: () => { + refetchLimits(); + } + }); + + // Initialize form values when data loads + useEffect(() => { + if (limitsConfig) { + const daily = parseDuration(limitsConfig.daily); + setDailyHours(String(daily.hours)); + setDailyMinutes(String(daily.minutes)); + + const session = parseDuration(limitsConfig.session); + setSessionHours(String(session.hours)); + setSessionMinutes(String(session.minutes)); + + const reset = parseDuration(limitsConfig.sessionReset); + setResetMinutes(String(reset.hours * 60 + reset.minutes)); + } + }, [limitsConfig]); + + // Auto-update daily limit when values change + useEffect(() => { + if (!limitsConfig) return; + + if (dailyTimeoutRef.current) { + clearTimeout(dailyTimeoutRef.current); + } + + dailyTimeoutRef.current = setTimeout(() => { + const daily = formatDuration({ + hours: parseInt(dailyHours) || 0, + minutes: parseInt(dailyMinutes) || 0 + }); + updateMutation.mutate({ daily }); + }, 500); + + return () => { + if (dailyTimeoutRef.current) { + clearTimeout(dailyTimeoutRef.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dailyHours, dailyMinutes]); + + // Auto-update session limit when values change + useEffect(() => { + if (!limitsConfig) return; + + if (sessionTimeoutRef.current) { + clearTimeout(sessionTimeoutRef.current); + } + + sessionTimeoutRef.current = setTimeout(() => { + const session = formatDuration({ + hours: parseInt(sessionHours) || 0, + minutes: parseInt(sessionMinutes) || 0 + }); + updateMutation.mutate({ session }); + }, 500); + + return () => { + if (sessionTimeoutRef.current) { + clearTimeout(sessionTimeoutRef.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionHours, sessionMinutes]); + + // Auto-update session reset when value changes + useEffect(() => { + if (!limitsConfig) return; + + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + + resetTimeoutRef.current = setTimeout(() => { + const resetMins = parseInt(resetMinutes) || 0; + const sessionReset = formatDuration({ + hours: Math.floor(resetMins / 60), + minutes: resetMins % 60 + }); + updateMutation.mutate({ sessionReset }); + }, 500); + + return () => { + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resetMinutes]); + + if (!connected) { + return null; + } + + const handleEnabledToggle = (enabled: boolean) => { + updateMutation.mutate({ enabled }); + }; + + const getStateBadgeColor = (state: string) => { + switch (state) { + case "active": + return "bg-green-500/20 text-green-400"; + case "cooldown": + return "bg-yellow-500/20 text-yellow-400"; + case "reset": + default: + return "bg-gray-500/20 text-gray-400"; + } + }; + + const getStateLabel = (state: string) => { + switch (state) { + case "active": + return t("settings.core.playtime.stateActive"); + case "cooldown": + return t("settings.core.playtime.stateCooldown"); + case "reset": + default: + return t("settings.core.playtime.stateReset"); + } + }; + + return ( +
+

{t("settings.core.playtime.title")}

+ + + + {limitsConfig?.enabled && ( + <> + {/* Status Display */} + {playtimeStatus && ( +
+ {/* Session Status */} +
+
+ {t("settings.core.playtime.currentSession")} + + {getStateLabel(playtimeStatus.state)} + +
+
+ {t("settings.core.playtime.sessionDuration")} + {formatDurationDisplay(playtimeStatus.sessionDuration)} +
+
+ {t("settings.core.playtime.sessionRemaining")} + {formatDurationDisplay(playtimeStatus.sessionRemaining)} +
+ {playtimeStatus.cooldownRemaining && playtimeStatus.state === "cooldown" && ( +
+ {t("settings.core.playtime.cooldownRemaining")} + {formatDurationDisplay(playtimeStatus.cooldownRemaining)} +
+ )} +
+ + {/* Daily Status */} +
+ {t("settings.core.playtime.dailyUsage")} +
+ {t("settings.core.playtime.dailyUsageToday")} + {formatDurationDisplay(playtimeStatus.dailyUsageToday)} +
+
+ {t("settings.core.playtime.dailyRemaining")} + {formatDurationDisplay(playtimeStatus.dailyRemaining)} +
+
+
+ )} + + {/* Configuration Inputs */} +
+ {/* Daily Limit */} +
+ +
+ + +
+
+ + {/* Session Limit */} +
+ +
+ + +
+
+ + {/* Session Reset Timeout */} +
+ + + + {t("settings.core.playtime.neverReset")} + +
+
+ + )} +
+ ); +} diff --git a/src/routes/settings.index.tsx b/src/routes/settings.index.tsx index 28f63410..cf7e688b 100644 --- a/src/routes/settings.index.tsx +++ b/src/routes/settings.index.tsx @@ -12,11 +12,10 @@ import { } from "@/components/ProPurchase.tsx"; import { SlideModal } from "@/components/SlideModal.tsx"; import { Button as SCNButton } from "@/components/ui/button"; -import { ScanSettings } from "@/components/home/ScanSettings.tsx"; -import { useAppSettings } from "@/hooks/useAppSettings.ts"; import i18n from "../i18n"; import { PageFrame } from "../components/PageFrame"; import { useStatusStore } from "../lib/store"; +import { usePreferencesStore } from "../lib/preferencesStore"; import { TextInput } from "../components/wui/TextInput"; import { Button } from "../components/wui/Button"; import { ExternalIcon, NextIcon } from "../lib/images"; @@ -24,27 +23,14 @@ import { getDeviceAddress, setDeviceAddress, CoreAPI } from "../lib/coreApi.ts"; import { MediaDatabaseCard } from "../components/MediaDatabaseCard"; interface LoaderData { - restartScan: boolean; - launchOnScan: boolean; launcherAccess: boolean; - preferRemoteWriter: boolean; } export const Route = createFileRoute("/settings/")({ - loader: async (): Promise => { - const [restartResult, launchResult, accessResult, remoteWriterResult] = - await Promise.all([ - Preferences.get({ key: "restartScan" }), - Preferences.get({ key: "launchOnScan" }), - Preferences.get({ key: "launcherAccess" }), - Preferences.get({ key: "preferRemoteWriter" }), - ]); - + loader: (): LoaderData => { + const state = usePreferencesStore.getState(); return { - restartScan: restartResult.value === "true", - launchOnScan: launchResult.value !== "false", - launcherAccess: accessResult.value === "true", - preferRemoteWriter: remoteWriterResult.value === "true", + launcherAccess: state.launcherAccess }; }, component: Settings @@ -53,10 +39,10 @@ export const Route = createFileRoute("/settings/")({ function Settings() { const initData = Route.useLoaderData(); - const { PurchaseModal, setProPurchaseModalOpen, proAccess } = - useProPurchase(initData.launcherAccess); + const { PurchaseModal, setProPurchaseModalOpen, proAccess } = useProPurchase( + initData.launcherAccess + ); - const connected = useStatusStore((state) => state.connected); const connectionError = useStatusStore((state) => state.connectionError); // const loggedInUser = useStatusStore((state) => state.loggedInUser); const deviceHistory = useStatusStore((state) => state.deviceHistory); @@ -64,7 +50,9 @@ function Settings() { const removeDeviceHistory = useStatusStore( (state) => state.removeDeviceHistory ); - const resetConnectionState = useStatusStore((state) => state.resetConnectionState); + const resetConnectionState = useStatusStore( + (state) => state.resetConnectionState + ); const version = useQuery({ queryKey: ["version"], @@ -86,23 +74,11 @@ function Settings() { }); }, [setDeviceHistory]); - const { restartScan, setRestartScan, launchOnScan, setLaunchOnScan } = - useAppSettings({ initData }); - const handleDeviceAddressChange = (newAddress: string) => { - // Set the new device address setDeviceAddress(newAddress); - - // Reset the connection state resetConnectionState(); - - // Reset CoreAPI state CoreAPI.reset(); - - // Clear React Query cache for all queries that depend on the device queryClient.invalidateQueries(); - - // Update local address state (this will trigger CoreApiWebSocket remount via key prop) setAddress(newAddress); }; @@ -110,23 +86,30 @@ function Settings() { <>
- +
+ { + if (e.key === "Enter" && address !== getDeviceAddress()) { + handleDeviceAddressChange(address); + } + }} + /> +
-
+
{version.isSuccess && ( - <> +
Platform: {version.data.platform}
Version: {version.data.version}
- +
)} {connectionError !== "" && ( -
{connectionError}
+
{connectionError}
)}
@@ -147,7 +130,10 @@ function Settings() { {deviceHistory .sort((a, b) => (a.address > b.address ? 1 : -1)) .map((entry) => ( -
+
{ @@ -173,14 +159,6 @@ function Settings() { )} - -
@@ -254,9 +232,18 @@ function Settings() {
- + {Capacitor.isNativePlatform() && ( + +
+

{t("settings.app.title")}

+ +
+ + )} + +
-

{t("settings.advanced.title")}

+

{t("settings.core.title")}

diff --git a/src/routes/settings.logs.tsx b/src/routes/settings.logs.tsx index 47fd28ca..a8296192 100644 --- a/src/routes/settings.logs.tsx +++ b/src/routes/settings.logs.tsx @@ -10,6 +10,8 @@ import { useStatusStore } from "../lib/store"; import { PageFrame } from "../components/PageFrame"; import { TextInput } from "../components/wui/TextInput"; import { BackIcon } from "../lib/images"; +import { HeaderButton } from "../components/wui/HeaderButton"; +import { ToggleChip } from "../components/wui/ToggleChip"; interface LogEntry { level: string; @@ -35,6 +37,8 @@ function Logs() { warn: true, error: true }); + const [expandedEntries, setExpandedEntries] = useState>(new Set()); + const [expandedFields, setExpandedFields] = useState>(new Set()); const swipeHandlers = useSmartSwipe({ onSwipeRight: () => navigate({ to: "/settings" }), @@ -163,11 +167,31 @@ function Logs() { } }; - const toggleLevelFilter = (level: keyof typeof levelFilters) => { - setLevelFilters(prev => ({ - ...prev, - [level]: !prev[level] - })); + const MESSAGE_TRUNCATE_LENGTH = 200; + + const toggleExpandEntry = (index: number) => { + setExpandedEntries(prev => { + const newSet = new Set(prev); + if (newSet.has(index)) { + newSet.delete(index); + } else { + newSet.add(index); + } + return newSet; + }); + }; + + const toggleExpandField = (entryIndex: number, fieldKey: string) => { + const fieldId = `${entryIndex}_${fieldKey}`; + setExpandedFields(prev => { + const newSet = new Set(prev); + if (newSet.has(fieldId)) { + newSet.delete(fieldId); + } else { + newSet.add(fieldId); + } + return newSet; + }); }; return ( @@ -175,9 +199,10 @@ function Logs() { navigate({ to: "/settings" })} className="cursor-pointer"> - - + navigate({ to: "/settings" })} + icon={} + /> } headerCenter={

{t("settings.logs.title")}

@@ -186,30 +211,24 @@ function Logs() {
{logsQuery.data && ( <> - - + /> )} - + />
} scrollRef={scrollContainerRef} @@ -228,20 +247,31 @@ function Logs() { {/* Filters and Entry Count */}
-
- {Object.entries(levelFilters).map(([level, enabled]) => ( - - ))} +
+ setLevelFilters(prev => ({ ...prev, debug: state }))} + compact + /> + setLevelFilters(prev => ({ ...prev, info: state }))} + compact + /> + setLevelFilters(prev => ({ ...prev, warn: state }))} + compact + /> + setLevelFilters(prev => ({ ...prev, error: state }))} + compact + />
{logsQuery.data && logEntries.length > 0 && (
@@ -288,16 +318,58 @@ function Logs() {
- {entry.message} + {entry.message && entry.message.length > MESSAGE_TRUNCATE_LENGTH ? ( + <> + {expandedEntries.has(entry._index) + ? entry.message + : `${entry.message.slice(0, MESSAGE_TRUNCATE_LENGTH)}...`} + + + ) : ( + entry.message + )}
{/* Additional fields */} {Object.entries(entry).filter(([key]) => !['level', 'time', 'caller', 'message', '_index'].includes(key) - ).map(([key, value]) => ( -
- {key}: {JSON.stringify(value)} -
- ))} + ).map(([key, value]) => { + const fieldId = `${entry._index}_${key}`; + const valueStr = JSON.stringify(value); + const isExpanded = expandedFields.has(fieldId); + const needsTruncation = valueStr.length > MESSAGE_TRUNCATE_LENGTH; + + return ( +
+ {key}:{" "} + {needsTruncation ? ( + <> + {isExpanded + ? valueStr + : `${valueStr.slice(0, MESSAGE_TRUNCATE_LENGTH)}...`} + + + ) : ( + valueStr + )} +
+ ); + })}
))}
diff --git a/src/test-utils/index.tsx b/src/test-utils/index.tsx index 137969c5..b8ac4eac 100644 --- a/src/test-utils/index.tsx +++ b/src/test-utils/index.tsx @@ -1,6 +1,7 @@ import React from "react"; import { render, RenderOptions } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { SlideModalProvider } from "../components/SlideModalProvider"; // Create a test query client const createTestQueryClient = () => @@ -26,7 +27,9 @@ function customRender( function Wrapper({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ); } diff --git a/src/translations/de-DE.json b/src/translations/de-DE.json index aeaaa456..be46cedb 100644 --- a/src/translations/de-DE.json +++ b/src/translations/de-DE.json @@ -163,14 +163,15 @@ "wizzodev": "Wizzo.dev Patrons", "joinPatreon": "Werde Patreon" }, - "advanced": { + "core": { "title": "Erweiterte Einstellungen", "soundEffects": "Soundeffekte beim Scannen abspielen", "autoDetect": "Externes NFC-Lesegerät automatisch erkennen", "debug": "Debug-Modus", "nfcDriver": "NFC-Treiber-Verbindungszeichenfolge", - "insertModeBlocklist": "Insert-Modus-Core-Blockliste", - "downloadLog": "Zaparoo-Protokolldatei herunterladen", + "insertModeBlocklist": "Insert-Modus-Core-Blockliste" + }, + "app": { "restorePurchases": "Käufe wiederherstellen", "restoreSuccess": "Käufe wurden wiederhergestellt", "restoreFail": "Käufe konnten nicht wiederhergestellt werden" diff --git a/src/translations/en-US.json b/src/translations/en-US.json index 8566cb93..fee91c24 100644 --- a/src/translations/en-US.json +++ b/src/translations/en-US.json @@ -5,6 +5,7 @@ "backToTop": "Back to top", "error": "Error: {{msg}}", "loading": "Loading...", + "cancelling": "Cancelling...", "yes": "Yes", "stopPlaying": "This will stop the playing media. You may lose any unsaved progress.", "nav": { @@ -24,7 +25,7 @@ "holdTag": "Hold tag to phone", "holdTagReader": "Hold tag to reader", "scanning": "Scanning", - "pressToScan": "Press to zap", + "pressToScan": "Press to scan", "writeSuccess": "Tag written successfully", "writeFailed": "Failed to write tag", "readSuccess": "Tag read successfully", @@ -42,7 +43,7 @@ "writingDb": "Writing database to disk", "hideLabel": "Hide", "updatedDb": "Games database updated", - "filesFound": "{{count}} media found", + "filesFound": "{{count}} items scanned", "nowPlayingHeading": "Now playing" }, "write": { @@ -50,10 +51,14 @@ "nfcNotSupported": "NFC is not supported on this device" }, "scan": { - "cameraMode": "QR/Barcode", + "cameraMode": "Camera", "nfcMode": "NFC", "scanError": "Error scanning tag", "noDevices": "No Zaparoo Devices Found", + "connecting": "Connecting...", + "reconnecting": "Reconnecting...", + "connectionError": "Connection Error", + "retry": "Retry", "continuous": "Continuous scanning", "launchOnScan": "Launch on scan", "connectedHeading": "Connected", @@ -90,17 +95,36 @@ "gamesDbUpdate": "Media database must be updated before searching", "searchError": "Error searching for media", "noGamesFound": "No media found", - "gamesFound": "{{count}} found", - "gamesFoundMax": "{{count}} found (showing first {{max}})", "title": "Search for Media", "gameInput": "Name", "gameInputPlaceholder": "Enter media name", "systemInput": "System", "allSystems": "All systems", - "systemLabel": "System:", - "pathLabel": "Path:", + "tagsInput": "Tags", + "allTags": "Any tags", + "systemLabel": "System", + "categoryLabel": "Category", + "pathLabel": "Path", + "zapscriptLabel": "ZapScript", + "tagsLabel": "Tags", "writeLabel": "Write to tag", - "playLabel": "Preview" + "playLabel": "Preview", + "selectSystem": "Select System", + "selectTags": "Select Tags", + "loading": "Loading...", + "startSearching": "Ready to search", + "startSearchingHint": "Enter a game name or select filters to start searching", + "noResultsFound": "No results found for \"{{query}}\"", + "noResultsFoundSimple": "No results found", + "tryRemovingFiltersOnly": "Try removing filters to see more results", + "tryDifferentSearch": "Try removing filters or searching with different terms", + "tryDifferentTerms": "Try different search terms or check your spelling", + "clearFilters": "Clear filters", + "tryAgain": "Try again", + "recentSearches": "Recent Searches", + "noRecentSearches": "No recent searches yet", + "noRecentSearchesHint": "Start searching to build your search history", + "clearHistory": "Clear History" }, "custom": { "title": "Custom ZapScript", @@ -130,6 +154,41 @@ "tools": "Tools", "history": "History" }, + "readTab": { + "scanTag": "Scan tag", + "tagInformation": "Tag Information", + "uid": "UID", + "content": "Content", + "technicalDetails": "Technical Details", + "writable": "Writable", + "yes": "Yes", + "no": "No", + "maxSize": "Max Size", + "bytes": "bytes", + "canMakeReadOnly": "Can Make Read-Only", + "manufacturerCode": "Manufacturer Code", + "technologyTypes": "Technology Types", + "ndefRecords": "NDEF Records ({{count}})", + "record": "Record {{index}}", + "tnf": "TNF:", + "type": "Type:", + "payload": "Payload:", + "showHex": "Show Hex", + "hideHex": "Hide Hex", + "lowLevelTagData": "Low-Level Tag Data", + "tagId": "Tag ID (UID)", + "atqa": "ATQA/SENS_RES (NFC-A)", + "sak": "SAK/SEL_RES (NFC-A)", + "applicationData": "Application Data (NFC-B)", + "historicalBytes": "Historical Bytes (ISO-DEP)", + "manufacturer": "Manufacturer (NFC-F)", + "systemCode": "System Code (NFC-F)", + "copiedToClipboard": "{{label}} copied to clipboard", + "copyFailed": "Failed to copy to clipboard", + "tagData": "Tag data", + "shareTitle": "NFC Tag Data", + "shareText": "UID: {{uid}}\nText: {{text}}" + }, "write": { "templates": { "text": "Text", @@ -191,9 +250,14 @@ "insertMode": "Hold", "insertHelp": "Exits the media when tag is removed. Requires a separate NFC reader.", "updateDb": "Update media database", + "updateDb.cancel": "Cancel", "updateDb.status.ready": "Database is ready", + "updateDb.status.optimizing": "Optimizing database (ready to use)", + "updateDb.status.mediaCount": "Database ready: {{formattedCount}} media titles", "updateDb.status.noConnection": "Not connected", "updateDb.status.checking": "Checking database status...", + "updateDb.selectSystemsTitle": "Select Systems to Update", + "updateDb.allSystems": "All systems", "designer": "Zaparoo Designer", "getApp": "Get the Zaparoo App", "language": "Language", @@ -207,18 +271,48 @@ "wizzodev": "Wizzo.dev Patrons", "joinPatreon": "Join the Patreon" }, - "advanced": { - "title": "Advanced Settings", + "core": { + "title": "Core Settings", "soundEffects": "Play sounds effects on scan", "autoDetect": "Auto-detect external NFC reader", "debug": "Debug mode", "nfcDriver": "NFC driver connection string", "insertModeBlocklist": "Insert mode core blocklist", - "downloadLog": "Download Zaparoo log file", + "playtime": { + "title": "Playtime Limits", + "enabled": "Enable playtime limits", + "dailyLimit": "Daily limit", + "sessionLimit": "Session limit", + "sessionReset": "Session reset timeout", + "hours": "Hours", + "minutes": "Minutes", + "neverReset": "Never reset (0 = keeps session active until daily limit)", + "currentSession": "Current Session", + "dailyUsage": "Daily Usage", + "stateReset": "Reset", + "stateActive": "Active", + "stateCooldown": "Cooldown", + "sessionDuration": "Duration", + "sessionRemaining": "Remaining", + "dailyUsageToday": "Used today", + "dailyRemaining": "Remaining", + "cooldownRemaining": "Cooldown ends in", + "warningToast": "{{remaining}} remaining until {{type}} limit", + "reachedToast": "{{type}} limit reached" + } + }, + "app": { + "title": "App Settings", + "preferRemoteWriter": "Prefer connected reader for writing", "restorePurchases": "Restore purchases", "restoreSuccess": "Purchases have been restored", "restoreFail": "Failed to restore purchases", - "preferRemoteWriter": "Prefer connected reader for writing" + "shakeToLaunch": "Shake to launch", + "proFeature": "Pro", + "shakeRandomMedia": "Random Media", + "shakeCustom": "Custom", + "shakeSelectSystem": "Select system", + "shakeZapscript": "Zapscript to launch" }, "logs": { "title": "Logs", @@ -228,13 +322,15 @@ "copy": "Copy to Clipboard", "filename": "Filename", "fileSize": "File Size", - "searchPlaceholder": "Filter logs", + "searchPlaceholder": "Filter logs...", "levelFilters": "Log Levels", "totalEntries": "Total entries", "filteredEntries": "Showing", "noEntriesFound": "No log entries match your filters", "fetchError": "Failed to fetch logs", - "notConnected": "Connect to a Zaparoo device to access logs" + "notConnected": "Connect to a Zaparoo device to access logs", + "showMore": "Show more", + "showLess": "Show less" }, "help": { "title": "Help", @@ -251,6 +347,104 @@ "reportIssue": "Report an issue on GitHub", "emailLabel": "Support email:" } + }, + "systemSelector": { + "title": "Select Systems", + "searchPlaceholder": "Search systems...", + "allSystems": "All systems", + "allCategories": "All", + "noResults": "No systems found", + "noSystems": "No systems available", + "selectedCount": "{{count}} systems selected", + "multipleSelected": "{{count}} systems selected", + "clearAll": "Clear all", + "apply": "Apply" + }, + "tagSelector": { + "title": "Select Tags", + "searchPlaceholder": "Filter tags...", + "allTypes": "All types", + "noResults": "No tags found", + "noTags": "No tags available", + "selectedCount": "{{count}} tags selected", + "multipleSelected": "{{count}} tags selected", + "clearAll": "Clear all", + "apply": "Apply", + "type": { + "accessibility": "Accessibility", + "addon": "Add-on", + "alt": "Alternate", + "arcadeboard": "Arcade Board", + "art": "Art Style", + "based": "Based On", + "category": "Category", + "compatibility": "Compatibility", + "copyright": "Copyright", + "developer": "Developer", + "disc": "Disc", + "disctotal": "Total Discs", + "distribution": "Distribution", + "dump": "Dump Status", + "edition": "Edition", + "embedded": "Embedded", + "extension": "Extension", + "gamegenre": "Game Genre", + "genre": "Genre", + "input": "Input", + "lang": "Language", + "mameparent": "MAME Parent", + "media": "Media", + "multigame": "Multi-game", + "perspective": "Perspective", + "platform": "Platform", + "players": "Players", + "port": "Port", + "publisher": "Publisher", + "reboxed": "Reboxed", + "region": "Region", + "rerelease": "Re-release", + "rev": "Revision", + "save": "Save", + "search": "Search", + "series": "Series", + "set": "Set", + "supplement": "Supplement", + "unlicensed": "Unlicensed", + "unfinished": "Unfinished", + "unknown": "Unknown", + "video": "Video", + "year": "Year" + } + }, + "tour": { + "stepIndicator": "{{current}}/{{total}}", + "welcome": { + "title": "Welcome to Zaparoo!", + "text": "Let's get you set up. This tour will show you how to connect to your device, scan your library, and create your first token." + }, + "deviceAddress": { + "title": "Connect Your Device", + "text": "Enter your Zaparoo Core device IP address here. You can find this on your device's display or in your router settings." + }, + "mediaDatabase": { + "title": "Update Media Library", + "text": "After connecting, use this button to scan and index your media library. We use this for searching and launching." + }, + "createCards": { + "title": "Search for Media", + "text": "Once that's done, use the search to find media from your library. When you find something, tap it to write it to a token." + }, + "complete": { + "title": "You're All Set!", + "text": "That's it! Once you've made your first token, launch it from this screen or using a reader connected to your device." + }, + "buttons": { + "skip": "Skip Tour", + "getStarted": "Get Started", + "back": "Back", + "next": "Next", + "finish": "Finish" + } } } } diff --git a/src/translations/fr-FR.json b/src/translations/fr-FR.json index 1feef34c..c8ce1df6 100644 --- a/src/translations/fr-FR.json +++ b/src/translations/fr-FR.json @@ -98,14 +98,15 @@ "contributors": "Contributeurs Zaparoo", "wizzodev": "Wizzo.dev Patrons" }, - "advanced": { + "core": { "title": "Réglages avancées", "soundEffects": "Jouer un son lors du scan", "autoDetect": "Détection auto. du lecteur externe NFC", "debug": "Mode débug", "nfcDriver": "Code de connection pour le driver NFC", - "insertModeBlocklist": "Insert mode core blocklist", - "downloadLog": "Télécharger les logs de Zaparoo", + "insertModeBlocklist": "Insert mode core blocklist" + }, + "app": { "restorePurchases": "Restaurer les achats", "restoreSuccess": "Achats restaurés", "restoreFail": "Echec lors de la restauration des achats" diff --git a/src/translations/ja-JP.json b/src/translations/ja-JP.json index dc8e7472..9db8049c 100644 --- a/src/translations/ja-JP.json +++ b/src/translations/ja-JP.json @@ -129,14 +129,15 @@ "wizzodev": "Wizzo.dev Patrons", "joinPatreon": "Patreonに参加する" }, - "advanced": { + "core": { "title": "詳細設定", "soundEffects": "スキャン時にサウンドエフェクトを再生する", "autoDetect": "外部NFCリーダーを自動検出する", "debug": "デバッグモード", "nfcDriver": "NFCドライバー接続文字列", - "insertModeBlocklist": "挿入モードのコアブロックリスト", - "downloadLog": "Zaparooログファイルをダウンロードする", + "insertModeBlocklist": "挿入モードのコアブロックリスト" + }, + "app": { "restorePurchases": "購入を復元する", "restoreSuccess": "購入が復元されました", "restoreFail": "購入の復元に失敗しました" diff --git a/src/translations/ko-KR.json b/src/translations/ko-KR.json index 356d9063..bb8fc358 100644 --- a/src/translations/ko-KR.json +++ b/src/translations/ko-KR.json @@ -98,14 +98,15 @@ "contributors": "탭토에 기여한", "wizzodev": "wizzo.dev Patrons" }, - "advanced": { + "core": { "title": "고급 설정", "soundEffects": "스캔시 소리 효과", "autoDetect": "외부 NFC 리더 자동 감지", "debug": "개발자모드", "nfcDriver": "NFC 드라이버 연결 문자열", - "insertModeBlocklist": "인서트 모드 코어 차단 목록", - "downloadLog": "탭토 로그 파일 다운로드", + "insertModeBlocklist": "인서트 모드 코어 차단 목록" + }, + "app": { "restorePurchases": "구매 복구", "restoreSuccess": "구매를 복구했습니다.", "restoreFail": "구매에 실패했습니다." diff --git a/src/translations/nl-NL.json b/src/translations/nl-NL.json index becb02da..2b5d25cd 100644 --- a/src/translations/nl-NL.json +++ b/src/translations/nl-NL.json @@ -115,14 +115,15 @@ "contributors": "Zaparoo-bijdragers", "wizzodev": "Wizzo.dev Patrons" }, - "advanced": { + "core": { "title": "Geavanceerde instellingen", "soundEffects": "Geluidseffecten afspelen bij scannen", "autoDetect": "Automatisch externe NFC-lezer detecteren", "debug": "Debug-modus", "nfcDriver": "NFC-driver verbindingsreeks", - "insertModeBlocklist": "Insert-modus core-blocklist", - "downloadLog": "Download Zaparoo-logbestand", + "insertModeBlocklist": "Insert-modus core-blocklist" + }, + "app": { "restorePurchases": "Aankopen herstellen", "restoreSuccess": "Aankopen zijn hersteld", "restoreFail": "Aankopen herstellen mislukt" diff --git a/src/translations/zh-CN.json b/src/translations/zh-CN.json index d7a0f3e6..90e4780e 100644 --- a/src/translations/zh-CN.json +++ b/src/translations/zh-CN.json @@ -97,14 +97,15 @@ "translationsBy": "翻译于", "contributors": "Zaparoo开发者" }, - "advanced": { + "core": { "title": "高级设置", "soundEffects": "扫描音效", "autoDetect": "自动选择外部读卡器", "debug": "除错模式", "nfcDriver": "NFC设备连接模式", - "insertModeBlocklist": "插入模式核心列表", - "downloadLog": "下载Zaparoo log文件", + "insertModeBlocklist": "插入模式核心列表" + }, + "app": { "restorePurchases": "恢复购买权限", "restoreSuccess": "购买权限已被恢复", "restoreFail": "购买权限恢复失败" diff --git a/vite.config.ts b/vite.config.ts index d70c396f..a0052be0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,7 @@ import { defineConfig, ServerOptions } from "vite"; import react from "@vitejs/plugin-react"; import { TanStackRouterVite } from "@tanstack/router-vite-plugin"; +import { visualizer } from "rollup-plugin-visualizer"; import path from "path"; import dotenv from "dotenv"; @@ -20,6 +21,19 @@ export default defineConfig(({ command, mode }) => { }; } + const plugins = [react(), TanStackRouterVite()]; + + if (mode === "analyze") { + plugins.push( + visualizer({ + open: true, + filename: "dist/stats.html", + gzipSize: true, + brotliSize: true + }) + ); + } + return { base: "./", server, @@ -28,19 +42,45 @@ export default defineConfig(({ command, mode }) => { "@": path.resolve(__dirname, "./src") } }, - plugins: [react(), TanStackRouterVite()], + plugins, build: { - sourcemap: true, + sourcemap: false, + minify: 'terser', + terserOptions: { + compress: { + drop_console: true, + drop_debugger: true, + pure_funcs: ['console.log', 'console.info', 'console.debug'] + } + }, rollupOptions: { output: { - manualChunks: { - vendor: ['react', 'react-dom'], - router: ['@tanstack/react-router'], - ui: ['react-i18next', 'react-hot-toast', 'classnames'], - capacitor: ['@capacitor/core', '@capacitor/preferences', '@capacitor-community/keep-awake'] + manualChunks: (id) => { + if (id.includes('node_modules')) { + if (id.includes('react') || id.includes('react-dom')) { + return 'vendor'; + } + if (id.includes('@tanstack/react-router')) { + return 'router'; + } + if (id.includes('firebase')) { + return 'firebase'; + } + if (id.includes('i18next')) { + return 'i18n'; + } + if (id.includes('lucide')) { + return 'icons'; + } + if (id.includes('@capacitor')) { + return 'capacitor'; + } + return 'vendor-other'; + } } } - } + }, + chunkSizeWarningLimit: 500 } }; });