From 01e0c15d1e98cedb8d144e0e30a1e5968ffedde8 Mon Sep 17 00:00:00 2001 From: Fabian Schliski Date: Mon, 27 Apr 2026 21:46:25 +0200 Subject: [PATCH] Addon remote management web UI --- .github/workflows/main.yaml | 3 + .gitignore | 5 +- app.config.ts | 9 +- eslint.config.js | 2 +- package.json | 5 + plugins/withRemoteUIBuild.ts | 56 + pnpm-lock.yaml | 1587 +++++++++++++++-- pnpm-workspace.yaml | 3 + remote-ui/index.html | 13 + remote-ui/package.json | 31 + remote-ui/src/App.tsx | 107 ++ remote-ui/src/api.ts | 84 + remote-ui/src/components/AddonsView.tsx | 229 +++ remote-ui/src/components/BottomNav.tsx | 30 + .../src/components/DisconnectionBanner.tsx | 12 + remote-ui/src/components/DodoLogo.tsx | 21 + remote-ui/src/components/Header.tsx | 12 + remote-ui/src/components/LoadingSpinner.tsx | 10 + remote-ui/src/components/PinDialog.tsx | 63 + remote-ui/src/components/ProfileAddonRow.tsx | 175 ++ remote-ui/src/components/ProfilesView.tsx | 200 +++ .../src/components/SessionEndedScreen.tsx | 17 + remote-ui/src/index.css | 9 + remote-ui/src/main.tsx | 22 + remote-ui/src/queries.ts | 220 +++ remote-ui/tsconfig.json | 20 + remote-ui/vite.config.ts | 19 + scripts/embed-web-ui.mjs | 15 + .../local-server/__tests__/handlers.test.ts | 606 +++++++ src/api/local-server/__tests__/router.test.ts | 248 +++ src/api/local-server/handlers.ts | 162 ++ src/api/local-server/router.ts | 124 ++ src/api/local-server/server.ts | 24 + src/api/stremio/__tests__/hooks.test.ts | 3 +- src/api/stremio/hooks.ts | 10 +- src/components/basic/AppLogo.tsx | 8 +- .../settings/AddonsSettingsContent.tsx | 92 +- .../settings/RemoteControlContent.tsx | 189 ++ .../settings/RemoteControlModal.tsx | 22 + src/constants/logo-path.ts | 3 + src/i18n/de/settings.json | 27 + src/i18n/en/settings.json | 29 +- .../__tests__/addon.store.migration.test.ts | 33 +- .../__tests__/local-server.store.test.ts | 179 ++ src/store/addon.store.ts | 576 +++--- src/store/local-server.store.ts | 66 + src/types/addon-config.ts | 10 + tsconfig.json | 3 +- 48 files changed, 4883 insertions(+), 510 deletions(-) create mode 100644 plugins/withRemoteUIBuild.ts create mode 100644 remote-ui/index.html create mode 100644 remote-ui/package.json create mode 100644 remote-ui/src/App.tsx create mode 100644 remote-ui/src/api.ts create mode 100644 remote-ui/src/components/AddonsView.tsx create mode 100644 remote-ui/src/components/BottomNav.tsx create mode 100644 remote-ui/src/components/DisconnectionBanner.tsx create mode 100644 remote-ui/src/components/DodoLogo.tsx create mode 100644 remote-ui/src/components/Header.tsx create mode 100644 remote-ui/src/components/LoadingSpinner.tsx create mode 100644 remote-ui/src/components/PinDialog.tsx create mode 100644 remote-ui/src/components/ProfileAddonRow.tsx create mode 100644 remote-ui/src/components/ProfilesView.tsx create mode 100644 remote-ui/src/components/SessionEndedScreen.tsx create mode 100644 remote-ui/src/index.css create mode 100644 remote-ui/src/main.tsx create mode 100644 remote-ui/src/queries.ts create mode 100644 remote-ui/tsconfig.json create mode 100644 remote-ui/vite.config.ts create mode 100644 scripts/embed-web-ui.mjs create mode 100644 src/api/local-server/__tests__/handlers.test.ts create mode 100644 src/api/local-server/__tests__/router.test.ts create mode 100644 src/api/local-server/handlers.ts create mode 100644 src/api/local-server/router.ts create mode 100644 src/api/local-server/server.ts create mode 100644 src/components/settings/RemoteControlContent.tsx create mode 100644 src/components/settings/RemoteControlModal.tsx create mode 100644 src/constants/logo-path.ts create mode 100644 src/store/__tests__/local-server.store.test.ts create mode 100644 src/store/local-server.store.ts create mode 100644 src/types/addon-config.ts diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 8548466..ca721b4 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -82,6 +82,9 @@ jobs: CI: true EXPO_NO_TELEMETRY: 1 + - name: đŸ§Ș Run expo prebuild + run: pnpm exec expo prebuild + - name: đŸ§Ș Run TypeScript check run: pnpm tsc diff --git a/.gitignore b/.gitignore index f630cb2..0f24c5e 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,7 @@ expo-env.d.ts .metro-health-check* .env.* -!.env.example \ No newline at end of file +!.env.example + +# Generated web UI embed +src/api/local-server/web-ui.ts diff --git a/app.config.ts b/app.config.ts index acb060d..cea09b2 100644 --- a/app.config.ts +++ b/app.config.ts @@ -1,6 +1,7 @@ +// Register ts-node so Expo can evaluate this ESM config file at prebuild time. import 'tsx/cjs'; -import { ExpoConfig, ConfigContext } from 'expo/config'; -const packageJson = require('./package.json'); +import type { ExpoConfig, ConfigContext } from 'expo/config'; +import packageJson from './package.json' with { type: 'json' }; export default ({ config }: ConfigContext): ExpoConfig => { const appBackgroundColor = '#181A20'; @@ -85,6 +86,7 @@ export default ({ config }: ConfigContext): ExpoConfig => { project: process.env.SENTRY_PROJECT ?? 'dodostream', }, ], + './plugins/withRemoteUIBuild', './plugins/withReactNativeTVOSPnpmFix', './plugins/withAndroidBuildOptimizations', './plugins/withAndroidConfigChanges', @@ -118,6 +120,9 @@ export default ({ config }: ConfigContext): ExpoConfig => { permissions: [ 'android.permission.FOREGROUND_SERVICE', 'android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK', + 'android.permission.INTERNET', + 'android.permission.ACCESS_NETWORK_STATE', + 'android.permission.ACCESS_WIFI_STATE' ], }, extra: { diff --git a/eslint.config.js b/eslint.config.js index 6921096..c08957e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,7 +7,7 @@ module.exports = defineConfig([ expoConfig, reactCompiler.configs.recommended, { - ignores: ['dist/*', '.expo/**'], + ignores: ['dist/*', '.expo/**', 'remote-ui/**'], }, { rules: { diff --git a/package.json b/package.json index f0a8192..071df09 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "build:production_apk:local": "eas build --profile production_apk --local", "build:production_apk:tv": "eas build --profile production_tv_apk", "build:production_apk:tv:local": "eas build --profile production_tv_apk --local", + "build:ui": "pnpm --filter remote-ui build && node scripts/embed-web-ui.mjs", "prebuild": "expo prebuild", "lint": "eslint \"**/*.{js,jsx,ts,tsx}\"", "format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write", @@ -56,6 +57,7 @@ "expo-linear-gradient": "~15.0.8", "expo-linking": "~8.0.12", "expo-localization": "~17.0.8", + "expo-network": "~8.0.8", "expo-router": "~6.0.23", "expo-splash-screen": "~31.0.13", "expo-sqlite": "~16.0.10", @@ -71,6 +73,8 @@ "react-native": "npm:react-native-tvos@0.81-stable", "react-native-gesture-handler": "~2.28.0", "react-native-immersive-mode": "^2.0.2", + "react-native-nitro-http-server": "^1.8.1", + "react-native-qrcode-svg": "^6.3.21", "react-native-reanimated": "~4.1.6", "react-native-safe-area-context": "~5.6.2", "react-native-screens": "~4.16.0", @@ -101,6 +105,7 @@ "jest-expo": "^54.0.17", "prettier": "^3.2.5", "react-test-renderer": "19.1.0", + "ts-node": "^10.9.2", "tsx": "^4.21.0", "typescript": "~5.9.2" }, diff --git a/plugins/withRemoteUIBuild.ts b/plugins/withRemoteUIBuild.ts new file mode 100644 index 0000000..cb64642 --- /dev/null +++ b/plugins/withRemoteUIBuild.ts @@ -0,0 +1,56 @@ +import { ConfigPlugin, withDangerousMod } from 'expo/config-plugins'; +import { execSync } from 'child_process'; +import path from 'path'; +import fs from 'fs'; + +/** + * Expo config plugin that builds the remote-ui SPA and embeds it + * into src/api/local-server/web-ui.ts before native project generation. + * + * Registered on both platforms so it runs regardless of which platform + * is being prebuilt. A module-level flag ensures the build only executes once + * when both platforms are built together. + * + * Runs: pnpm --filter remote-ui build && node scripts/embed-web-ui.mjs + */ + +let built = false; + +function buildRemoteUI(projectRoot: string): void { + if (built) return; + built = true; + + const webUiPath = path.join(projectRoot, 'src/api/local-server/web-ui.ts'); + + console.log('🌐 Building remote-ui SPA...'); + try { + execSync('pnpm --filter remote-ui build', { cwd: projectRoot, stdio: 'inherit' }); + execSync('node scripts/embed-web-ui.mjs', { cwd: projectRoot, stdio: 'inherit' }); + console.log('✓ Remote UI embedded successfully'); + } catch (error) { + console.error('⚠ Failed to build remote-ui:', error); + // Don't fail the prebuild — write a stub if the file is missing + if (!fs.existsSync(webUiPath)) { + fs.writeFileSync( + webUiPath, + "// AUTO-GENERATED — do not edit. Run pnpm build:ui to regenerate.\nexport const WEB_UI_HTML = '';\n", + 'utf-8' + ); + } + } +} + +const withRemoteUIBuild: ConfigPlugin = (config) => { + for (const platform of ['ios', 'android'] as const) { + config = withDangerousMod(config, [ + platform, + (modConfig) => { + buildRemoteUI(modConfig.modRequest.projectRoot); + return modConfig; + }, + ]); + } + return config; +}; + +export default withRemoteUIBuild; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73d030a..f356a39 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,9 +100,12 @@ importers: expo-localization: specifier: ~17.0.8 version: 17.0.8(expo@54.0.34)(react@19.1.0) + expo-network: + specifier: ~8.0.8 + version: 8.0.8(expo@54.0.34)(react@19.1.0) expo-router: specifier: ~6.0.23 - version: 6.0.23(9762b74b39ac1ba59cbc6550e182d2ca) + version: 6.0.23(d3775fd364ea69d962a61ae45d7a67fd) expo-splash-screen: specifier: ~31.0.13 version: 31.0.13(expo@54.0.34) @@ -123,7 +126,7 @@ importers: version: 15.0.11(expo@54.0.34)(react-native-tvos@0.81.5-1) i18next: specifier: ^26.0.6 - version: 26.0.6(typescript@5.9.3) + version: 26.0.8(typescript@5.9.3) moti: specifier: ^0.30.0 version: 0.30.0(react-dom@19.1.0(react@19.1.0))(react-native-reanimated@4.1.6(@babel/core@7.28.5)(react-native-tvos@0.81.5-1)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native-tvos@0.81.5-1)(react@19.1.0))(react@19.1.0))(react@19.1.0) @@ -135,7 +138,7 @@ importers: version: 19.1.0(react@19.1.0) react-i18next: specifier: ^17.0.4 - version: 17.0.4(i18next@26.0.6(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0)(typescript@5.9.3) + version: 17.0.6(i18next@26.0.8(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0)(typescript@5.9.3) react-native: specifier: npm:react-native-tvos@0.81-stable version: react-native-tvos@0.81.5-1(patch_hash=e91fec82df6b5870c3625e79ba3c16e25c5cecceb339f6f21cd184eca5d363df)(@babel/core@7.28.5)(@types/react@19.1.17)(react-native-tvos@0.81.5-1)(react@19.1.0) @@ -145,6 +148,12 @@ importers: react-native-immersive-mode: specifier: ^2.0.2 version: 2.0.2(react-native-tvos@0.81.5-1) + react-native-nitro-http-server: + specifier: ^1.8.1 + version: 1.8.1(react-native-nitro-modules@0.35.5(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0) + react-native-qrcode-svg: + specifier: ^6.3.21 + version: 6.3.21(react-native-svg@15.12.1(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0) react-native-reanimated: specifier: ~4.1.6 version: 4.1.6(@babel/core@7.28.5)(react-native-tvos@0.81.5-1)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native-tvos@0.81.5-1)(react@19.1.0))(react@19.1.0) @@ -181,7 +190,7 @@ importers: version: 3.0.3 '@testing-library/react-native': specifier: ^13.3.3 - version: 13.3.3(jest@29.7.0(@types/node@25.0.9))(react-native-tvos@0.81.5-1)(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) + version: 13.3.3(jest@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)))(react-native-tvos@0.81.5-1)(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) '@types/jest': specifier: ^29.5.14 version: 29.5.14 @@ -199,37 +208,40 @@ importers: version: 6.9.4 babel-preset-expo: specifier: ~54.0.10 - version: 54.0.10(@babel/core@7.28.5)(@babel/runtime@7.29.2)(expo@54.0.34)(react-refresh@0.14.2) + version: 54.0.10(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@54.0.34)(react-refresh@0.17.0) drizzle-kit: specifier: ^0.31.9 version: 0.31.9 eslint: specifier: ^9.25.1 - version: 9.39.1(jiti@1.21.7) + version: 9.39.1(jiti@2.6.1) eslint-config-expo: specifier: ~10.0.0 - version: 10.0.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + version: 10.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint-config-prettier: specifier: ^10.1.2 - version: 10.1.8(eslint@9.39.1(jiti@1.21.7)) + version: 10.1.8(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react-compiler: specifier: 19.1.0-rc.2 - version: 19.1.0-rc.2(eslint@9.39.1(jiti@1.21.7)) + version: 19.1.0-rc.2(eslint@9.39.1(jiti@2.6.1)) expo-sqlite-mock: specifier: ^3.0.2 - version: 3.0.2(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.34)(react-native-tvos@0.81.5-1)(react@19.1.0))(expo@54.0.34)(jest@29.7.0(@types/node@25.0.9)) + version: 3.0.2(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.34)(react-native-tvos@0.81.5-1)(react@19.1.0))(expo@54.0.34)(jest@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3))) jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.0.9) + version: 29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)) jest-expo: specifier: ^54.0.17 - version: 54.0.17(@babel/core@7.28.5)(expo@54.0.34)(jest@29.7.0(@types/node@25.0.9))(react-native-tvos@0.81.5-1)(react@19.1.0) + version: 54.0.17(@babel/core@7.28.5)(expo@54.0.34)(jest@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)))(react-native-tvos@0.81.5-1)(react@19.1.0) prettier: specifier: ^3.2.5 version: 3.7.4 react-test-renderer: specifier: 19.1.0 version: 19.1.0(react@19.1.0) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@25.0.9)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -237,6 +249,58 @@ importers: specifier: ~5.9.2 version: 5.9.3 + remote-ui: + dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.1.0) + '@headlessui/react': + specifier: ^2.2.0 + version: 2.2.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/react-query': + specifier: ^5.90.12 + version: 5.90.12(react@19.1.0) + lucide-react: + specifier: ^0.474.0 + version: 0.474.0(react@19.1.0) + react: + specifier: ^19.0.0 + version: 19.1.0 + react-dom: + specifier: ^19.0.0 + version: 19.1.0(react@19.1.0) + tailwindcss: + specifier: ^4.0.0 + version: 4.2.4 + devDependencies: + '@tailwindcss/vite': + specifier: ^4.0.0 + version: 4.2.4(vite@6.4.2(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@types/react': + specifier: ^19.0.0 + version: 19.1.17 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.1.17) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.2(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vite: + specifier: ^6.1.0 + version: 6.4.2(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-plugin-singlefile: + specifier: ^2.0.2 + version: 2.3.3(rollup@4.60.2)(vite@6.4.2(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + packages: '@0no-co/graphql.web@1.2.0': @@ -789,12 +853,38 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + '@d11/react-native-fast-image@8.13.0': resolution: {integrity: sha512-zfsBtYNttiZVV/NwEnN/PzgW3PGlGYqn0/6DUOQ/tCv1lO0gO7+S0GiANmNDl35oVmh8o0DK81lF8xAhYz/aNA==} peerDependencies: react: '*' react-native: '*' + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -1429,6 +1519,34 @@ packages: resolution: {integrity: sha512-ReZxZ8pdnoI3tP/dNnJdnmAk7uLT4FjsKDGW7YeDdvdOMz2XCQSmSCM9IWlrXuWtMF9zeSB6WJtEhCQ41gQOfw==} hasBin: true + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.26.28': + resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@headlessui/react@2.2.10': + resolution: {integrity: sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==} + engines: {node: '>=10'} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1445,6 +1563,15 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@internationalized/date@3.12.1': + resolution: {integrity: sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==} + + '@internationalized/number@3.6.6': + resolution: {integrity: sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==} + + '@internationalized/string@3.2.8': + resolution: {integrity: sha512-NdbMQUSfXLYIQol5VyMtinm9pZDciiMfN7RtmSuSB78io1hqwJ0naYfxyW6vgxWBkzWymQa/3uLDlbfmshtCaA==} + '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -1570,6 +1697,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@legendapp/list@3.0.0-beta.44': resolution: {integrity: sha512-loGRve78NuZ5k8Z54ZSDNOtv3dVBM1SeBCRtm1EYtZiDIZ8SyMVcYpUGgFpGuNKk71+9/NuM9hvScrgf7+4E+A==} peerDependencies: @@ -1835,6 +1965,18 @@ packages: '@types/react': optional: true + '@react-aria/focus@3.22.0': + resolution: {integrity: sha512-ZfDOVuVhqDsM9mkNji3QUZ/d40JhlVgXrDkrfXylM1035QCrcTHN7m2DpbE95sU2A8EQb4wikvt5jM6K/73BPg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@react-aria/interactions@3.28.0': + resolution: {integrity: sha512-OXwdU1EWFdMxmr/K1CXNGJzmNlCClByb+PuCaqUyzBymHPCGVhawirLIon/CrIN5psh3AiWpHSh4H0WeJdVpng==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + '@react-native-async-storage/async-storage@2.2.0': resolution: {integrity: sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==} peerDependencies: @@ -1987,6 +2129,139 @@ packages: '@react-navigation/routers@7.5.2': resolution: {integrity: sha512-kymreY5aeTz843E+iPAukrsOtc7nabAH6novtAPREmmGu77dQpfxPB2ZWpKb5nRErIRowp1kYRoN2Ckl+S6JYw==} + '@react-types/shared@3.34.0': + resolution: {integrity: sha512-gp6xo/s2lX54AlTjOiqwDnxA7UW79BNvI9dB9pr3LZTzRKCd1ZA+ZbgKw/ReIiWuvvVw/8QFJpnqeeFyLocMcQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.60.2': + resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.2': + resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.2': + resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.2': + resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.2': + resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.2': + resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.2': + resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.2': + resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.2': + resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.2': + resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.2': + resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.2': + resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.2': + resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.2': + resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.2': + resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.2': + resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.2': + resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.2': + resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.2': + resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.2': + resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} + cpu: [x64] + os: [win32] + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -2161,6 +2436,99 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@swc/helpers@0.5.21': + resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} + + '@tailwindcss/node@4.2.4': + resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==} + + '@tailwindcss/oxide-android-arm64@4.2.4': + resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.4': + resolution: {integrity: sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.4': + resolution: {integrity: sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.4': + resolution: {integrity: sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': + resolution: {integrity: sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': + resolution: {integrity: sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.4': + resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.4': + resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.2.4': + resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.2.4': + resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': + resolution: {integrity: sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': + resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.4': + resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.2.4': + resolution: {integrity: sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/query-core@5.90.12': resolution: {integrity: sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==} @@ -2169,6 +2537,15 @@ packages: peerDependencies: react: ^18 || ^19 + '@tanstack/react-virtual@3.13.24': + resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==} + 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/virtual-core@3.14.0': + resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==} + '@testing-library/react-native@13.3.3': resolution: {integrity: sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg==} engines: {node: '>=18'} @@ -2185,6 +2562,18 @@ packages: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} + '@tsconfig/node10@1.0.12': + resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -2233,6 +2622,14 @@ packages: '@types/node@25.0.9': resolution: {integrity: sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==} + '@types/parse-json@4.0.2': + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + '@types/react@19.1.17': resolution: {integrity: sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==} @@ -2416,6 +2813,12 @@ packages: peerDependencies: '@urql/core': ^5.0.0 + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} @@ -2587,6 +2990,10 @@ packages: resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + babel-plugin-polyfill-corejs2@0.4.14: resolution: {integrity: sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==} peerDependencies: @@ -2801,6 +3208,9 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -2809,6 +3219,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -2876,6 +3290,10 @@ packages: core-js-compat@3.47.0: resolution: {integrity: sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==} + cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + cosmiconfig@9.0.0: resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} engines: {node: '>=14'} @@ -2890,6 +3308,9 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cross-fetch@3.2.0: resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} @@ -2969,6 +3390,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -3041,6 +3466,13 @@ packages: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + diff@4.0.4: + resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} + engines: {node: '>=0.3.1'} + + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -3195,6 +3627,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.21.0: + resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} + engines: {node: '>=10.13.0'} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -3430,6 +3866,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + exec-async@2.2.0: resolution: {integrity: sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==} @@ -3576,6 +4015,12 @@ packages: react: '*' react-native: '*' + expo-network@8.0.8: + resolution: {integrity: sha512-dgrL8UHAmWofqeY4UEjWskCl/RoQAM0DG6PZR8xz2WZt+6aQEboQgFRXowCfhbKZ71d16sNuKXtwBEsp2DtdNw==} + peerDependencies: + expo: '*' + react: '*' + expo-router@6.0.23: resolution: {integrity: sha512-qCxVAiCrCyu0npky6azEZ6dJDMt77OmCzEbpF6RbUTlfkaCA417LvY14SBkk0xyGruSxy/7pvJOI6tuThaUVCA==} peerDependencies: @@ -3993,8 +4438,8 @@ packages: hyphenate-style-name@1.1.0: resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} - i18next@26.0.6: - resolution: {integrity: sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg==} + i18next@26.0.8: + resolution: {integrity: sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw==} peerDependencies: typescript: ^5 || ^6 peerDependenciesMeta: @@ -4398,8 +4843,8 @@ packages: jimp-compact@0.16.1: resolution: {integrity: sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==} - jiti@1.21.7: - resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true js-tokens@4.0.0: @@ -4480,74 +4925,74 @@ packages: lighthouse-logger@1.4.2: resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} - lightningcss-android-arm64@1.30.2: - resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.30.2: - resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.30.2: - resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.30.2: - resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.30.2: - resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.30.2: - resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-arm64-musl@1.30.2: - resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-x64-gnu@1.30.2: - resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-linux-x64-musl@1.30.2: - resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-win32-arm64-msvc@1.30.2: - resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.30.2: - resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.30.2: - resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} lines-and-columns@1.2.4: @@ -4595,10 +5040,21 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@0.474.0: + resolution: {integrity: sha512-CmghgHkh0OJNmxGKWc0qfPJCYHASPMVSyGY8fj3xgk4v84ItqDg64JNKFZn5hC6E0vHi6gxnbCgwhyVB09wQtA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} @@ -4988,6 +5444,10 @@ packages: resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} engines: {node: 20 || >=22} + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -5015,6 +5475,10 @@ packages: resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} engines: {node: '>=4.0.0'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + popmotion@11.0.3: resolution: {integrity: sha512-Y55FLdj3UxkR7Vl3s7Qr4e9m0onSnP8W7d/xQLsoJM40vs6UKHFdygs6SWryasTZYqugMjm3BepCF4CWXDiHgA==} @@ -5029,6 +5493,10 @@ packages: resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.12: + resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} + engines: {node: ^10 || ^12 || >=14} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -5097,6 +5565,11 @@ packages: resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==} hasBin: true + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + query-string@7.1.3: resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} engines: {node: '>=6'} @@ -5115,6 +5588,12 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-aria@3.48.0: + resolution: {integrity: sha512-jQjd4rBEIMqecBaAKYJbVGK6EqIHLa5znVQ7jwFyK5vCyljoj6KhgtiahmcIPsG5vG5vEDLw+ba+bEWn6A2P4w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-devtools-core@6.1.5: resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==} @@ -5132,8 +5611,8 @@ packages: peerDependencies: react: '>=17.0.0' - react-i18next@17.0.4: - resolution: {integrity: sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==} + react-i18next@17.0.6: + resolution: {integrity: sha512-WzJ6SMKF+GTD7JZZqxSR1AKKmXjaSu39sClUrNlwxS4Tl7a99O+ltFy6yhPMO+wgZuxpQjJ2PZkfrQKmAqrLhw==} peerDependencies: i18next: '>= 26.0.1' react: '>= 16.8.0' @@ -5185,6 +5664,31 @@ packages: react: '*' react-native: '*' + react-native-nitro-buffer@0.2.2: + resolution: {integrity: sha512-mE8jD03ol2u7mAa4qcD9mRy1G6jcweI5B0JQbqcCl8U+FvTdbMW9s7tNlg1AWZkxdJLm4rBX9zl8yTlXdSNMbA==} + peerDependencies: + react: '*' + react-native: '*' + react-native-nitro-modules: ^0.35.0 + + react-native-nitro-http-server@1.8.1: + resolution: {integrity: sha512-IFkQsnUD9jdW434yb6X6H6qM4vJnapPs/gC590yk082sJj0YljXixBCnoiqhbt5D6DgV0dlZ3YVPi6Vjw58Nfg==} + peerDependencies: + react-native-nitro-modules: ^0.35.0 + + react-native-nitro-modules@0.35.5: + resolution: {integrity: sha512-aa03UzC5dLg5qFfyBkVK+JGSwHTjmK7jUZzyRz11r1Yk9C/nJTFe59EeHPxxNNTagkiwQTM6p3sySgD/TDRC7Q==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-qrcode-svg@6.3.21: + resolution: {integrity: sha512-6vcj4rcdpWedvphDR+NSJcudJykNuLgNGFwm2p4xYjR8RdyTzlrELKI5LkO4ANS9cQUbqsfkpippPv64Q2tUtA==} + peerDependencies: + react: '*' + react-native: '>=0.63.4' + react-native-svg: '>=14.0.0' + react-native-reanimated@4.1.6: resolution: {integrity: sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==} peerDependencies: @@ -5246,6 +5750,10 @@ packages: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -5266,6 +5774,11 @@ packages: '@types/react': optional: true + react-stately@3.46.0: + resolution: {integrity: sha512-OdxhWvHgs2L4OJGIs7hnuTr5WjjMM6enhNEAMRqiekhF8+ITvA2LRwNftOZwcogaoCslGYq5S2VQTQwnm0GbCA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -5330,6 +5843,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + requireg@0.2.2: resolution: {integrity: sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg==} engines: {node: '>= 4.0.0'} @@ -5384,6 +5900,11 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rollup@4.60.2: + resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + rtl-detect@1.1.2: resolution: {integrity: sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==} @@ -5453,6 +5974,9 @@ packages: server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -5715,6 +6239,16 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + tailwindcss@4.2.4: + resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + tar-fs@2.1.4: resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} @@ -5744,6 +6278,10 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + text-encoding@0.7.0: + resolution: {integrity: sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==} + deprecated: no longer maintained + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -5789,6 +6327,20 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} @@ -5947,6 +6499,9 @@ packages: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} hasBin: true + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + v8-to-istanbul@9.3.0: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} @@ -5965,16 +6520,66 @@ packages: react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc - vlq@1.0.1: - resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} - - void-elements@3.1.0: - resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} - engines: {node: '>=0.10.0'} - - w3c-xmlserializer@4.0.0: - resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} - engines: {node: '>=14'} + vite-plugin-singlefile@2.3.3: + resolution: {integrity: sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==} + engines: {node: '>18.0.0'} + peerDependencies: + rollup: ^4.59.0 + vite: ^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + rollup: + optional: true + + vite@6.4.2: + resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vlq@1.0.1: + resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} + + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + + w3c-xmlserializer@4.0.0: + resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} + engines: {node: '>=14'} walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} @@ -6031,6 +6636,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.19: resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} engines: {node: '>= 0.4'} @@ -6047,6 +6655,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -6116,6 +6728,9 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6127,19 +6742,35 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + yaml@2.8.2: resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -6842,11 +7473,40 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + '@d11/react-native-fast-image@8.13.0(react-native-tvos@0.81.5-1)(react@19.1.0)': dependencies: react: 19.1.0 react-native: react-native-tvos@0.81.5-1(patch_hash=e91fec82df6b5870c3625e79ba3c16e25c5cecceb339f6f21cd184eca5d363df)(@babel/core@7.28.5)(@types/react@19.1.17)(react-native-tvos@0.81.5-1)(react@19.1.0) + '@dnd-kit/accessibility@3.1.1(react@19.1.0)': + dependencies: + react: 19.1.0 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.1.0) + '@dnd-kit/utilities': 3.2.2(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@dnd-kit/utilities': 3.2.2(react@19.1.0) + react: 19.1.0 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.1.0)': + dependencies: + react: 19.1.0 + tslib: 2.8.1 + '@drizzle-team/brocli@0.10.2': {} '@egjs/hammerjs@2.0.17': @@ -7109,9 +7769,9 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true - '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@1.21.7))': + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@2.6.1))': dependencies: - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -7226,7 +7886,7 @@ snapshots: wrap-ansi: 7.0.0 ws: 8.18.3 optionalDependencies: - expo-router: 6.0.23(9762b74b39ac1ba59cbc6550e182d2ca) + expo-router: 6.0.23(d3775fd364ea69d962a61ae45d7a67fd) react-native: react-native-tvos@0.81.5-1(patch_hash=e91fec82df6b5870c3625e79ba3c16e25c5cecceb339f6f21cd184eca5d363df)(@babel/core@7.28.5)(@types/react@19.1.17)(react-native-tvos@0.81.5-1)(react@19.1.0) transitivePeerDependencies: - bufferutil @@ -7354,7 +8014,7 @@ snapshots: glob: 13.0.0 hermes-parser: 0.29.1 jsc-safe-url: 0.2.4 - lightningcss: 1.30.2 + lightningcss: 1.32.0 picomatch: 4.0.3 postcss: 8.4.49 resolve-from: 5.0.0 @@ -7459,6 +8119,41 @@ snapshots: find-up: 5.0.0 js-yaml: 4.1.1 + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@floating-ui/react@0.26.28(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@floating-ui/utils': 0.2.11 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + tabbable: 6.4.0 + + '@floating-ui/utils@0.2.11': {} + + '@headlessui/react@2.2.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/react': 0.26.28(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@react-aria/focus': 3.22.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@react-aria/interactions': 3.28.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/react-virtual': 3.13.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + use-sync-external-store: 1.6.0(react@19.1.0) + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -7470,6 +8165,18 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@internationalized/date@3.12.1': + dependencies: + '@swc/helpers': 0.5.21 + + '@internationalized/number@3.6.6': + dependencies: + '@swc/helpers': 0.5.21 + + '@internationalized/string@3.2.8': + dependencies: + '@swc/helpers': 0.5.21 + '@isaacs/balanced-match@4.0.1': {} '@isaacs/brace-expansion@5.0.0': @@ -7501,7 +8208,7 @@ snapshots: jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@29.7.0': + '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -7515,7 +8222,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@25.0.9) + jest-config: 29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -7690,6 +8397,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@legendapp/list@3.0.0-beta.44(patch_hash=ea90e6420e375cb0e56d1b7d7674e3703fd0779550fab949c43a9f571800674f)(react-dom@19.1.0(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0)': dependencies: react: 19.1.0 @@ -7744,16 +8456,17 @@ snapshots: '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-collection@1.1.7(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(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.17)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.17 + '@types/react-dom': 19.2.3(@types/react@19.1.17) '@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.17)(react@19.1.0)': dependencies: @@ -7767,18 +8480,18 @@ snapshots: optionalDependencies: '@types/react': 19.1.17 - '@radix-ui/react-dialog@1.1.15(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(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.17)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.7(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-id': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-portal': 1.1.9(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.1.0) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) aria-hidden: 1.2.6 @@ -7787,6 +8500,7 @@ snapshots: react-remove-scroll: 2.7.2(@types/react@19.1.17)(react@19.1.0) optionalDependencies: '@types/react': 19.1.17 + '@types/react-dom': 19.2.3(@types/react@19.1.17) '@radix-ui/react-direction@1.1.1(@types/react@19.1.17)(react@19.1.0)': dependencies: @@ -7794,17 +8508,18 @@ snapshots: optionalDependencies: '@types/react': 19.1.17 - '@radix-ui/react-dismissable-layer@1.1.11(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(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.17)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0) '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.17)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.17 + '@types/react-dom': 19.2.3(@types/react@19.1.17) '@radix-ui/react-focus-guards@1.1.3(@types/react@19.1.17)(react@19.1.0)': dependencies: @@ -7812,15 +8527,16 @@ snapshots: optionalDependencies: '@types/react': 19.1.17 - '@radix-ui/react-focus-scope@1.1.7(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(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.17)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.17 + '@types/react-dom': 19.2.3(@types/react@19.1.17) '@radix-ui/react-id@1.1.1(@types/react@19.1.17)(react@19.1.0)': dependencies: @@ -7829,16 +8545,17 @@ snapshots: optionalDependencies: '@types/react': 19.1.17 - '@radix-ui/react-portal@1.1.9(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.17 + '@types/react-dom': 19.2.3(@types/react@19.1.17) - '@radix-ui/react-presence@1.1.5(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(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.17)(react@19.1.0) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) @@ -7846,30 +8563,33 @@ snapshots: react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.17 + '@types/react-dom': 19.2.3(@types/react@19.1.17) - '@radix-ui/react-primitive@2.1.3(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.17 + '@types/react-dom': 19.2.3(@types/react@19.1.17) - '@radix-ui/react-roving-focus@1.1.11(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(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.17)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) '@radix-ui/react-direction': 1.1.1(@types/react@19.1.17)(react@19.1.0) '@radix-ui/react-id': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.17 + '@types/react-dom': 19.2.3(@types/react@19.1.17) '@radix-ui/react-slot@1.2.0(@types/react@19.1.17)(react@19.1.0)': dependencies: @@ -7885,20 +8605,21 @@ snapshots: optionalDependencies: '@types/react': 19.1.17 - '@radix-ui/react-tabs@1.1.13(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) '@radix-ui/react-direction': 1.1.1(@types/react@19.1.17)(react@19.1.0) '@radix-ui/react-id': 1.1.1(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-presence': 1.1.5(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-roving-focus': 1.1.11(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(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.17)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.17 + '@types/react-dom': 19.2.3(@types/react@19.1.17) '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.17)(react@19.1.0)': dependencies: @@ -7934,6 +8655,21 @@ snapshots: optionalDependencies: '@types/react': 19.1.17 + '@react-aria/focus@3.22.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@swc/helpers': 0.5.21 + react: 19.1.0 + react-aria: 3.48.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react-dom: 19.1.0(react@19.1.0) + + '@react-aria/interactions@3.28.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@react-types/shared': 3.34.0(react@19.1.0) + '@swc/helpers': 0.5.21 + react: 19.1.0 + react-aria: 3.48.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react-dom: 19.1.0(react@19.1.0) + '@react-native-async-storage/async-storage@2.2.0(react-native-tvos@0.81.5-1)': dependencies: merge-options: 3.0.4 @@ -8162,6 +8898,87 @@ snapshots: dependencies: nanoid: 3.3.11 + '@react-types/shared@3.34.0(react@19.1.0)': + dependencies: + react: 19.1.0 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.60.2': + optional: true + + '@rollup/rollup-android-arm64@4.60.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.2': + optional: true + + '@rollup/rollup-darwin-x64@4.60.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.2': + optional: true + '@rtsao/scc@1.1.0': {} '@sentry-internal/browser-utils@10.12.0': @@ -8322,6 +9139,78 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@swc/helpers@0.5.21': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.2.4': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.0 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.4 + + '@tailwindcss/oxide-android-arm64@4.2.4': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.4': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.4': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.4': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.4': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': + optional: true + + '@tailwindcss/oxide@4.2.4': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.4 + '@tailwindcss/oxide-darwin-arm64': 4.2.4 + '@tailwindcss/oxide-darwin-x64': 4.2.4 + '@tailwindcss/oxide-freebsd-x64': 4.2.4 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.4 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.4 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.4 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.4 + '@tailwindcss/oxide-linux-x64-musl': 4.2.4 + '@tailwindcss/oxide-wasm32-wasi': 4.2.4 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 + + '@tailwindcss/vite@4.2.4(vite@6.4.2(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@tailwindcss/node': 4.2.4 + '@tailwindcss/oxide': 4.2.4 + tailwindcss: 4.2.4 + vite: 6.4.2(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + '@tanstack/query-core@5.90.12': {} '@tanstack/react-query@5.90.12(react@19.1.0)': @@ -8329,7 +9218,15 @@ snapshots: '@tanstack/query-core': 5.90.12 react: 19.1.0 - '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.0.9))(react-native-tvos@0.81.5-1)(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': + '@tanstack/react-virtual@3.13.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/virtual-core': 3.14.0 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@tanstack/virtual-core@3.14.0': {} + + '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)))(react-native-tvos@0.81.5-1)(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: jest-matcher-utils: 30.2.0 picocolors: 1.1.1 @@ -8339,10 +9236,18 @@ snapshots: react-test-renderer: 19.1.0(react@19.1.0) redent: 3.0.0 optionalDependencies: - jest: 29.7.0(@types/node@25.0.9) + jest: 29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)) '@tootallnate/once@2.0.0': {} + '@tsconfig/node10@1.0.12': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -8350,8 +9255,8 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 @@ -8406,6 +9311,13 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/parse-json@4.0.2': + optional: true + + '@types/react-dom@19.2.3(@types/react@19.1.17)': + dependencies: + '@types/react': 19.1.17 + '@types/react@19.1.17': dependencies: csstype: 3.2.3 @@ -8422,15 +9334,15 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.49.0 - '@typescript-eslint/type-utils': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.49.0 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.1.0(typescript@5.9.3) @@ -8438,14 +9350,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.49.0 '@typescript-eslint/types': 8.49.0 '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.49.0 debug: 4.4.3 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -8468,13 +9380,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.49.0 '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) ts-api-utils: 2.1.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -8497,13 +9409,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/utils@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.49.0 '@typescript-eslint/types': 8.49.0 '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -8586,6 +9498,18 @@ snapshots: '@urql/core': 5.2.0 wonka: 6.3.5 + '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.2(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + '@xmldom/xmldom@0.8.11': {} abab@2.0.6: {} @@ -8792,6 +9716,13 @@ snapshots: '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 + babel-plugin-macros@3.1.0: + dependencies: + '@babel/runtime': 7.29.2 + cosmiconfig: 7.1.0 + resolve: 1.22.11 + optional: true + babel-plugin-polyfill-corejs2@0.4.14(@babel/core@7.28.5): dependencies: '@babel/compat-data': 7.28.5 @@ -8853,7 +9784,7 @@ snapshots: '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.5) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.5) - babel-preset-expo@54.0.10(@babel/core@7.28.5)(@babel/runtime@7.29.2)(expo@54.0.34)(react-refresh@0.14.2): + babel-preset-expo@54.0.10(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@54.0.34)(react-refresh@0.14.2): dependencies: '@babel/helper-module-imports': 7.27.1 '@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.28.5) @@ -8879,7 +9810,39 @@ snapshots: react-refresh: 0.14.2 resolve-from: 5.0.0 optionalDependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.28.4 + expo: 54.0.34(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-tvos@0.81.5-1)(react@19.1.0) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + babel-preset-expo@54.0.10(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@54.0.34)(react-refresh@0.17.0): + dependencies: + '@babel/helper-module-imports': 7.27.1 + '@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.28.5) + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-syntax-export-default-from': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-class-static-block': 7.28.3(@babel/core@7.28.5) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-object-rest-spread': 7.28.4(@babel/core@7.28.5) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.28.5) + '@babel/plugin-transform-private-methods': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-private-property-in-object': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-runtime': 7.28.5(@babel/core@7.28.5) + '@babel/preset-react': 7.28.5(@babel/core@7.28.5) + '@babel/preset-typescript': 7.28.5(@babel/core@7.28.5) + '@react-native/babel-preset': 0.81.5(@babel/core@7.28.5) + babel-plugin-react-compiler: 1.0.0 + babel-plugin-react-native-web: 0.21.2 + babel-plugin-syntax-hermes-parser: 0.29.1 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.28.5) + debug: 4.4.3 + react-refresh: 0.17.0 + resolve-from: 5.0.0 + optionalDependencies: + '@babel/runtime': 7.28.4 expo: 54.0.34(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-tvos@0.81.5-1)(react@19.1.0) transitivePeerDependencies: - '@babel/core' @@ -9055,6 +10018,12 @@ snapshots: client-only@0.0.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -9063,6 +10032,8 @@ snapshots: clone@1.0.4: {} + clsx@2.1.1: {} + co@4.6.0: {} collect-v8-coverage@1.0.3: {} @@ -9134,6 +10105,15 @@ snapshots: dependencies: browserslist: 4.28.1 + cosmiconfig@7.1.0: + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.1 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.3 + optional: true + cosmiconfig@9.0.0(typescript@5.9.3): dependencies: env-paths: 2.2.1 @@ -9143,13 +10123,13 @@ snapshots: optionalDependencies: typescript: 5.9.3 - create-jest@29.7.0(@types/node@25.0.9): + create-jest@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@25.0.9) + jest-config: 29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -9158,6 +10138,8 @@ snapshots: - supports-color - ts-node + create-require@1.1.1: {} + cross-fetch@3.2.0: dependencies: node-fetch: 2.7.0 @@ -9237,6 +10219,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decimal.js@10.6.0: {} decode-uri-component@0.2.2: {} @@ -9245,7 +10229,9 @@ snapshots: dependencies: mimic-response: 3.1.0 - dedent@1.7.1: {} + dedent@1.7.1(babel-plugin-macros@3.1.0): + optionalDependencies: + babel-plugin-macros: 3.1.0 deep-extend@0.6.0: {} @@ -9285,6 +10271,10 @@ snapshots: diff-sequences@29.6.3: {} + diff@4.0.4: {} + + dijkstrajs@1.0.3: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -9353,6 +10343,11 @@ snapshots: dependencies: once: 1.4.0 + enhanced-resolve@5.21.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + entities@4.5.0: {} entities@6.0.1: {} @@ -9578,16 +10573,16 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-expo@10.0.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3): + eslint-config-expo@10.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) - eslint: 9.39.1(jiti@1.21.7) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-expo: 1.0.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@1.21.7)) + '@typescript-eslint/eslint-plugin': 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.1(jiti@2.6.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-expo: 1.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@2.6.1)) globals: 16.5.0 transitivePeerDependencies: - eslint-import-resolver-webpack @@ -9595,9 +10590,9 @@ snapshots: - supports-color - typescript - eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@1.21.7)): + eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)): dependencies: - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node@0.3.9: dependencies: @@ -9607,42 +10602,42 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) get-tsconfig: 4.13.0 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) - eslint: 9.39.1(jiti@1.21.7) + '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-expo@1.0.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3): + eslint-plugin-expo@1.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/types': 8.49.0 - '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) - eslint: 9.39.1(jiti@1.21.7) + '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.1(jiti@2.6.1) transitivePeerDependencies: - supports-color - typescript - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -9651,9 +10646,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -9665,29 +10660,29 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-react-compiler@19.1.0-rc.2(eslint@9.39.1(jiti@1.21.7)): + eslint-plugin-react-compiler@19.1.0-rc.2(eslint@9.39.1(jiti@2.6.1)): dependencies: '@babel/core': 7.28.5 '@babel/parser': 7.28.6 '@babel/plugin-proposal-private-methods': 7.18.6(@babel/core@7.28.5) - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) hermes-parser: 0.25.1 zod: 3.25.76 zod-validation-error: 3.5.4(zod@3.25.76) transitivePeerDependencies: - supports-color - eslint-plugin-react-hooks@5.2.0(eslint@9.39.1(jiti@1.21.7)): + eslint-plugin-react-hooks@5.2.0(eslint@9.39.1(jiti@2.6.1)): dependencies: - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) - eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@1.21.7)): + eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@2.6.1)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -9695,7 +10690,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.2.1 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 @@ -9718,9 +10713,9 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.39.1(jiti@1.21.7): + eslint@9.39.1(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 @@ -9755,7 +10750,7 @@ snapshots: natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 1.21.7 + jiti: 2.6.1 transitivePeerDependencies: - supports-color @@ -9783,6 +10778,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter3@5.0.4: {} + exec-async@2.2.0: {} execa@5.1.1: @@ -9958,12 +10955,17 @@ snapshots: react: 19.1.0 react-native: react-native-tvos@0.81.5-1(patch_hash=e91fec82df6b5870c3625e79ba3c16e25c5cecceb339f6f21cd184eca5d363df)(@babel/core@7.28.5)(@types/react@19.1.17)(react-native-tvos@0.81.5-1)(react@19.1.0) - expo-router@6.0.23(9762b74b39ac1ba59cbc6550e182d2ca): + expo-network@8.0.8(expo@54.0.34)(react@19.1.0): + dependencies: + expo: 54.0.34(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-tvos@0.81.5-1)(react@19.1.0) + react: 19.1.0 + + expo-router@6.0.23(d3775fd364ea69d962a61ae45d7a67fd): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.34)(react-dom@19.1.0(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0) '@expo/schema-utils': 0.1.8 '@radix-ui/react-slot': 1.2.0(@types/react@19.1.17)(react@19.1.0) - '@radix-ui/react-tabs': 1.1.13(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@react-navigation/bottom-tabs': 7.8.12(@react-navigation/native@7.1.25(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-screens@4.16.0(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0) '@react-navigation/native': 7.1.25(react-native-tvos@0.81.5-1)(react@19.1.0) '@react-navigation/native-stack': 7.8.6(@react-navigation/native@7.1.25(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-screens@4.16.0(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0) @@ -9989,10 +10991,10 @@ snapshots: sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 use-latest-callback: 0.2.6(react@19.1.0) - vaul: 1.1.2(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) optionalDependencies: '@react-navigation/drawer': 7.7.9(@react-navigation/native@7.1.25(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-reanimated@4.1.6(@babel/core@7.28.5)(react-native-tvos@0.81.5-1)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native-tvos@0.81.5-1)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-screens@4.16.0(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0) - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.0.9))(react-native-tvos@0.81.5-1)(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)))(react-native-tvos@0.81.5-1)(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) react-dom: 19.1.0(react@19.1.0) react-native-gesture-handler: 2.28.0(react-native-tvos@0.81.5-1)(react@19.1.0) react-native-reanimated: 4.1.6(@babel/core@7.28.5)(react-native-tvos@0.81.5-1)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native-tvos@0.81.5-1)(react@19.1.0))(react@19.1.0) @@ -10014,12 +11016,12 @@ snapshots: transitivePeerDependencies: - supports-color - expo-sqlite-mock@3.0.2(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.34)(react-native-tvos@0.81.5-1)(react@19.1.0))(expo@54.0.34)(jest@29.7.0(@types/node@25.0.9)): + expo-sqlite-mock@3.0.2(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.34)(react-native-tvos@0.81.5-1)(react@19.1.0))(expo@54.0.34)(jest@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3))): dependencies: better-sqlite3: 12.6.2 expo: 54.0.34(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-tvos@0.81.5-1)(react@19.1.0) expo-sqlite: 16.0.10(expo@54.0.34)(react-native-tvos@0.81.5-1)(react@19.1.0) - jest: 29.7.0(@types/node@25.0.9) + jest: 29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)) expo-sqlite@16.0.10(expo@54.0.34)(react-native-tvos@0.81.5-1)(react@19.1.0): dependencies: @@ -10080,7 +11082,7 @@ snapshots: expo@54.0.34(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-tvos@0.81.5-1)(react@19.1.0): dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.28.4 '@expo/cli': 54.0.24(expo-router@6.0.23)(expo@54.0.34)(react-native-tvos@0.81.5-1) '@expo/config': 12.0.13 '@expo/config-plugins': 54.0.4 @@ -10090,7 +11092,7 @@ snapshots: '@expo/metro-config': 54.0.15(expo@54.0.34) '@expo/vector-icons': 15.0.3(expo-font@14.0.11(expo@54.0.34)(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0) '@ungap/structured-clone': 1.3.0 - babel-preset-expo: 54.0.10(@babel/core@7.28.5)(@babel/runtime@7.29.2)(expo@54.0.34)(react-refresh@0.14.2) + babel-preset-expo: 54.0.10(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@54.0.34)(react-refresh@0.14.2) expo-asset: 12.0.13(expo@54.0.34)(react-native-tvos@0.81.5-1)(react@19.1.0) expo-constants: 18.0.13(expo@54.0.34)(react-native-tvos@0.81.5-1) expo-file-system: 19.0.22(expo@54.0.34)(react-native-tvos@0.81.5-1) @@ -10427,9 +11429,7 @@ snapshots: hyphenate-style-name@1.1.0: {} - i18next@26.0.6(typescript@5.9.3): - dependencies: - '@babel/runtime': 7.29.2 + i18next@26.0.8(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 @@ -10676,7 +11676,7 @@ snapshots: jest-util: 29.7.0 p-limit: 3.1.0 - jest-circus@29.7.0: + jest-circus@29.7.0(babel-plugin-macros@3.1.0): dependencies: '@jest/environment': 29.7.0 '@jest/expect': 29.7.0 @@ -10685,7 +11685,7 @@ snapshots: '@types/node': 25.0.9 chalk: 4.1.2 co: 4.6.0 - dedent: 1.7.1 + dedent: 1.7.1(babel-plugin-macros@3.1.0) is-generator-fn: 2.1.0 jest-each: 29.7.0 jest-matcher-utils: 29.7.0 @@ -10702,16 +11702,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@25.0.9): + jest-cli@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)): dependencies: - '@jest/core': 29.7.0 + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@25.0.9) + create-jest: 29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@25.0.9) + jest-config: 29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -10721,7 +11721,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@25.0.9): + jest-config@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)): dependencies: '@babel/core': 7.28.5 '@jest/test-sequencer': 29.7.0 @@ -10732,7 +11732,7 @@ snapshots: deepmerge: 4.3.1 glob: 7.2.3 graceful-fs: 4.2.11 - jest-circus: 29.7.0 + jest-circus: 29.7.0(babel-plugin-macros@3.1.0) jest-environment-node: 29.7.0 jest-get-type: 29.6.3 jest-regex-util: 29.6.3 @@ -10747,6 +11747,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 25.0.9 + ts-node: 10.9.2(@types/node@25.0.9)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -10801,7 +11802,7 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 - jest-expo@54.0.17(@babel/core@7.28.5)(expo@54.0.34)(jest@29.7.0(@types/node@25.0.9))(react-native-tvos@0.81.5-1)(react@19.1.0): + jest-expo@54.0.17(@babel/core@7.28.5)(expo@54.0.34)(jest@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)))(react-native-tvos@0.81.5-1)(react@19.1.0): dependencies: '@expo/config': 12.0.13 '@expo/json-file': 10.0.8 @@ -10812,7 +11813,7 @@ snapshots: jest-environment-jsdom: 29.7.0 jest-snapshot: 29.7.0 jest-watch-select-projects: 2.0.0 - jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@25.0.9)) + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3))) json5: 2.2.3 lodash: 4.17.21 react-native: react-native-tvos@0.81.5-1(patch_hash=e91fec82df6b5870c3625e79ba3c16e25c5cecceb339f6f21cd184eca5d363df)(@babel/core@7.28.5)(@types/react@19.1.17)(react-native-tvos@0.81.5-1)(react@19.1.0) @@ -11010,11 +12011,11 @@ snapshots: chalk: 3.0.0 prompts: 2.4.2 - jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@25.0.9)): + jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3))): dependencies: ansi-escapes: 6.2.1 chalk: 4.1.2 - jest: 29.7.0(@types/node@25.0.9) + jest: 29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)) jest-regex-util: 29.6.3 jest-watcher: 29.7.0 slash: 5.1.0 @@ -11039,12 +12040,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@25.0.9): + jest@29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)): dependencies: - '@jest/core': 29.7.0 + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@25.0.9) + jest-cli: 29.7.0(@types/node@25.0.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -11053,8 +12054,7 @@ snapshots: jimp-compact@0.16.1: {} - jiti@1.21.7: - optional: true + jiti@2.6.1: {} js-tokens@4.0.0: {} @@ -11149,54 +12149,54 @@ snapshots: transitivePeerDependencies: - supports-color - lightningcss-android-arm64@1.30.2: + lightningcss-android-arm64@1.32.0: optional: true - lightningcss-darwin-arm64@1.30.2: + lightningcss-darwin-arm64@1.32.0: optional: true - lightningcss-darwin-x64@1.30.2: + lightningcss-darwin-x64@1.32.0: optional: true - lightningcss-freebsd-x64@1.30.2: + lightningcss-freebsd-x64@1.32.0: optional: true - lightningcss-linux-arm-gnueabihf@1.30.2: + lightningcss-linux-arm-gnueabihf@1.32.0: optional: true - lightningcss-linux-arm64-gnu@1.30.2: + lightningcss-linux-arm64-gnu@1.32.0: optional: true - lightningcss-linux-arm64-musl@1.30.2: + lightningcss-linux-arm64-musl@1.32.0: optional: true - lightningcss-linux-x64-gnu@1.30.2: + lightningcss-linux-x64-gnu@1.32.0: optional: true - lightningcss-linux-x64-musl@1.30.2: + lightningcss-linux-x64-musl@1.32.0: optional: true - lightningcss-win32-arm64-msvc@1.30.2: + lightningcss-win32-arm64-msvc@1.32.0: optional: true - lightningcss-win32-x64-msvc@1.30.2: + lightningcss-win32-x64-msvc@1.32.0: optional: true - lightningcss@1.30.2: + lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.30.2 - lightningcss-darwin-arm64: 1.30.2 - lightningcss-darwin-x64: 1.30.2 - lightningcss-freebsd-x64: 1.30.2 - lightningcss-linux-arm-gnueabihf: 1.30.2 - lightningcss-linux-arm64-gnu: 1.30.2 - lightningcss-linux-arm64-musl: 1.30.2 - lightningcss-linux-x64-gnu: 1.30.2 - lightningcss-linux-x64-musl: 1.30.2 - lightningcss-win32-arm64-msvc: 1.30.2 - lightningcss-win32-x64-msvc: 1.30.2 + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 lines-and-columns@1.2.4: {} @@ -11236,10 +12236,20 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@0.474.0(react@19.1.0): + dependencies: + react: 19.1.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + make-dir@4.0.0: dependencies: semver: 7.7.3 + make-error@1.3.6: {} + makeerror@1.0.12: dependencies: tmpl: 1.0.5 @@ -11718,6 +12728,9 @@ snapshots: lru-cache: 11.2.4 minipass: 7.1.2 + path-type@4.0.0: + optional: true + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -11738,6 +12751,8 @@ snapshots: pngjs@3.4.0: {} + pngjs@5.0.0: {} + popmotion@11.0.3: dependencies: framesync: 6.0.1 @@ -11755,6 +12770,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.12: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -11828,6 +12849,12 @@ snapshots: qrcode-terminal@0.11.0: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + query-string@7.1.3: dependencies: decode-uri-component: 0.2.2 @@ -11850,6 +12877,20 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-aria@3.48.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@internationalized/date': 3.12.1 + '@internationalized/number': 3.6.6 + '@internationalized/string': 3.2.8 + '@react-types/shared': 3.34.0(react@19.1.0) + '@swc/helpers': 0.5.21 + aria-hidden: 1.2.6 + clsx: 2.1.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-stately: 3.46.0(react@19.1.0) + use-sync-external-store: 1.6.0(react@19.1.0) + react-devtools-core@6.1.5: dependencies: shell-quote: 1.8.3 @@ -11869,11 +12910,11 @@ snapshots: dependencies: react: 19.1.0 - react-i18next@17.0.4(i18next@26.0.6(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0)(typescript@5.9.3): + react-i18next@17.0.6(i18next@26.0.8(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0)(typescript@5.9.3): dependencies: '@babel/runtime': 7.29.2 html-parse-stringify: 3.0.1 - i18next: 26.0.6(typescript@5.9.3) + i18next: 26.0.8(typescript@5.9.3) react: 19.1.0 use-sync-external-store: 1.6.0(react@19.1.0) optionalDependencies: @@ -11915,6 +12956,35 @@ snapshots: react: 19.1.0 react-native: react-native-tvos@0.81.5-1(patch_hash=e91fec82df6b5870c3625e79ba3c16e25c5cecceb339f6f21cd184eca5d363df)(@babel/core@7.28.5)(@types/react@19.1.17)(react-native-tvos@0.81.5-1)(react@19.1.0) + react-native-nitro-buffer@0.2.2(react-native-nitro-modules@0.35.5(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0): + dependencies: + react: 19.1.0 + react-native: react-native-tvos@0.81.5-1(patch_hash=e91fec82df6b5870c3625e79ba3c16e25c5cecceb339f6f21cd184eca5d363df)(@babel/core@7.28.5)(@types/react@19.1.17)(react-native-tvos@0.81.5-1)(react@19.1.0) + react-native-nitro-modules: 0.35.5(react-native-tvos@0.81.5-1)(react@19.1.0) + + react-native-nitro-http-server@1.8.1(react-native-nitro-modules@0.35.5(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0): + dependencies: + eventemitter3: 5.0.4 + react-native-nitro-buffer: 0.2.2(react-native-nitro-modules@0.35.5(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0) + react-native-nitro-modules: 0.35.5(react-native-tvos@0.81.5-1)(react@19.1.0) + transitivePeerDependencies: + - react + - react-native + + react-native-nitro-modules@0.35.5(react-native-tvos@0.81.5-1)(react@19.1.0): + dependencies: + react: 19.1.0 + react-native: react-native-tvos@0.81.5-1(patch_hash=e91fec82df6b5870c3625e79ba3c16e25c5cecceb339f6f21cd184eca5d363df)(@babel/core@7.28.5)(@types/react@19.1.17)(react-native-tvos@0.81.5-1)(react@19.1.0) + + react-native-qrcode-svg@6.3.21(react-native-svg@15.12.1(react-native-tvos@0.81.5-1)(react@19.1.0))(react-native-tvos@0.81.5-1)(react@19.1.0): + dependencies: + prop-types: 15.8.1 + qrcode: 1.5.4 + react: 19.1.0 + react-native: react-native-tvos@0.81.5-1(patch_hash=e91fec82df6b5870c3625e79ba3c16e25c5cecceb339f6f21cd184eca5d363df)(@babel/core@7.28.5)(@types/react@19.1.17)(react-native-tvos@0.81.5-1)(react@19.1.0) + react-native-svg: 15.12.1(react-native-tvos@0.81.5-1)(react@19.1.0) + text-encoding: 0.7.0 + react-native-reanimated@4.1.6(@babel/core@7.28.5)(react-native-tvos@0.81.5-1)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native-tvos@0.81.5-1)(react@19.1.0))(react@19.1.0): dependencies: '@babel/core': 7.28.5 @@ -12034,6 +13104,8 @@ snapshots: react-refresh@0.14.2: {} + react-refresh@0.17.0: {} + react-remove-scroll-bar@2.3.8(@types/react@19.1.17)(react@19.1.0): dependencies: react: 19.1.0 @@ -12053,6 +13125,16 @@ snapshots: optionalDependencies: '@types/react': 19.1.17 + react-stately@3.46.0(react@19.1.0): + dependencies: + '@internationalized/date': 3.12.1 + '@internationalized/number': 3.6.6 + '@internationalized/string': 3.2.8 + '@react-types/shared': 3.34.0(react@19.1.0) + '@swc/helpers': 0.5.21 + react: 19.1.0 + use-sync-external-store: 1.6.0(react@19.1.0) + react-style-singleton@2.2.3(@types/react@19.1.17)(react@19.1.0): dependencies: get-nonce: 1.0.1 @@ -12127,6 +13209,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + requireg@0.2.2: dependencies: nested-error-stacks: 2.0.1 @@ -12178,6 +13262,37 @@ snapshots: dependencies: glob: 7.2.3 + rollup@4.60.2: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.2 + '@rollup/rollup-android-arm64': 4.60.2 + '@rollup/rollup-darwin-arm64': 4.60.2 + '@rollup/rollup-darwin-x64': 4.60.2 + '@rollup/rollup-freebsd-arm64': 4.60.2 + '@rollup/rollup-freebsd-x64': 4.60.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.2 + '@rollup/rollup-linux-arm-musleabihf': 4.60.2 + '@rollup/rollup-linux-arm64-gnu': 4.60.2 + '@rollup/rollup-linux-arm64-musl': 4.60.2 + '@rollup/rollup-linux-loong64-gnu': 4.60.2 + '@rollup/rollup-linux-loong64-musl': 4.60.2 + '@rollup/rollup-linux-ppc64-gnu': 4.60.2 + '@rollup/rollup-linux-ppc64-musl': 4.60.2 + '@rollup/rollup-linux-riscv64-gnu': 4.60.2 + '@rollup/rollup-linux-riscv64-musl': 4.60.2 + '@rollup/rollup-linux-s390x-gnu': 4.60.2 + '@rollup/rollup-linux-x64-gnu': 4.60.2 + '@rollup/rollup-linux-x64-musl': 4.60.2 + '@rollup/rollup-openbsd-x64': 4.60.2 + '@rollup/rollup-openharmony-arm64': 4.60.2 + '@rollup/rollup-win32-arm64-msvc': 4.60.2 + '@rollup/rollup-win32-ia32-msvc': 4.60.2 + '@rollup/rollup-win32-x64-gnu': 4.60.2 + '@rollup/rollup-win32-x64-msvc': 4.60.2 + fsevents: 2.3.3 + rtl-detect@1.1.2: {} safe-array-concat@1.1.3: @@ -12268,6 +13383,8 @@ snapshots: server-only@0.0.1: {} + set-blocking@2.0.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -12554,6 +13671,12 @@ snapshots: symbol-tree@3.2.4: {} + tabbable@6.4.0: {} + + tailwindcss@4.2.4: {} + + tapable@2.3.3: {} + tar-fs@2.1.4: dependencies: chownr: 1.1.4 @@ -12597,6 +13720,8 @@ snapshots: glob: 7.2.3 minimatch: 3.1.2 + text-encoding@0.7.0: {} + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -12639,6 +13764,24 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-node@10.9.2(@types/node@25.0.9)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 25.0.9 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.0 + create-require: 1.1.1 + diff: 4.0.4 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29 @@ -12808,6 +13951,8 @@ snapshots: uuid@7.0.3: {} + v8-compile-cache-lib@3.0.1: {} + v8-to-istanbul@9.3.0: dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -12818,15 +13963,39 @@ snapshots: vary@1.1.2: {} - vaul@1.1.2(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - '@radix-ui/react-dialog': 1.1.15(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) transitivePeerDependencies: - '@types/react' - '@types/react-dom' + vite-plugin-singlefile@2.3.3(rollup@4.60.2)(vite@6.4.2(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + dependencies: + micromatch: 4.0.8 + vite: 6.4.2(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + optionalDependencies: + rollup: 4.60.2 + + vite@6.4.2(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.12 + rollup: 4.60.2 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.0.9 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 + terser: 5.44.1 + tsx: 4.21.0 + yaml: 2.8.2 + vlq@1.0.1: {} void-elements@3.1.0: {} @@ -12906,6 +14075,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.19: dependencies: available-typed-arrays: 1.0.7 @@ -12924,6 +14095,12 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -12963,16 +14140,40 @@ snapshots: xmlchars@2.2.0: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} yallist@5.0.0: {} + yaml@1.10.3: + optional: true + yaml@2.8.2: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 @@ -12983,6 +14184,8 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yn@3.1.1: {} + yocto-queue@0.1.0: {} yocto-queue@1.2.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6de2b1b..2b612a6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,5 @@ +packages: + - 'remote-ui' + onlyBuiltDependencies: - 'react-native-video' diff --git a/remote-ui/index.html b/remote-ui/index.html new file mode 100644 index 0000000..5252080 --- /dev/null +++ b/remote-ui/index.html @@ -0,0 +1,13 @@ + + + + + + + DodoStream Remote + + +
+ + + diff --git a/remote-ui/package.json b/remote-ui/package.json new file mode 100644 index 0000000..402ae7c --- /dev/null +++ b/remote-ui/package.json @@ -0,0 +1,31 @@ +{ + "name": "remote-ui", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@headlessui/react": "^2.2.0", + "@tanstack/react-query": "^5.90.12", + "lucide-react": "^0.474.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwindcss": "^4.0.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^6.1.0", + "vite-plugin-singlefile": "^2.0.2" + } +} \ No newline at end of file diff --git a/remote-ui/src/App.tsx b/remote-ui/src/App.tsx new file mode 100644 index 0000000..d4f0a5e --- /dev/null +++ b/remote-ui/src/App.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { AddonsView } from './components/AddonsView'; +import { BottomNav, ViewTab } from './components/BottomNav'; +import { DisconnectionBanner } from './components/DisconnectionBanner'; +import { Header } from './components/Header'; +import { LoadingSpinner } from './components/LoadingSpinner'; +import { PinDialog } from './components/PinDialog'; +import { ProfilesView } from './components/ProfilesView'; +import { SessionEndedScreen } from './components/SessionEndedScreen'; +import { api, isSessionEndedError, Profile, setPin } from './api'; +import { queryKeys } from './queries'; + +const HEALTH_CHECK_INTERVAL_MS = 5_000; + +function consumeUrlPin(): void { + const params = new URLSearchParams(window.location.search); + const pin = params.get('pin'); + if (!pin) return; + + setPin(pin); + params.delete('pin'); + const query = params.toString(); + const nextUrl = `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`; + window.history.replaceState({}, '', nextUrl); +} + +export default function App() { + const [hasPin, setHasPin] = useState(() => { + consumeUrlPin(); + return Boolean(localStorage.getItem('dodostream_pin')); + }); + const [view, setView] = useState('addons'); + const [sessionEnded, setSessionEnded] = useState(false); + const queryClient = useQueryClient(); + + const profilesQuery = useQuery({ + queryKey: queryKeys.profiles.all, + queryFn: api.getProfiles, + enabled: hasPin && !sessionEnded, + retry: false, + refetchInterval: (query) => + query.state.status === 'success' ? HEALTH_CHECK_INTERVAL_MS : false, + }); + + // Detect session-ended errors from any fetch (initial or background refetch) + useEffect(() => { + if (profilesQuery.error && isSessionEndedError(profilesQuery.error)) { + setSessionEnded(true); + } + }, [profilesQuery.error]); + + const handleSessionEnded = () => { + setSessionEnded(true); + }; + + const handlePinSuccess = () => { + setHasPin(true); + void queryClient.invalidateQueries({ queryKey: queryKeys.profiles.all }); + }; + + // Derive status from query state — no manual state machine needed + const status = (() => { + if (sessionEnded) return 'session-ended' as const; + if (!hasPin) return 'pin-required' as const; + if (profilesQuery.status === 'error') { + return isSessionEndedError(profilesQuery.error) + ? ('session-ended' as const) + : ('pin-required' as const); + } + if (profilesQuery.status === 'success') return 'ready' as const; + return 'loading' as const; // pending with query enabled + })(); + + const profiles: Profile[] = profilesQuery.data ?? []; + const isDisconnected = status === 'ready' && profilesQuery.error !== null; + + if (status === 'session-ended') { + return ; + } + + return ( +
+
+ +
+ {status === 'ready' && isDisconnected && } + +
+ {status === 'loading' && } + + {status === 'ready' && view === 'addons' && ( + + )} + + {status === 'ready' && view === 'profiles' && ( + + )} + + {status === 'pin-required' && } +
+
+ + {status === 'ready' && } +
+ ); +} \ No newline at end of file diff --git a/remote-ui/src/api.ts b/remote-ui/src/api.ts new file mode 100644 index 0000000..44a81ef --- /dev/null +++ b/remote-ui/src/api.ts @@ -0,0 +1,84 @@ +import { type AddonConfig } from '@app/types/addon-config'; + +// Base URL is the TV's server — same origin when served by the TV +export const isSessionEndedError = (error: unknown): boolean => { + if (!(error instanceof Error)) return false; + if (error.message === 'UNAUTHORIZED') return true; + // Browser fetch throws TypeError for network failures (DNS, CORS, offline). + // These have well-known messages per engine: "Failed to fetch" (Chromium), + // "Load failed" (WebKit), "NetworkError when attempting to fetch" (Gecko). + if (error.name === 'TypeError') { + const msg = error.message; + return ( + msg.includes('Failed to fetch') || + msg.includes('Load failed') || + msg.includes('NetworkError when attempting to fetch') + ); + } + return false; +}; + +const getBaseUrl = () => { + return window.location.origin; +}; + +const getPin = () => localStorage.getItem('dodostream_pin') ?? ''; + +export const setPin = (pin: string) => localStorage.setItem('dodostream_pin', pin); +const clearPin = () => localStorage.removeItem('dodostream_pin'); + +async function request(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`${getBaseUrl()}${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${getPin()}`, + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + + if (res.status === 401) { + clearPin(); + throw new Error('UNAUTHORIZED'); + } + + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `HTTP ${res.status}`); + } + + if (res.status === 204 || res.status === 205) { + return undefined as T; + } + + return res.json() as Promise; +} + +export interface Profile { + id: string; + name: string; + color: string; +} + +export type { AddonConfig }; + +export interface Addon { + id: string; + name: string; + version: string; + description?: string; + manifestUrl: string; + configurable: boolean; + config: AddonConfig; +} + +export const api = { + getProfiles: () => request('GET', '/api/v1/profiles'), + getAddons: (profileId: string) => request('GET', `/api/v1/profiles/${profileId}/addons`), + installAddon: (manifestUrl: string) => request<{ id: string }>('POST', '/api/v1/addons', { manifestUrl }), + removeAddon: (addonId: string) => request('DELETE', `/api/v1/addons/${encodeURIComponent(addonId)}`), + updateAddonConfig: (profileId: string, addonId: string, config: Partial) => + request('PATCH', `/api/v1/profiles/${profileId}/addons/${encodeURIComponent(addonId)}`, config), + reorderAddons: (profileId: string, orderedIds: string[]) => + request('PUT', `/api/v1/profiles/${profileId}/addons/order`, { orderedIds }), +}; diff --git a/remote-ui/src/components/AddonsView.tsx b/remote-ui/src/components/AddonsView.tsx new file mode 100644 index 0000000..ba8a4d9 --- /dev/null +++ b/remote-ui/src/components/AddonsView.tsx @@ -0,0 +1,229 @@ +import React, { useState, useCallback } from 'react'; +import { + Trash2, + Settings, + Puzzle, + Plus, + AlertCircle, + Loader2, + AlertTriangle, + X, +} from 'lucide-react'; +import { Profile, Addon } from '../api'; +import { useAllAddonsQuery, useInstallAddonMutation, useRemoveAddonMutation } from '../queries'; + +interface AddonsViewProps { + profiles: Profile[]; + onSessionEnded?: () => void; +} + +interface AddonRowProps { + addon: Addon; + activeInProfiles: Profile[]; + profiles: Profile[]; + onRequestDelete: (addon: Addon) => void; +} + +const AddonRow = React.memo(function AddonRow({ + addon, + activeInProfiles, + profiles, + onRequestDelete, +}: AddonRowProps) { + return ( +
+
+
+ +
+
+
+

{addon.name}

+ v{addon.version} +
+

{addon.description}

+ + {activeInProfiles.length > 0 && ( +
+ {activeInProfiles.map((p) => { + const profileIndex = profiles.findIndex((prof) => prof.id === p.id); + return ( +
+ {p.name.charAt(0).toUpperCase()} +
+ ); + })} +
+ )} +
+
+ +
+ {addon.configurable && ( + + + + )} + +
+
+ ); +}); + +export function AddonsView({ profiles, onSessionEnded }: AddonsViewProps) { + const [manifestUrl, setManifestUrl] = useState(''); + const [pendingDelete, setPendingDelete] = useState(null); + + const { addons, activeByProfile, isLoading, error } = useAllAddonsQuery(profiles, onSessionEnded); + const installMutation = useInstallAddonMutation(onSessionEnded); + const removeMutation = useRemoveAddonMutation(onSessionEnded); + + const handleInstall = (e: React.FormEvent) => { + e.preventDefault(); + if (!manifestUrl.trim()) return; + installMutation.mutate(manifestUrl.trim(), { + onSuccess: () => setManifestUrl(''), + }); + }; + + const handleDelete = (addon: Addon) => { + removeMutation.mutate(addon.id, { + onSuccess: () => setPendingDelete(null), + }); + }; + + const handleRequestDelete = useCallback((addon: Addon) => { + setPendingDelete(addon); + }, []); + + const handleCancelDelete = useCallback(() => { + setPendingDelete(null); + }, []); + + if (isLoading && addons.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+
+

Addons

+

+ Addons are installed globally. Go to Profiles to activate and reorder per profile. +

+
+ +
+
+ setManifestUrl(e.target.value)} + placeholder="https://example.com/manifest.json" + className="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-all" + required + /> + +
+
+ + {error && ( +
+ +

{error}

+
+ )} + + {installMutation.isError && ( +
+ +

+ {installMutation.error instanceof Error + ? installMutation.error.message + : 'Failed to install addon'} +

+
+ )} + + {removeMutation.isError && ( +
+ +

+ {removeMutation.error instanceof Error + ? removeMutation.error.message + : 'Failed to delete addon'} +

+
+ )} + + {pendingDelete && ( +
+ +

+ Delete {pendingDelete.name}? This + cannot be undone. +

+ + +
+ )} + +
+ {addons.length === 0 ? ( +
+ +

No addons installed yet.

+
+ ) : ( +
+ {addons.map((addon) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/remote-ui/src/components/BottomNav.tsx b/remote-ui/src/components/BottomNav.tsx new file mode 100644 index 0000000..70f172e --- /dev/null +++ b/remote-ui/src/components/BottomNav.tsx @@ -0,0 +1,30 @@ +import { Puzzle, Users } from 'lucide-react'; + +export type ViewTab = 'addons' | 'profiles'; + +interface BottomNavProps { + activeView: ViewTab; + onViewChange: (view: ViewTab) => void; +} + +const TAB_BASE = + 'flex flex-1 flex-col items-center gap-1 py-3 transition-colors text-[10px] font-medium uppercase tracking-wider'; + +export function BottomNav({ activeView, onViewChange }: BottomNavProps) { + return ( + + ); +} diff --git a/remote-ui/src/components/DisconnectionBanner.tsx b/remote-ui/src/components/DisconnectionBanner.tsx new file mode 100644 index 0000000..25f2f2a --- /dev/null +++ b/remote-ui/src/components/DisconnectionBanner.tsx @@ -0,0 +1,12 @@ +import { WifiOff } from 'lucide-react'; + +export function DisconnectionBanner() { + return ( +
+ +

+ Lost connection to DodoStream — make sure the app is open and Remote Control is active. +

+
+ ); +} diff --git a/remote-ui/src/components/DodoLogo.tsx b/remote-ui/src/components/DodoLogo.tsx new file mode 100644 index 0000000..135acbb --- /dev/null +++ b/remote-ui/src/components/DodoLogo.tsx @@ -0,0 +1,21 @@ +import { useId } from 'react'; +import { LOGO_PATH_D } from '@app/constants/logo-path'; + +interface DodoLogoProps { + size?: number; +} + +export function DodoLogo({ size = 28 }: DodoLogoProps) { + const gradientId = useId(); + return ( + + + + + + + + + + ); +} diff --git a/remote-ui/src/components/Header.tsx b/remote-ui/src/components/Header.tsx new file mode 100644 index 0000000..8dbe3d7 --- /dev/null +++ b/remote-ui/src/components/Header.tsx @@ -0,0 +1,12 @@ +import { DodoLogo } from './DodoLogo'; + +export function Header() { + return ( +
+
+ +

DodoStream Remote

+
+
+ ); +} diff --git a/remote-ui/src/components/LoadingSpinner.tsx b/remote-ui/src/components/LoadingSpinner.tsx new file mode 100644 index 0000000..fcf98e4 --- /dev/null +++ b/remote-ui/src/components/LoadingSpinner.tsx @@ -0,0 +1,10 @@ +import { Loader2 } from 'lucide-react'; + +export function LoadingSpinner() { + return ( +
+ +

Loading


+
+ ); +} diff --git a/remote-ui/src/components/PinDialog.tsx b/remote-ui/src/components/PinDialog.tsx new file mode 100644 index 0000000..6dd8b1c --- /dev/null +++ b/remote-ui/src/components/PinDialog.tsx @@ -0,0 +1,63 @@ +import { useState } from 'react'; +import { Dialog, DialogPanel, DialogTitle } from '@headlessui/react'; +import { setPin } from '../api'; + +interface PinDialogProps { + onSuccess: () => void; +} + +export function PinDialog({ onSuccess }: PinDialogProps) { + const [pinInput, setPinInput] = useState(''); + const [error, setError] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (pinInput.trim().length === 0) { + setError('Please enter a PIN'); + return; + } + + setPin(pinInput.trim()); + onSuccess(); + }; + + return ( + {}}> +
+ +
+ + + Enter PIN + +

+ Please enter the PIN shown on your TV to connect. +

+ +
+
+ { + setPinInput(e.target.value); + setError(''); + }} + placeholder="e.g. 1234" + className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-4 py-3 text-white placeholder-zinc-500 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500" + autoFocus + /> + {error &&

{error}

} +
+ + +
+
+
+
+ ); +} diff --git a/remote-ui/src/components/ProfileAddonRow.tsx b/remote-ui/src/components/ProfileAddonRow.tsx new file mode 100644 index 0000000..6ed0414 --- /dev/null +++ b/remote-ui/src/components/ProfileAddonRow.tsx @@ -0,0 +1,175 @@ +import React, { useState } from 'react'; +import { GripVertical, Settings, ChevronDown, ChevronUp, Puzzle } from 'lucide-react'; +import { Switch } from '@headlessui/react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { Addon, AddonConfig } from '../api'; + +interface ProfileAddonRowProps { + addon: Addon; + isSortable: boolean; + onToggleActive: (addon: Addon, isActive: boolean) => void; + onUpdateConfig: (addon: Addon, configUpdates: Partial) => void; +} + +const ProfileAddonRow = React.memo(function ProfileAddonRow({ + addon, + isSortable, + onToggleActive, + onUpdateConfig, +}: ProfileAddonRowProps) { + const [isExpanded, setIsExpanded] = useState(false); + const { config } = addon; + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: addon.id, disabled: !isSortable }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 10 : 1, + }; + + const hasSubConfigs = config.isActive; + + return ( +
+
+
+ {isSortable ? ( +
+ +
+ ) : ( +
// Spacer to align with sortable items + )} + +
+ + +
+ +
+
+

+ {addon.name} +

+ v{addon.version} +
+

{addon.description}

+
+
+ +
+ {addon.configurable && ( + + + + + )} + + {hasSubConfigs && ( + + )} + + onToggleActive(addon, checked)} + className={`${ + config.isActive ? 'bg-indigo-500' : 'bg-zinc-700' + } relative inline-flex h-5 w-9 sm:h-6 sm:w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-zinc-900 sm:ml-2`} + > + + +
+
+ + {hasSubConfigs && isExpanded && ( +
+
+ Visible on Home + onUpdateConfig(addon, { useCatalogsOnHome: checked })} + className={`${ + config.useCatalogsOnHome !== false ? 'bg-indigo-500' : 'bg-zinc-700' + } relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none`} + > + + +
+ +
+ Use in Search + onUpdateConfig(addon, { useCatalogsInSearch: checked })} + className={`${ + config.useCatalogsInSearch !== false ? 'bg-indigo-500' : 'bg-zinc-700' + } relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none`} + > + + +
+ +
+ Use for Subtitles + onUpdateConfig(addon, { useForSubtitles: checked })} + className={`${ + config.useForSubtitles ? 'bg-indigo-500' : 'bg-zinc-700' + } relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none`} + > + + +
+
+ )} +
+ ); +}); + +export { ProfileAddonRow }; \ No newline at end of file diff --git a/remote-ui/src/components/ProfilesView.tsx b/remote-ui/src/components/ProfilesView.tsx new file mode 100644 index 0000000..1d29a7e --- /dev/null +++ b/remote-ui/src/components/ProfilesView.tsx @@ -0,0 +1,200 @@ +import React, { useState, useMemo } from 'react'; +import { Loader2, AlertCircle, Puzzle } from 'lucide-react'; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { Profile, Addon, AddonConfig } from '../api'; +import { + useProfileAddonsQuery, + useReorderAddonsMutation, + useToggleActiveMutation, + useUpdateAddonConfigMutation, +} from '../queries'; +import { ProfileAddonRow } from './ProfileAddonRow'; + +interface ProfilesViewProps { + profiles: Profile[]; + onSessionEnded?: () => void; +} + +export function ProfilesView({ profiles, onSessionEnded }: ProfilesViewProps) { + const [selectedProfileId, setSelectedProfileId] = useState(profiles[0]?.id ?? ''); + + const { addons, isLoading, error, refetch } = useProfileAddonsQuery( + selectedProfileId, + onSessionEnded, + ); + + const reorderMutation = useReorderAddonsMutation(selectedProfileId, onSessionEnded); + const toggleMutation = useToggleActiveMutation(selectedProfileId, onSessionEnded); + const configMutation = useUpdateAddonConfigMutation(selectedProfileId, onSessionEnded); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + const activeAddons = useMemo(() => addons.filter((a) => a.config.isActive), [addons]); + const inactiveAddons = useMemo(() => addons.filter((a) => !a.config.isActive), [addons]); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + + const oldIndex = activeAddons.findIndex((a) => a.id === active.id); + const newIndex = activeAddons.findIndex((a) => a.id === over.id); + const newActive = arrayMove(activeAddons, oldIndex, newIndex); + + const orderedIds = [...newActive.map((a) => a.id), ...inactiveAddons.map((a) => a.id)]; + reorderMutation.mutate(orderedIds); + }; + + const handleToggleActive = (addon: Addon, isActive: boolean) => { + toggleMutation.mutate({ addonId: addon.id, isActive }); + }; + + const handleUpdateConfig = (addon: Addon, configUpdates: Partial) => { + configMutation.mutate({ addonId: addon.id, configUpdates }); + }; + + if (profiles.length === 0) { + return ( +
+ No profiles found. +
+ ); + } + + return ( +
+ + {/* Header */} +
+

Profiles

+

+ Activate, configure, and reorder addons per profile. +

+
+ + {/* Profile Selector */} +
+

Select Profile

+
+ {profiles.map(p => ( + + ))} +
+
+ + {error && ( +
+
+ +

{error}

+
+ +
+ )} + + {/* Addons Sections */} + {isLoading && addons.length === 0 ? ( +
+ +
+ ) : addons.length === 0 ? ( +
+ +

No addons found for this profile.

+
+ ) : ( +
+ + {/* Active Addons */} +
+

+ Active Addons + + {activeAddons.length} + +

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

No active addons. Enable some below.

+ ) : ( + + a.id)} strategy={verticalListSortingStrategy}> +
+ {activeAddons.map(addon => ( + + ))} +
+
+
+ )} +
+ + {/* Inactive Addons */} + {inactiveAddons.length > 0 && ( +
+

+ Inactive Addons + + {inactiveAddons.length} + +

+ +
+ {inactiveAddons.map(addon => ( + + ))} +
+
+ )} + +
+ )} +
+ ); +} diff --git a/remote-ui/src/components/SessionEndedScreen.tsx b/remote-ui/src/components/SessionEndedScreen.tsx new file mode 100644 index 0000000..cfd6c46 --- /dev/null +++ b/remote-ui/src/components/SessionEndedScreen.tsx @@ -0,0 +1,17 @@ +import { WifiOff } from 'lucide-react'; + +export function SessionEndedScreen() { + return ( +
+
+ +
+

+ Session Ended +

+

+ Your connection has expired. Please restart the remote from your TV to generate a new link. +

+
+ ); +} diff --git a/remote-ui/src/index.css b/remote-ui/src/index.css new file mode 100644 index 0000000..92a298d --- /dev/null +++ b/remote-ui/src/index.css @@ -0,0 +1,9 @@ +@import "tailwindcss"; + +@theme { + --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +html, body, #root { + height: 100%; +} diff --git a/remote-ui/src/main.tsx b/remote-ui/src/main.tsx new file mode 100644 index 0000000..c904710 --- /dev/null +++ b/remote-ui/src/main.tsx @@ -0,0 +1,22 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import './index.css'; +import App from './App'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: false, + }, + }, +}); + +createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/remote-ui/src/queries.ts b/remote-ui/src/queries.ts new file mode 100644 index 0000000..0336b1b --- /dev/null +++ b/remote-ui/src/queries.ts @@ -0,0 +1,220 @@ +import { useQuery, useMutation, useQueryClient, useQueries } from '@tanstack/react-query'; +import { useEffect } from 'react'; +import { api, Addon, Profile, AddonConfig, isSessionEndedError } from './api'; + +// ─── Query Keys ────────────────────────────────────────────────────────────── + +export const queryKeys = { + profiles: { + all: ['profiles'] as const, + }, + addons: { + all: ['addons'] as const, + profile: (profileId: string) => ['addons', profileId] as const, + }, +} as const; + +// ─── AddonsView Hooks ───────────────────────────────────────────────────────── + +/** Fetch addons across all profiles, merging into a flat list with per-addon profile membership. */ +export function useAllAddonsQuery(profiles: Profile[], onSessionEnded?: () => void) { + const queries = useQueries({ + queries: profiles.map((p) => ({ + queryKey: queryKeys.addons.profile(p.id), + queryFn: () => api.getAddons(p.id), + enabled: profiles.length > 0, + })), + }); + + const hasSessionError = queries.some((q) => q.isError && isSessionEndedError(q.error)); + useEffect(() => { + if (hasSessionError) onSessionEnded?.(); + }, [hasSessionError, onSessionEnded]); + + const addonMap = new Map(); + const activeByProfile: Record = {}; + + for (let i = 0; i < queries.length; i++) { + const data = queries[i].data; + if (!data) continue; + const profile = profiles[i]; + for (const addon of data) { + if (!addonMap.has(addon.id)) { + addonMap.set(addon.id, addon); + activeByProfile[addon.id] = []; + } + if (addon.config.isActive) { + activeByProfile[addon.id].push(profile); + } + } + } + + const firstError = queries.find((q) => q.isError)?.error; + + return { + addons: Array.from(addonMap.values()), + activeByProfile, + isLoading: queries.some((q) => q.isLoading), + error: firstError instanceof Error ? firstError.message : null, + }; +} + +export function useInstallAddonMutation(onSessionEnded?: () => void) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (manifestUrl: string) => api.installAddon(manifestUrl), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.addons.all }); + }, + onError: (err: unknown) => { + if (isSessionEndedError(err)) onSessionEnded?.(); + }, + }); +} + +export function useRemoveAddonMutation(onSessionEnded?: () => void) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (addonId: string) => api.removeAddon(addonId), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.addons.all }); + }, + onError: (err: unknown) => { + if (isSessionEndedError(err)) onSessionEnded?.(); + }, + }); +} + +// ─── ProfilesView Hooks ─────────────────────────────────────────────────────── + +export function useProfileAddonsQuery(profileId: string, onSessionEnded?: () => void) { + const query = useQuery({ + queryKey: queryKeys.addons.profile(profileId), + queryFn: () => api.getAddons(profileId), + enabled: !!profileId, + }); + + const hasSessionError = query.isError && isSessionEndedError(query.error); + useEffect(() => { + if (hasSessionError) onSessionEnded?.(); + }, [hasSessionError, onSessionEnded]); + + return { + addons: query.data ?? [], + isLoading: query.isLoading, + error: query.isError + ? query.error instanceof Error + ? query.error.message + : 'Failed to load profile addons' + : null, + refetch: query.refetch, + }; +} + +export function useReorderAddonsMutation(profileId: string, onSessionEnded?: () => void) { + const queryClient = useQueryClient(); + const key = queryKeys.addons.profile(profileId); + + return useMutation({ + mutationFn: (orderedIds: string[]) => api.reorderAddons(profileId, orderedIds), + onMutate: async (orderedIds) => { + await queryClient.cancelQueries({ queryKey: key }); + const previousAddons = queryClient.getQueryData(key); + + if (previousAddons) { + const byId = new Map(previousAddons.map((a) => [a.id, a])); + const reordered = orderedIds.map((id) => byId.get(id)!).filter(Boolean); + const reorderedSet = new Set(orderedIds); + const remaining = previousAddons.filter((a) => !reorderedSet.has(a.id)); + queryClient.setQueryData(key, [...reordered, ...remaining]); + } + + return { previousAddons }; + }, + onError: (err: unknown, _vars, context) => { + if (isSessionEndedError(err)) { + onSessionEnded?.(); + } else if (context?.previousAddons) { + queryClient.setQueryData(key, context.previousAddons); + } + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: key }); + }, + }); +} + +export function useToggleActiveMutation(profileId: string, onSessionEnded?: () => void) { + const queryClient = useQueryClient(); + const key = queryKeys.addons.profile(profileId); + + return useMutation({ + mutationFn: ({ addonId, isActive }: { addonId: string; isActive: boolean }) => + api.updateAddonConfig(profileId, addonId, { isActive }), + onMutate: async ({ addonId, isActive }) => { + await queryClient.cancelQueries({ queryKey: key }); + const previousAddons = queryClient.getQueryData(key); + + if (previousAddons) { + queryClient.setQueryData( + key, + previousAddons.map((a) => + a.id === addonId ? { ...a, config: { ...a.config, isActive } } : a, + ), + ); + } + + return { previousAddons }; + }, + onError: (err: unknown, _vars, context) => { + if (isSessionEndedError(err)) { + onSessionEnded?.(); + } else if (context?.previousAddons) { + queryClient.setQueryData(key, context.previousAddons); + } + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: key }); + }, + }); +} + +export function useUpdateAddonConfigMutation(profileId: string, onSessionEnded?: () => void) { + const queryClient = useQueryClient(); + const key = queryKeys.addons.profile(profileId); + + return useMutation({ + mutationFn: ({ + addonId, + configUpdates, + }: { + addonId: string; + configUpdates: Partial; + }) => api.updateAddonConfig(profileId, addonId, configUpdates), + onMutate: async ({ addonId, configUpdates }) => { + await queryClient.cancelQueries({ queryKey: key }); + const previousAddons = queryClient.getQueryData(key); + + if (previousAddons) { + queryClient.setQueryData( + key, + previousAddons.map((a) => + a.id === addonId ? { ...a, config: { ...a.config, ...configUpdates } } : a, + ), + ); + } + + return { previousAddons }; + }, + onError: (err: unknown, _vars, context) => { + if (isSessionEndedError(err)) { + onSessionEnded?.(); + } else if (context?.previousAddons) { + queryClient.setQueryData(key, context.previousAddons); + } + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: key }); + }, + }); +} diff --git a/remote-ui/tsconfig.json b/remote-ui/tsconfig.json new file mode 100644 index 0000000..aa0e5f7 --- /dev/null +++ b/remote-ui/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "paths": { + "@app/*": ["../src/*"] + }, + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true + }, + "include": ["src", "../src/constants/logo-path.ts"] +} diff --git a/remote-ui/vite.config.ts b/remote-ui/vite.config.ts new file mode 100644 index 0000000..e676464 --- /dev/null +++ b/remote-ui/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; +import { viteSingleFile } from 'vite-plugin-singlefile'; +import path from 'path'; + +export default defineConfig({ + plugins: [tailwindcss(), react(), viteSingleFile()], + resolve: { + alias: { + '@app': path.resolve(__dirname, '../src'), + }, + }, + build: { + outDir: 'dist', + assetsInlineLimit: 100000000, + cssCodeSplit: false, + }, +}); diff --git a/scripts/embed-web-ui.mjs b/scripts/embed-web-ui.mjs new file mode 100644 index 0000000..a768ccc --- /dev/null +++ b/scripts/embed-web-ui.mjs @@ -0,0 +1,15 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..'); + +const html = readFileSync(resolve(root, 'remote-ui/dist/index.html'), 'utf-8'); +const escaped = html.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${'); + +const output = `// AUTO-GENERATED — do not edit. Run pnpm build:ui to regenerate.\nexport const WEB_UI_HTML = \`${escaped}\`;\n`; + +writeFileSync(resolve(root, 'src/api/local-server/web-ui.ts'), output, 'utf-8'); +console.log('✓ Embedded web UI into src/api/local-server/web-ui.ts'); diff --git a/src/api/local-server/__tests__/handlers.test.ts b/src/api/local-server/__tests__/handlers.test.ts new file mode 100644 index 0000000..698aafb --- /dev/null +++ b/src/api/local-server/__tests__/handlers.test.ts @@ -0,0 +1,606 @@ +import { + deleteAddon, + getProfileAddons, + getProfiles, + installAddon, + patchProfileAddon, + reorderProfileAddons, +} from '../handlers'; + +import { useAddonStore } from '@/store/addon.store'; +import { type AddonConfig } from '@/types/addon-config'; +import { useProfileStore } from '@/store/profile.store'; + +jest.mock('@/store/addon.store', () => ({ + useAddonStore: { + getState: jest.fn(), + }, +})); + +jest.mock('@/store/profile.store', () => ({ + useProfileStore: { + getState: jest.fn(), + }, +})); + + +interface MockProfile { + id: string; + name: string; + avatarIcon: string; + avatarColor: string; +} + +interface MockInstalledAddon { + id: string; + manifestUrl: string; + manifest: { + id: string; + name: string; + version: string; + description?: string; + }; +} + +const mockUseProfileStore = useProfileStore as jest.Mocked; +const mockUseAddonStore = useAddonStore as jest.Mocked; + +const defaultConfig: AddonConfig = { + isActive: true, + useCatalogsOnHome: true, + useCatalogsInSearch: true, + useForSubtitles: true, +}; + +function createAddonStoreState(overrides?: { + orderedAddons?: MockInstalledAddon[]; + configByAddonId?: Record; +}) { + const orderedAddons = overrides?.orderedAddons ?? []; + const configByAddonId = overrides?.configByAddonId ?? {}; + + const addAddon = jest.fn(); + const removeAddon = jest.fn(); + const activateAddon = jest.fn((addonId: string) => { + const cfg = configByAddonId[addonId]; + if (cfg) { + configByAddonId[addonId] = { ...cfg, isActive: true }; + } + }); + const deactivateAddon = jest.fn((addonId: string) => { + const cfg = configByAddonId[addonId]; + if (cfg) { + configByAddonId[addonId] = { ...cfg, isActive: false }; + } + }); + const reorderAddon = jest.fn((fromIndex: number, toIndex: number) => { + const [moved] = orderedAddons.splice(fromIndex, 1); + orderedAddons.splice(toIndex, 0, moved); + }); + const toggleUseCatalogsOnHome = jest.fn((addonId: string) => { + const cfg = configByAddonId[addonId]; + if (cfg) { + configByAddonId[addonId] = { ...cfg, useCatalogsOnHome: !cfg.useCatalogsOnHome }; + } + }); + const toggleUseCatalogsInSearch = jest.fn((addonId: string) => { + const cfg = configByAddonId[addonId]; + if (cfg) { + configByAddonId[addonId] = { ...cfg, useCatalogsInSearch: !cfg.useCatalogsInSearch }; + } + }); + const toggleUseForSubtitles = jest.fn((addonId: string) => { + const cfg = configByAddonId[addonId]; + if (cfg) { + configByAddonId[addonId] = { ...cfg, useForSubtitles: !cfg.useForSubtitles }; + } + }); + const getAddonConfig = jest.fn((addonId: string) => configByAddonId[addonId]); + const setAddonConfig = jest.fn((addonId: string, config: Partial) => { + const existing = configByAddonId[addonId] ?? defaultConfig; + configByAddonId[addonId] = { ...existing, ...config }; + }); + const getOrderedAddonsList = jest.fn(() => orderedAddons); + return { + addons: {}, + configsByProfile: {}, + addonOrderByProfile: {}, + addAddon, + removeAddon, + activateAddon, + deactivateAddon, + reorderAddon, + toggleUseCatalogsOnHome, + toggleUseCatalogsInSearch, + toggleUseForSubtitles, + getAddonConfig, + setAddonConfig, + getOrderedAddonsList, + }; +} + +function setProfiles(profiles: Record) { + mockUseProfileStore.getState.mockReturnValue({ profiles } as never); +} + +describe('local server handlers', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('getProfiles', () => { + it('returns 200 with mapped profile list', () => { + // Arrange + setProfiles({ + p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' }, + p2: { id: 'p2', name: 'Bob', avatarIcon: 'robot', avatarColor: 'red' }, + }); + + // Act + const result = getProfiles(); + + // Assert + expect(result).toEqual({ + status: 200, + body: [ + { + id: 'p1', + name: 'Alice', + color: 'blue', + }, + { + id: 'p2', + name: 'Bob', + color: 'red', + }, + ], + }); + }); + + it('returns 200 with empty array when no profiles', () => { + // Arrange + setProfiles({}); + + // Act + const result = getProfiles(); + + // Assert + expect(result).toEqual({ status: 200, body: [] }); + }); + }); + + describe('getProfileAddons', () => { + it('returns 404 when profile not found', () => { + // Arrange + setProfiles({}); + + // Act + const result = getProfileAddons('missing-profile'); + + // Assert + expect(result).toEqual({ status: 404, body: { error: 'Profile not found' } }); + }); + + it('returns 200 with addon list including config', () => { + // Arrange + setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } }); + + const addonStore = createAddonStoreState({ + orderedAddons: [ + { + id: 'addon.one', + manifestUrl: 'https://example.com/one/manifest.json', + manifest: { + id: 'addon.one', + name: 'Addon One', + version: '1.2.3', + description: 'First addon', + }, + }, + ], + configByAddonId: { + 'addon.one': { ...defaultConfig, useCatalogsOnHome: false }, + }, + }); + mockUseAddonStore.getState.mockReturnValue(addonStore as never); + + // Act + const result = getProfileAddons('p1'); + + // Assert + expect(result).toEqual({ + status: 200, + body: [ + { + id: 'addon.one', + name: 'Addon One', + version: '1.2.3', + description: 'First addon', + manifestUrl: 'https://example.com/one/manifest.json', + config: { + isActive: true, + useCatalogsOnHome: false, + useCatalogsInSearch: true, + useForSubtitles: true, + }, + configurable: false + }, + ], + }); + }); + + it('returns 200 with empty array when profile has no addons', () => { + // Arrange + setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } }); + const addonStore = createAddonStoreState(); + mockUseAddonStore.getState.mockReturnValue(addonStore as never); + + // Act + const result = getProfileAddons('p1'); + + // Assert + expect(result).toEqual({ status: 200, body: [] }); + }); + }); + + describe('installAddon', () => { + it('returns 400 when manifestUrl is missing', async () => { + // Act + const result = await installAddon({}); + + // Assert + expect(result).toEqual({ status: 400, body: { error: 'manifestUrl is required' } }); + }); + + it('returns 400 when manifestUrl is not a string', async () => { + // Act + const result = await installAddon({ manifestUrl: 123 }); + + // Assert + expect(result).toEqual({ status: 400, body: { error: 'manifestUrl is required' } }); + }); + + it('returns 400 when fetch fails with network error', async () => { + // Arrange + const fetchSpy = jest.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('network failed')); + + // Act + const result = await installAddon({ manifestUrl: 'https://example.com/manifest.json' }); + + // Assert + expect(result).toEqual({ status: 400, body: { error: 'Failed to fetch manifest' } }); + fetchSpy.mockRestore(); + }); + + it('returns 400 when response is not ok', async () => { + // Arrange + const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + ok: false, + status: 404, + } as Response); + + // Act + const result = await installAddon({ manifestUrl: 'https://example.com/missing.json' }); + + // Assert + expect(result).toEqual({ + status: 400, + body: { error: 'Failed to fetch manifest (404)' }, + }); + fetchSpy.mockRestore(); + }); + + it('returns 400 when manifest is missing required fields', async () => { + // Arrange + const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ id: 'addon.invalid' }), + } as Response); + + // Act + const result = await installAddon({ manifestUrl: 'https://example.com/manifest.json' }); + + // Assert + expect(result).toEqual({ + status: 400, + body: { error: 'Invalid manifest: missing required fields' }, + }); + fetchSpy.mockRestore(); + }); + + it('returns 200 with id and calls addAddon when manifest is valid', async () => { + // Arrange + const addonStore = createAddonStoreState(); + mockUseAddonStore.getState.mockReturnValue(addonStore as never); + + const manifest = { + id: 'addon.valid', + name: 'Valid Addon', + version: '1.0.0', + }; + const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => manifest, + } as Response); + + // Act + const result = await installAddon({ manifestUrl: 'https://example.com/manifest.json' }); + + // Assert + expect(result).toEqual({ status: 200, body: { id: 'addon.valid' } }); + expect(addonStore.addAddon).toHaveBeenCalledWith( + 'addon.valid', + 'https://example.com/manifest.json', + manifest + ); + fetchSpy.mockRestore(); + }); + }); + + describe('deleteAddon', () => { + it('calls removeAddon with decoded addonId and returns 204', () => { + // Arrange + const addonStore = createAddonStoreState(); + mockUseAddonStore.getState.mockReturnValue(addonStore as never); + + // Act + const result = deleteAddon('my%2Faddon'); + + // Assert + expect(addonStore.removeAddon).toHaveBeenCalledWith('my%2Faddon'); + expect(result).toEqual({ status: 204 }); + }); + }); + + describe('patchProfileAddon', () => { + it('returns 404 when profile not found', () => { + // Arrange + setProfiles({}); + + // Act + const result = patchProfileAddon('missing-profile', 'addon.one', {}); + + // Assert + expect(result).toEqual({ status: 404, body: { error: 'Profile not found' } }); + }); + + it('returns 200 with no-op when body is empty', () => { + // Arrange + setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } }); + const addonStore = createAddonStoreState({ + configByAddonId: { + 'addon.one': { ...defaultConfig }, + }, + }); + mockUseAddonStore.getState.mockReturnValue(addonStore as never); + + // Act + const result = patchProfileAddon('p1', 'addon.one', {}); + + // Assert + expect(addonStore.setAddonConfig).not.toHaveBeenCalled(); + expect(result).toEqual({ status: 200, body: { ok: true } }); + }); + + it('calls setAddonConfig with isActive: true', () => { + // Arrange + setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } }); + const addonStore = createAddonStoreState({ + configByAddonId: { + 'addon.one': { ...defaultConfig, isActive: false }, + }, + }); + mockUseAddonStore.getState.mockReturnValue(addonStore as never); + + // Act + const result = patchProfileAddon('p1', 'addon.one', { isActive: true }); + + // Assert + expect(addonStore.setAddonConfig).toHaveBeenCalledWith( + 'addon.one', + { isActive: true }, + 'p1', + ); + expect(result).toEqual({ status: 200, body: { ok: true } }); + }); + + it('calls setAddonConfig with isActive: false when config exists', () => { + // Arrange + setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } }); + const addonStore = createAddonStoreState({ + configByAddonId: { + 'addon.one': { ...defaultConfig, isActive: true }, + }, + }); + mockUseAddonStore.getState.mockReturnValue(addonStore as never); + + // Act + const result = patchProfileAddon('p1', 'addon.one', { isActive: false }); + + // Assert + expect(addonStore.setAddonConfig).toHaveBeenCalledWith( + 'addon.one', + { isActive: false }, + 'p1', + ); + expect(result).toEqual({ status: 200, body: { ok: true } }); + }); + + it('returns 404 when deactivating and no config exists', () => { + // Arrange + setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } }); + const addonStore = createAddonStoreState({ + configByAddonId: { 'addon.one': undefined }, + }); + mockUseAddonStore.getState.mockReturnValue(addonStore as never); + + // Act + const result = patchProfileAddon('p1', 'addon.one', { isActive: false }); + + // Assert + expect(addonStore.setAddonConfig).not.toHaveBeenCalled(); + expect(result).toEqual({ status: 404, body: { error: 'Addon config not found' } }); + }); + + it('calls setAddonConfig with useCatalogsOnHome', () => { + // Arrange + setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } }); + const addonStore = createAddonStoreState({ + configByAddonId: { + 'addon.one': { ...defaultConfig, useCatalogsOnHome: true }, + }, + }); + mockUseAddonStore.getState.mockReturnValue(addonStore as never); + + // Act + patchProfileAddon('p1', 'addon.one', { useCatalogsOnHome: false }); + + // Assert + expect(addonStore.setAddonConfig).toHaveBeenCalledWith( + 'addon.one', + { useCatalogsOnHome: false }, + 'p1', + ); + }); + + it('calls setAddonConfig with useCatalogsInSearch', () => { + // Arrange + setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } }); + const addonStore = createAddonStoreState({ + configByAddonId: { + 'addon.one': { ...defaultConfig, useCatalogsInSearch: false }, + }, + }); + mockUseAddonStore.getState.mockReturnValue(addonStore as never); + + // Act + patchProfileAddon('p1', 'addon.one', { useCatalogsInSearch: true }); + + // Assert + expect(addonStore.setAddonConfig).toHaveBeenCalledWith( + 'addon.one', + { useCatalogsInSearch: true }, + 'p1', + ); + }); + + it('calls setAddonConfig with useForSubtitles', () => { + // Arrange + setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } }); + const addonStore = createAddonStoreState({ + configByAddonId: { + 'addon.one': { ...defaultConfig, useForSubtitles: false }, + }, + }); + mockUseAddonStore.getState.mockReturnValue(addonStore as never); + + // Act + const result = patchProfileAddon('p1', 'addon.one', { useForSubtitles: true }); + + // Assert + expect(addonStore.setAddonConfig).toHaveBeenCalledWith( + 'addon.one', + { useForSubtitles: true }, + 'p1', + ); + expect(result).toEqual({ status: 200, body: { ok: true } }); + }); + + it('calls setAddonConfig with multiple fields at once', () => { + // Arrange + setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } }); + const addonStore = createAddonStoreState({ + configByAddonId: { + 'addon.one': { ...defaultConfig }, + }, + }); + mockUseAddonStore.getState.mockReturnValue(addonStore as never); + + // Act + patchProfileAddon('p1', 'addon.one', { + isActive: false, + useCatalogsOnHome: false, + useForSubtitles: false, + }); + + // Assert + expect(addonStore.setAddonConfig).toHaveBeenCalledWith( + 'addon.one', + { + isActive: false, + useCatalogsOnHome: false, + useForSubtitles: false, + }, + 'p1', + ); + }); + }); + + describe('reorderProfileAddons', () => { + it('returns 404 when profile not found', () => { + // Arrange + setProfiles({}); + + // Act + const result = reorderProfileAddons('missing-profile', { orderedIds: [] }); + + // Assert + expect(result).toEqual({ status: 404, body: { error: 'Profile not found' } }); + }); + + it('returns 400 when orderedIds is not an array', () => { + // Arrange + setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } }); + + // Act + const result = reorderProfileAddons('p1', { orderedIds: 'not-an-array' }); + + // Assert + expect(result).toEqual({ status: 400, body: { error: 'orderedIds must be an array of strings' } }); + }); + + it('calls reorderAddon with correct fromIndex/toIndex for moved items and returns 200', () => { + // Arrange + setProfiles({ p1: { id: 'p1', name: 'Alice', avatarIcon: 'person', avatarColor: 'blue' } }); + + const addonStore = createAddonStoreState({ + orderedAddons: [ + { + id: 'addon/a', + manifestUrl: 'https://example.com/a.json', + manifest: { id: 'addon/a', name: 'A', version: '1.0.0' }, + }, + { + id: 'addon/b', + manifestUrl: 'https://example.com/b.json', + manifest: { id: 'addon/b', name: 'B', version: '1.0.0' }, + }, + { + id: 'addon/c', + manifestUrl: 'https://example.com/c.json', + manifest: { id: 'addon/c', name: 'C', version: '1.0.0' }, + }, + { + id: 'addon/d', + manifestUrl: 'https://example.com/d.json', + manifest: { id: 'addon/d', name: 'D', version: '1.0.0' }, + }, + ], + }); + mockUseAddonStore.getState.mockReturnValue(addonStore as never); + + // Act + const result = reorderProfileAddons('p1', { + orderedIds: ['addon%2Fd', 'addon%2Fb', 'addon%2Fa', 'addon%2Fc'], + }); + + // Assert + expect(addonStore.reorderAddon).toHaveBeenCalledTimes(2); + expect(addonStore.reorderAddon).toHaveBeenNthCalledWith(1, 3, 0, 'p1'); + expect(addonStore.reorderAddon).toHaveBeenNthCalledWith(2, 2, 1, 'p1'); + expect(result).toEqual({ status: 200, body: { ok: true } }); + }); + }); +}); diff --git a/src/api/local-server/__tests__/router.test.ts b/src/api/local-server/__tests__/router.test.ts new file mode 100644 index 0000000..6d3e3e9 --- /dev/null +++ b/src/api/local-server/__tests__/router.test.ts @@ -0,0 +1,248 @@ +import type { HttpRequest } from 'react-native-nitro-http-server'; + +import { + deleteAddon, + getProfileAddons, + getProfiles, + installAddon, + patchProfileAddon, + reorderProfileAddons, +} from '../handlers'; +import { createRouter } from '../router'; + +jest.mock('../handlers', () => ({ + getProfiles: jest.fn(), + getProfileAddons: jest.fn(), + installAddon: jest.fn(), + deleteAddon: jest.fn(), + patchProfileAddon: jest.fn(), + reorderProfileAddons: jest.fn(), +})); + +function req( + method: string, + path: string, + opts: { headers?: Record; body?: string } = {} +): HttpRequest { + return { requestId: '1', method, path, headers: opts.headers ?? {}, body: opts.body }; +} + +describe('local-server router', () => { + const PIN = '123456'; + const HTML = 'test'; + + const getProfilesMock = jest.mocked(getProfiles); + const getProfileAddonsMock = jest.mocked(getProfileAddons); + const installAddonMock = jest.mocked(installAddon); + const deleteAddonMock = jest.mocked(deleteAddon); + const patchProfileAddonMock = jest.mocked(patchProfileAddon); + const reorderProfileAddonsMock = jest.mocked(reorderProfileAddons); + + let router: ReturnType; + + beforeEach(() => { + jest.resetAllMocks(); + router = createRouter(PIN, HTML); + + getProfilesMock.mockReturnValue({ status: 200, body: { ok: true } }); + getProfileAddonsMock.mockReturnValue({ status: 200, body: { ok: true } }); + installAddonMock.mockResolvedValue({ status: 200, body: { ok: true } }); + deleteAddonMock.mockReturnValue({ status: 204 }); + patchProfileAddonMock.mockReturnValue({ status: 200, body: { ok: true } }); + reorderProfileAddonsMock.mockReturnValue({ status: 200, body: { ok: true } }); + }); + + describe('Web UI', () => { + it('GET / returns HTML page', async () => { + // Arrange + const request = req('GET', '/'); + + // Act + const response = await router(request); + + // Assert + expect(response.statusCode).toBe(200); + expect(response.headers?.['Content-Type']).toBe('text/html; charset=utf-8'); + expect(response.body).toBe(HTML); + }); + + it('GET / with trailing slash still serves web UI', async () => { + // Arrange + const request = req('GET', '///'); + + // Act + const response = await router(request); + + // Assert + expect(response.statusCode).toBe(200); + expect(response.headers?.['Content-Type']).toBe('text/html; charset=utf-8'); + expect(response.body).toBe(HTML); + }); + }); + + describe('Auth', () => { + it('rejects /api route without auth', async () => { + const response = await router(req('GET', '/api/v1/profiles')); + + expect(response.statusCode).toBe(401); + expect(response.headers?.['Content-Type']).toBe('application/json; charset=utf-8'); + expect(response.body).toBe(JSON.stringify({ error: 'Unauthorized' })); + }); + + it('rejects wrong Bearer token', async () => { + const response = await router( + req('GET', '/api/v1/profiles', { headers: { Authorization: 'Bearer wrong' } }) + ); + + expect(response.statusCode).toBe(401); + }); + + it('accepts correct Bearer token', async () => { + const response = await router( + req('GET', '/api/v1/profiles', { headers: { Authorization: `Bearer ${PIN}` } }) + ); + + expect(response.statusCode).toBe(200); + }); + + it('accepts pin in query param', async () => { + const response = await router(req('GET', `/api/v1/profiles?pin=${PIN}`)); + + expect(response.statusCode).toBe(200); + }); + + it('rejects wrong pin in query param', async () => { + const response = await router(req('GET', '/api/v1/profiles?pin=WRONG')); + + expect(response.statusCode).toBe(401); + }); + + it('accepts lowercase authorization header', async () => { + const response = await router( + req('GET', '/api/v1/profiles', { headers: { authorization: `Bearer ${PIN}` } }) + ); + + expect(response.statusCode).toBe(200); + }); + }); + + describe('Route matching', () => { + const auth = { Authorization: `Bearer ${PIN}` }; + + it('GET /api/v1/profiles calls getProfiles', async () => { + const response = await router(req('GET', '/api/v1/profiles', { headers: auth })); + + expect(response.statusCode).toBe(200); + expect(getProfilesMock).toHaveBeenCalledTimes(1); + }); + + it('GET /api/v1/profiles/:profileId/addons calls getProfileAddons', async () => { + const response = await router(req('GET', '/api/v1/profiles/p1/addons', { headers: auth })); + + expect(response.statusCode).toBe(200); + expect(getProfileAddonsMock).toHaveBeenCalledWith('p1'); + }); + + it('POST /api/v1/addons calls installAddon with parsed body', async () => { + const payload = { transportUrl: 'https://example.com/addon' }; + + const response = await router( + req('POST', '/api/v1/addons', { headers: auth, body: JSON.stringify(payload) }) + ); + + expect(response.statusCode).toBe(200); + expect(installAddonMock).toHaveBeenCalledWith(payload); + }); + + it('DELETE /api/v1/addons/:addonId calls deleteAddon', async () => { + const response = await router(req('DELETE', '/api/v1/addons/some-addon', { headers: auth })); + + expect(response.statusCode).toBe(204); + expect(deleteAddonMock).toHaveBeenCalledWith('some-addon'); + }); + + it('PATCH /api/v1/profiles/:profileId/addons/:addonId calls patchProfileAddon', async () => { + const payload = { enabled: true }; + + const response = await router( + req('PATCH', '/api/v1/profiles/p1/addons/addon1', { + headers: auth, + body: JSON.stringify(payload), + }) + ); + + expect(response.statusCode).toBe(200); + expect(patchProfileAddonMock).toHaveBeenCalledWith('p1', 'addon1', payload); + }); + + it('PUT /api/v1/profiles/:profileId/addons/order calls reorderProfileAddons', async () => { + const payload = { addonIds: ['a1', 'a2'] }; + + const response = await router( + req('PUT', '/api/v1/profiles/p1/addons/order', { + headers: auth, + body: JSON.stringify(payload), + }) + ); + + expect(response.statusCode).toBe(200); + expect(reorderProfileAddonsMock).toHaveBeenCalledWith('p1', payload); + }); + + it('unknown route returns 404', async () => { + const response = await router(req('GET', '/api/v1/unknown', { headers: auth })); + + expect(response.statusCode).toBe(404); + expect(response.headers?.['Content-Type']).toBe('application/json; charset=utf-8'); + expect(response.body).toBe(JSON.stringify({ error: 'Not found' })); + }); + }); + + describe('Error handling', () => { + const auth = { Authorization: `Bearer ${PIN}` }; + + it('returns 400 when handler throws SyntaxError', async () => { + installAddonMock.mockRejectedValueOnce(new SyntaxError('invalid json')); + + const response = await router( + req('POST', '/api/v1/addons', { headers: auth, body: JSON.stringify({ a: 1 }) }) + ); + + expect(response.statusCode).toBe(400); + expect(response.headers?.['Content-Type']).toBe('application/json; charset=utf-8'); + expect(response.body).toBe(JSON.stringify({ error: 'Invalid JSON body' })); + }); + + it('returns 500 when handler throws generic error', async () => { + installAddonMock.mockRejectedValueOnce(new Error('boom')); + + const response = await router( + req('POST', '/api/v1/addons', { headers: auth, body: JSON.stringify({ a: 1 }) }) + ); + + expect(response.statusCode).toBe(500); + expect(response.headers?.['Content-Type']).toBe('application/json; charset=utf-8'); + expect(response.body).toBe(JSON.stringify({ error: 'Internal server error' })); + }); + }); + + describe('Response format', () => { + const auth = { Authorization: `Bearer ${PIN}` }; + + it('204 response has empty body', async () => { + const response = await router(req('DELETE', '/api/v1/addons/some-addon', { headers: auth })); + + expect(response.statusCode).toBe(204); + expect(response.body).toBe(''); + expect(response.headers).toEqual({}); + }); + + it('JSON responses have application/json content type', async () => { + const response = await router(req('GET', '/api/v1/profiles', { headers: auth })); + + expect(response.statusCode).toBe(200); + expect(response.headers?.['Content-Type']).toBe('application/json; charset=utf-8'); + expect(response.body).toBe(JSON.stringify({ ok: true })); + }); + }); +}); diff --git a/src/api/local-server/handlers.ts b/src/api/local-server/handlers.ts new file mode 100644 index 0000000..b4e1b95 --- /dev/null +++ b/src/api/local-server/handlers.ts @@ -0,0 +1,162 @@ +import { useAddonStore } from '@/store/addon.store'; +import { type AddonConfig } from '@/types/addon-config'; +import { useProfileStore } from '@/store/profile.store'; +import type { Manifest } from '@/types/stremio'; + +export interface RouteResult { + status: number; + body?: unknown; +} + +interface AddAddonRequestBody { + manifestUrl: string; +} + +interface PatchAddonConfigBody { + isActive?: boolean; + useCatalogsOnHome?: boolean; + useCatalogsInSearch?: boolean; + useForSubtitles?: boolean; +} + +interface ReorderAddonsBody { + orderedIds: string[]; +} + +function badRequest(message: string): RouteResult { + return { status: 400, body: { error: message } }; +} + +export function getProfiles(): RouteResult { + const { profiles } = useProfileStore.getState(); + const data = Object.values(profiles).map((profile) => ({ + id: profile.id, + name: profile.name, + color: profile.avatarColor, + })); + + return { status: 200, body: data }; +} + +export function getProfileAddons(profileId: string): RouteResult { + const { profiles } = useProfileStore.getState(); + if (!profiles[profileId]) { + return { status: 404, body: { error: 'Profile not found' } }; + } + + const addonStore = useAddonStore.getState(); + const addons = addonStore.getOrderedAddonsList(profileId).map((addon) => ({ + id: addon.id, + name: addon.manifest.name, + version: addon.manifest.version, + description: addon.manifest.description, + configurable: addon.manifest.behaviorHints?.configurable ?? false, + manifestUrl: addon.manifestUrl, + config: addonStore.getAddonConfig(addon.id, profileId) ?? { isActive: false, useCatalogsOnHome: true, useCatalogsInSearch: true, useForSubtitles: true }, + })); + + return { status: 200, body: addons }; +} + +export async function installAddon(input: unknown): Promise { + const body = input as AddAddonRequestBody; + if (!body?.manifestUrl || typeof body.manifestUrl !== 'string') { + return badRequest('manifestUrl is required'); + } + + let manifest: Manifest; + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10_000); + const response = await fetch(body.manifestUrl, { + headers: { Accept: 'application/json' }, + signal: controller.signal, + }).finally(() => clearTimeout(timeoutId)); + if (!response.ok) { + return { status: 400, body: { error: `Failed to fetch manifest (${response.status})` } }; + } + manifest = (await response.json()) as Manifest; + } catch { + return { status: 400, body: { error: 'Failed to fetch manifest' } }; + } + + if (!manifest?.id || !manifest?.name || !manifest?.version) { + return badRequest('Invalid manifest: missing required fields'); + } + + useAddonStore.getState().addAddon(manifest.id, body.manifestUrl, manifest); + return { status: 200, body: { id: manifest.id } }; +} + +export function deleteAddon(addonId: string): RouteResult { + useAddonStore.getState().removeAddon(addonId); + return { status: 204 }; +} + +export function patchProfileAddon( + profileId: string, + addonId: string, + input: unknown +): RouteResult { + const { profiles } = useProfileStore.getState(); + if (!profiles[profileId]) { + return { status: 404, body: { error: 'Profile not found' } }; + } + + const body = (input ?? {}) as PatchAddonConfigBody; + const store = useAddonStore.getState(); + + // Build the partial config from explicitly-provided boolean fields. + const updates: Partial = {}; + if (typeof body.isActive === 'boolean') updates.isActive = body.isActive; + if (typeof body.useCatalogsOnHome === 'boolean') updates.useCatalogsOnHome = body.useCatalogsOnHome; + if (typeof body.useCatalogsInSearch === 'boolean') updates.useCatalogsInSearch = body.useCatalogsInSearch; + if (typeof body.useForSubtitles === 'boolean') updates.useForSubtitles = body.useForSubtitles; + + if (Object.keys(updates).length === 0) { + return { status: 200, body: { ok: true } }; + } + + // Deactivation requires an existing config entry. + if (updates.isActive === false) { + const currentConfig = store.getAddonConfig(addonId, profileId); + if (!currentConfig) { + return { status: 404, body: { error: 'Addon config not found' } }; + } + } + + // setAddonConfig upserts the entry with inactive defaults when no config exists, + store.setAddonConfig(addonId, updates, profileId); + + return { status: 200, body: { ok: true } }; +} + +export function reorderProfileAddons(profileId: string, input: unknown): RouteResult { + const { profiles } = useProfileStore.getState(); + if (!profiles[profileId]) { + return { status: 404, body: { error: 'Profile not found' } }; + } + + const body = input as ReorderAddonsBody; + if (!Array.isArray(body?.orderedIds) || !body.orderedIds.every((id) => typeof id === 'string')) { + return badRequest('orderedIds must be an array of strings'); + } + + const store = useAddonStore.getState(); + const currentIds = store.getOrderedAddonsList(profileId).map((addon) => addon.id); + const workingOrder = [...currentIds]; + + for (let targetIndex = 0; targetIndex < body.orderedIds.length; targetIndex += 1) { + const addonId = decodeURIComponent(body.orderedIds[targetIndex]); + const fromIndex = workingOrder.indexOf(addonId); + if (fromIndex === -1) continue; + if (fromIndex === targetIndex) continue; + + store.reorderAddon(fromIndex, targetIndex, profileId); + + workingOrder.splice(fromIndex, 1); + workingOrder.splice(targetIndex, 0, addonId); + } + + return { status: 200, body: { ok: true } }; +} diff --git a/src/api/local-server/router.ts b/src/api/local-server/router.ts new file mode 100644 index 0000000..fa56e92 --- /dev/null +++ b/src/api/local-server/router.ts @@ -0,0 +1,124 @@ +import type { HttpRequest, HttpResponse } from 'react-native-nitro-http-server'; + +import { + deleteAddon, + getProfileAddons, + getProfiles, + installAddon, + patchProfileAddon, + reorderProfileAddons, +} from './handlers'; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function json(statusCode: number, body: unknown): HttpResponse { + return { + statusCode, + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify(body), + }; +} + +function toResponse(result: { status: number; body?: unknown }): HttpResponse { + if (result.status === 204) { + return { statusCode: 204, headers: {}, body: '' }; + } + return json(result.status, result.body ?? {}); +} + +function parseBody(raw: string | undefined): unknown { + if (!raw) return {}; + return JSON.parse(raw) as unknown; +} + +function getAuthPin(req: HttpRequest, rawPath: string): string { + const authHeader = + Object.entries(req.headers).find(([k]) => k.toLowerCase() === 'authorization')?.[1] ?? ''; + if (authHeader.startsWith('Bearer ')) return authHeader.slice(7); + const qs = rawPath.includes('?') ? rawPath.slice(rawPath.indexOf('?') + 1) : ''; + return new URLSearchParams(qs).get('pin') ?? ''; +} + +// ─── Route table ──────────────────────────────────────────────────────────── + +type RouteHandler = ( + params: Record, + req: HttpRequest +) => Promise | HttpResponse; + +interface Route { + method: string; + pattern: RegExp; + paramNames: string[]; + handler: RouteHandler; +} + +function route(method: string, path: string, handler: RouteHandler): Route { + // Convert :param segments to named capture groups + const paramNames: string[] = []; + const regexStr = path.replace(/:([^/]+)/g, (_, name: string) => { + paramNames.push(name); + return '([^/]+)'; + }); + return { method, pattern: new RegExp(`^${regexStr}$`), paramNames, handler }; +} + +const ROUTES: Route[] = [ + route('GET', '/api/v1/profiles', () => toResponse(getProfiles())), + route('GET', '/api/v1/profiles/:profileId/addons', ({ profileId }) => + toResponse(getProfileAddons(decodeURIComponent(profileId))) + ), + route('POST', '/api/v1/addons', (_, req) => installAddon(parseBody(req.body)).then(toResponse)), + route('DELETE', '/api/v1/addons/:addonId', ({ addonId }) => + toResponse(deleteAddon(decodeURIComponent(addonId))) + ), + route('PATCH', '/api/v1/profiles/:profileId/addons/:addonId', ({ profileId, addonId }, req) => + toResponse(patchProfileAddon(decodeURIComponent(profileId), decodeURIComponent(addonId), parseBody(req.body))) + ), + route('PUT', '/api/v1/profiles/:profileId/addons/order', ({ profileId }, req) => + toResponse(reorderProfileAddons(decodeURIComponent(profileId), parseBody(req.body))) + ), +]; + +// ─── Router factory ───────────────────────────────────────────────────────── + +export function createRouter(pin: string, webUiHtml: string) { + return async (req: HttpRequest): Promise => { + const method = req.method.toUpperCase(); + const rawPath = req.path ?? '/'; + const path = rawPath.split('?')[0].replace(/\/+$/, '') || '/'; + + // Serve web UI + if (method === 'GET' && path === '/') { + return { + statusCode: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + body: webUiHtml, + }; + } + + // Auth check for API routes + if (path.startsWith('/api/')) { + if (getAuthPin(req, rawPath) !== pin) { + return json(401, { error: 'Unauthorized' }); + } + } + + // Match route table + for (const { method: m, pattern, paramNames, handler } of ROUTES) { + if (m !== method) continue; + const match = pattern.exec(path); + if (!match) continue; + + const params = Object.fromEntries(paramNames.map((name, i) => [name, match[i + 1]])); + try { + return await handler(params, req); + } catch (err) { + if (err instanceof SyntaxError) return json(400, { error: 'Invalid JSON body' }); + return json(500, { error: 'Internal server error' }); + } + } + + return json(404, { error: 'Not found' }); + }; +} diff --git a/src/api/local-server/server.ts b/src/api/local-server/server.ts new file mode 100644 index 0000000..4242ea9 --- /dev/null +++ b/src/api/local-server/server.ts @@ -0,0 +1,24 @@ +import { HttpServer } from 'react-native-nitro-http-server'; + +import { createRouter } from './router'; +import { WEB_UI_HTML } from './web-ui'; + +const PORT = 7676; + +let serverInstance: HttpServer | null = null; + +export async function startLocalServer(pin: string): Promise { + if (serverInstance) return; + + const router = createRouter(pin, WEB_UI_HTML); + serverInstance = new HttpServer(); + await serverInstance.start(PORT, router, '0.0.0.0'); +} + +export async function stopLocalServer(): Promise { + if (!serverInstance) return; + await serverInstance.stop(); + serverInstance = null; +} + +export const LOCAL_SERVER_PORT = PORT; diff --git a/src/api/stremio/__tests__/hooks.test.ts b/src/api/stremio/__tests__/hooks.test.ts index 85bbae7..49c3829 100644 --- a/src/api/stremio/__tests__/hooks.test.ts +++ b/src/api/stremio/__tests__/hooks.test.ts @@ -17,11 +17,10 @@ jest.mock('../client', () => ({ const mockAddAddon = jest.fn(); const mockGetAddonsList = jest.fn(); jest.mock('@/store/addon.store', () => ({ - DEFAULT_ADDON_CONFIG: { isActive: true }, useAddonStore: jest.fn((selector: any) => selector({ addAddon: mockAddAddon, - configsByProfile: {}, + configsByProfile: { 'profile-1': { 'addon.id': { isActive: true, useCatalogsOnHome: true, useCatalogsInSearch: true, useForSubtitles: true } } }, getAddonsList: mockGetAddonsList, }) ), diff --git a/src/api/stremio/hooks.ts b/src/api/stremio/hooks.ts index 1a39994..207af11 100644 --- a/src/api/stremio/hooks.ts +++ b/src/api/stremio/hooks.ts @@ -14,7 +14,7 @@ import { fetchCatalog, fetchSubtitles, } from './client'; -import { useAddonStore, DEFAULT_ADDON_CONFIG } from '@/store/addon.store'; +import { useAddonStore } from '@/store/addon.store'; import { useProfileStore } from '@/store/profile.store'; import { AddonSubtitle, ContentType, InstalledAddon, MetaPreview, Stream } from '@/types/stremio'; import { useDebugLogger } from '@/utils/debug'; @@ -282,7 +282,7 @@ export function useSearchCatalogs(query: string, enabled: boolean = true) { const searchableCatalogs = addons .filter((addon) => { const config = activeProfileId - ? (configsByProfile[activeProfileId]?.[addon.id] ?? DEFAULT_ADDON_CONFIG) + ? (configsByProfile[activeProfileId]?.[addon.id]) : undefined; return config?.isActive && config?.useCatalogsInSearch; }) @@ -382,7 +382,7 @@ export function useMeta(type: ContentType, id: string, enabled: boolean = true) // Find all activated addons that support this content const compatibleAddons = addons.filter((addon) => { const config = activeProfileId - ? (configsByProfile[activeProfileId]?.[addon.id] ?? DEFAULT_ADDON_CONFIG) + ? (configsByProfile[activeProfileId]?.[addon.id]) : undefined; return config?.isActive && addonSupportsContent(addon, type, id); }); @@ -466,7 +466,7 @@ export function useStreams( // Find all activated addons that support this content and have stream resource const compatibleAddons = addons.filter((addon) => { const config = activeProfileId - ? (configsByProfile[activeProfileId]?.[addon.id] ?? DEFAULT_ADDON_CONFIG) + ? (configsByProfile[activeProfileId]?.[addon.id]) : undefined; if (!config?.isActive) return false; @@ -573,7 +573,7 @@ export function useSubtitles( const compatibleAddons = useMemo(() => { return Object.values(addons).filter((addon) => { const config = activeProfileId - ? (configsByProfile[activeProfileId]?.[addon.id] ?? DEFAULT_ADDON_CONFIG) + ? (configsByProfile[activeProfileId]?.[addon.id]) : undefined; const { manifest } = addon; diff --git a/src/components/basic/AppLogo.tsx b/src/components/basic/AppLogo.tsx index d4cc1dd..146ed30 100644 --- a/src/components/basic/AppLogo.tsx +++ b/src/components/basic/AppLogo.tsx @@ -1,7 +1,8 @@ -import { useTheme } from '@shopify/restyle'; -import { type Theme } from '@/theme/theme'; import { memo } from 'react'; +import { useTheme } from '@shopify/restyle'; import Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg'; +import { type Theme } from '@/theme/theme'; +import { LOGO_PATH_D } from '@/constants/logo-path'; interface AppLogoProps { size: number; @@ -10,9 +11,6 @@ interface AppLogoProps { const GRADIENT_ID = 'appLogoGradient'; -const LOGO_PATH_D = - 'M567.000 188.960 C 538.772 192.590,515.586 203.708,495.150 223.413 C 477.029 240.886,465.839 260.642,459.229 286.833 C 456.809 296.422,456.539 299.268,456.558 315.000 C 456.575 329.360,456.976 334.283,458.795 342.436 C 462.753 360.182,471.354 383.847,481.436 404.734 C 492.848 428.378,496.938 439.773,497.685 450.000 C 498.425 460.139,496.939 465.114,491.564 470.489 C 485.255 476.797,480.417 477.664,460.245 476.099 C 411.154 472.289,364.456 489.176,329.832 523.258 C 315.385 537.479,305.983 550.203,297.311 567.267 C 292.269 577.188,290.879 577.116,293.482 567.069 C 295.719 558.438,301.642 545.186,308.493 533.487 C 318.187 516.935,319.395 514.166,319.817 507.534 C 320.522 496.455,314.217 487.693,301.335 481.852 C 290.725 477.042,283.127 475.722,269.500 476.323 C 256.556 476.893,252.395 477.909,240.519 483.396 C 220.162 492.801,207.665 506.616,200.369 527.779 C 197.291 536.708,197.018 538.478,197.025 549.500 C 197.034 565.853,200.166 578.000,204.375 578.000 C 205.106 578.000,207.171 574.737,208.964 570.750 C 216.567 553.840,220.469 547.519,226.879 541.728 C 233.880 535.403,244.610 531.001,256.750 529.473 C 262.965 528.691,263.865 530.241,258.250 532.059 C 247.494 535.541,237.061 543.537,231.096 552.868 C 222.821 565.815,220.583 583.382,225.317 598.244 C 230.008 612.969,237.736 621.582,253.500 629.652 C 271.776 639.008,273.332 641.351,274.987 662.000 C 276.394 679.570,278.082 688.855,282.541 703.565 C 286.592 716.931,291.957 729.384,298.720 741.122 C 303.947 750.194,316.767 767.514,321.839 772.356 L 324.832 775.213 321.379 764.356 C 315.932 747.226,314.542 737.846,314.584 718.500 C 314.613 704.895,315.056 699.482,316.799 691.396 C 319.749 677.719,323.268 667.310,328.710 656.171 C 339.810 633.448,353.646 618.139,379.000 600.523 C 384.775 596.510,394.214 589.100,399.975 584.056 C 412.304 573.261,421.347 567.113,432.774 561.759 C 440.717 558.037,459.758 552.425,460.826 553.492 C 461.456 554.122,462.107 553.684,452.000 559.425 C 440.023 566.228,432.588 571.681,424.462 579.622 C 415.701 588.184,410.949 594.587,402.995 608.549 C 389.300 632.588,387.332 635.740,382.111 642.000 C 379.129 645.575,372.774 652.550,367.988 657.500 C 358.760 667.044,356.328 671.604,357.404 677.341 C 359.495 688.486,382.465 701.513,411.929 708.262 C 424.888 711.231,454.055 711.452,467.000 708.679 C 493.907 702.917,516.083 690.880,534.205 672.203 C 539.543 666.701,546.013 658.951,548.582 654.979 C 558.548 639.573,566.550 618.677,568.869 602.000 L 569.634 596.500 570.882 600.500 C 571.568 602.700,572.374 610.350,572.673 617.500 C 574.123 652.206,563.676 686.503,543.281 714.000 C 535.977 723.847,518.757 740.824,507.573 749.203 C 502.664 752.881,490.994 760.078,481.641 765.195 C 461.168 776.397,452.490 783.879,449.522 792.888 C 445.072 806.398,452.032 822.024,465.269 828.240 C 477.236 833.860,493.400 835.988,524.000 835.972 C 565.461 835.950,592.767 829.536,629.000 811.308 C 649.495 800.996,666.310 789.289,681.684 774.625 C 701.639 755.593,714.818 738.230,725.612 716.753 C 736.550 694.990,741.820 678.958,745.921 654.964 C 752.530 616.300,747.098 575.997,730.186 538.219 C 719.333 513.976,707.095 497.152,683.673 474.278 C 652.483 443.817,651.413 442.659,649.647 437.478 C 643.719 420.080,655.503 405.980,678.339 403.145 C 689.350 401.778,704.406 403.281,723.000 407.605 C 738.311 411.165,740.617 411.443,755.000 411.471 C 766.911 411.493,772.237 411.065,778.000 409.622 C 782.125 408.589,785.906 407.373,786.403 406.919 C 787.650 405.780,781.821 398.441,775.453 393.134 C 760.041 380.289,738.772 373.529,696.372 367.998 C 658.469 363.055,643.678 359.157,627.937 349.963 C 620.553 345.651,609.000 335.610,609.000 333.504 C 609.000 332.739,612.114 331.456,616.750 330.312 C 621.013 329.261,624.450 327.973,624.389 327.450 C 623.882 323.111,604.928 322.016,596.500 325.839 C 587.056 330.123,581.663 337.646,581.857 346.266 C 582.032 354.036,583.233 354.984,586.291 349.767 C 589.091 344.988,596.208 338.000,598.275 338.000 C 598.973 338.000,603.584 341.938,608.522 346.750 C 618.105 356.089,624.165 360.130,636.753 365.572 C 651.688 372.029,677.671 377.559,704.500 379.990 C 711.100 380.588,720.088 381.785,724.473 382.650 C 738.240 385.365,762.000 394.462,762.000 397.017 C 762.000 398.491,744.929 398.205,734.000 396.548 C 728.775 395.755,720.682 394.191,716.015 393.071 C 693.679 387.713,671.981 388.181,641.000 394.688 C 614.320 400.291,606.761 401.258,594.500 400.634 C 581.481 399.971,575.125 398.388,565.000 393.288 C 551.486 386.480,540.139 372.648,536.999 359.156 C 535.146 351.192,535.530 339.572,537.896 332.000 C 540.273 324.396,543.797 318.588,550.583 311.085 L 555.482 305.670 549.738 306.263 C 541.474 307.116,533.885 310.989,526.310 318.220 L 519.731 324.500 520.289 313.500 C 521.265 294.285,526.827 279.315,538.984 263.182 C 548.383 250.709,560.167 240.963,574.717 233.629 C 580.879 230.523,582.511 230.311,581.460 232.750 C 579.992 236.160,578.195 246.030,578.695 247.940 C 579.106 249.515,580.283 248.516,584.983 242.603 C 604.419 218.144,629.711 213.227,648.538 230.249 C 654.979 236.072,658.044 241.832,661.966 255.480 C 666.654 271.796,671.587 278.961,683.808 287.208 C 696.460 295.745,715.692 302.259,734.790 304.476 C 738.205 304.872,740.994 305.490,740.988 305.848 C 740.981 306.207,739.421 307.507,737.521 308.737 C 733.370 311.426,727.031 319.238,724.118 325.256 C 719.583 334.625,719.139 346.757,723.005 355.666 C 725.288 360.926,727.456 362.247,738.055 364.833 C 756.404 369.310,772.917 378.221,784.535 389.914 C 792.010 397.437,796.370 404.528,800.391 415.701 C 802.101 420.452,804.108 424.541,804.852 424.787 C 806.984 425.493,816.671 414.478,819.902 407.675 C 830.761 384.814,831.827 362.385,823.091 340.569 C 815.585 321.823,802.449 308.110,783.840 299.594 C 773.600 294.908,765.511 293.131,746.745 291.443 C 722.230 289.238,713.511 287.222,700.021 280.638 C 690.590 276.035,681.550 267.958,677.966 260.933 C 676.801 258.650,674.050 251.544,671.852 245.141 C 667.052 231.161,664.724 226.976,657.264 218.919 C 644.890 205.554,623.514 194.496,602.121 190.393 C 592.559 188.559,575.524 187.864,567.000 188.960 M603.500 252.871 C 598.637 255.092,593.547 260.123,590.722 265.500 C 588.005 270.674,587.794 281.948,590.326 286.737 C 594.923 295.430,602.454 300.781,611.378 301.696 C 634.599 304.074,647.987 276.302,632.109 258.690 C 630.124 256.489,626.700 253.868,624.500 252.866 C 619.149 250.429,608.841 250.431,603.500 252.871 M621.401 266.394 C 628.135 269.208,629.897 275.592,625.411 280.923 C 623.323 283.404,622.056 284.000,618.866 284.000 C 607.621 284.000,606.680 267.558,617.782 265.063 C 617.937 265.029,619.566 265.627,621.401 266.394 M673.923 308.923 C 672.205 310.641,671.000 312.908,671.000 314.423 C 671.000 316.953,671.132 317.000,678.250 317.024 C 682.237 317.037,687.188 317.440,689.250 317.919 C 693.646 318.939,693.910 318.208,690.899 313.337 C 688.695 309.770,682.561 306.000,678.963 306.000 C 677.799 306.000,675.531 307.315,673.923 308.923'; - export const AppLogo = memo(({ size, color }: AppLogoProps) => { const theme = useTheme(); diff --git a/src/components/settings/AddonsSettingsContent.tsx b/src/components/settings/AddonsSettingsContent.tsx index b78edaa..35a55e2 100644 --- a/src/components/settings/AddonsSettingsContent.tsx +++ b/src/components/settings/AddonsSettingsContent.tsx @@ -16,6 +16,11 @@ import { InstalledAddon } from '@/types/stremio'; import { showToast } from '@/store/toast.store'; import { SettingsSwitch } from '@/components/settings/SettingsSwitch'; import { Focusable } from '@/components/basic/Focusable'; +import { RemoteControlModal } from '@/components/settings/RemoteControlModal'; +import { useResponsiveLayout } from '@/hooks/useBreakpoint'; +import { createDebugLogger } from '@/utils/debug'; + +const debug = createDebugLogger('AddonsSettings'); export interface AddonsSettingsContentProps { /** Whether to show the install addon section (default: true) */ @@ -34,6 +39,8 @@ export const AddonsSettingsContent: FC = memo( ({ showInstall = true, showInstalled = true, scrollable = true }) => { const { t } = useTranslation(['settings', 'common']); const [manifestUrl, setManifestUrl] = useState(''); + const [isRemoteControlVisible, setIsRemoteControlVisible] = useState(false); + const { isWide } = useResponsiveLayout(); const theme = useTheme(); const activeProfileId = useProfileStore((state) => state.activeProfileId); const { configsByProfile, orderedAddons, storeError, reorderAddon } = useAddonStore( @@ -142,8 +149,20 @@ export const AddonsSettingsContent: FC = memo( [activeAddons, orderedAddons, reorderAddon, activeProfileId] ); + const handleOpenRemoteControl = useCallback(() => { + setIsRemoteControlVisible(true); + }, []); + + const handleCloseRemoteControl = useCallback(() => { + setIsRemoteControlVisible(false); + }, []); + const content = ( + {(isWide || __DEV__) && ( + + )} + {/* Install Addon Section */} {showInstall && ( @@ -230,13 +249,72 @@ export const AddonsSettingsContent: FC = memo( ); if (scrollable) { - return {content}; + return ( + <> + {content} + + + ); } - return content; + return ( + <> + {content} + + + ); } ); +interface RemoteControlShortcutProps { + onPress: () => void; +} + +/** + * Shortcut card that opens the remote control modal. + * Shown on wide layouts and in dev mode. + */ +const RemoteControlShortcut: FC = memo(({ onPress }) => { + const theme = useTheme(); + const { t } = useTranslation('settings'); + + return ( + + {({ isFocused }) => ( + + + + + {t('remoteControl.manage_title')} + + + {t('remoteControl.manage_desc')} + + + + + )} + + ); +}); + interface AddonCardProps { addon: InstalledAddon; onRemove: (id: string, name: string) => void; @@ -283,12 +361,20 @@ const AddonCard: FC = memo( }, [addon.manifestUrl, onConfigure]); const handleToggleActive = useCallback(() => { + debug('AddonCard.handleToggleActive', { + addonId: addon.id, + addonName: addon.manifest.name, + activeProfileId, + currentIsActive: isActive, + action: isActive ? 'deactivate' : 'activate', + hasConfig: !!config, + }); if (isActive) { deactivateAddon(addon.id); } else { activateAddon(addon.id); } - }, [isActive, addon.id, activateAddon, deactivateAddon]); + }, [isActive, addon.id, activateAddon, deactivateAddon, activeProfileId, config, addon.manifest.name]); const handleToggleHome = useCallback(() => { toggleUseCatalogsOnHome(addon.id); diff --git a/src/components/settings/RemoteControlContent.tsx b/src/components/settings/RemoteControlContent.tsx new file mode 100644 index 0000000..9db328c --- /dev/null +++ b/src/components/settings/RemoteControlContent.tsx @@ -0,0 +1,189 @@ +import { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { ActivityIndicator, Platform, ScrollView } from 'react-native'; +import * as Network from 'expo-network'; +import QRCode from 'react-native-qrcode-svg'; +import { Button } from '@/components/basic/Button'; +import { SettingsCard } from '@/components/settings/SettingsCard'; +import { useLocalServerStore } from '@/store/local-server.store'; +import { showToast } from '@/store/toast.store'; +import { createDebugLogger } from '@/utils/debug'; +import { Box, Text } from '@/theme/theme'; +import { useTranslation } from 'react-i18next'; + +const debug = createDebugLogger('RemoteControl'); + +export const RemoteControlContent: FC<{ onStop?: () => void }> = memo(({ onStop }) => { + const isRunning = useLocalServerStore((state) => state.isRunning); + const { t } = useTranslation('settings'); + const pin = useLocalServerStore((state) => state.pin); + const port = useLocalServerStore((state) => state.port); + const startServer = useLocalServerStore((state) => state.startServer); + const stopServer = useLocalServerStore((state) => state.stopServer); + const generatePin = useLocalServerStore((state) => state.generatePin); + + const [localIp, setLocalIp] = useState(''); + const [ipDetectionFailed, setIpDetectionFailed] = useState(false); + + const fetchLocalIp = useCallback(async () => { + try { + const ip = await Promise.race([ + Network.getIpAddressAsync(), + new Promise((resolve) => setTimeout(() => resolve(null), 5_000)), + ]); + if (!ip || ip === '0.0.0.0' || ip === '127.0.0.1') { + debug('fetchLocalIp: unusable IP', ip); + setIpDetectionFailed(true); + setLocalIp(''); + return; + } + setLocalIp(ip); + setIpDetectionFailed(false); + } catch (err) { + debug('fetchLocalIp: error', err); + setIpDetectionFailed(true); + setLocalIp(''); + } + }, []); + + const serverUrl = useMemo(() => { + if (!localIp) return ''; + return `http://${localIp}:${port}`; + }, [localIp, port]); + + const ipHint = useMemo(() => { + if (Platform.OS === 'ios') return t('remoteControl.ip_hint_ios'); + return t('remoteControl.ip_hint_android'); + }, [t]); + + const qrUrl = useMemo(() => { + if (!serverUrl) return ''; + return `${serverUrl}?pin=${pin}`; + }, [serverUrl, pin]); + + const handleRegeneratePin = useCallback(() => { + generatePin(); + showToast({ + title: t('remoteControl.toast.pin_updated_title'), + message: t('remoteControl.toast.pin_updated_msg'), + }); + }, [generatePin, t]); + + useEffect(() => { + let cancelled = false; + + debug('mounting: starting server'); + void (async () => { + try { + await startServer(); + if (cancelled) { + // Server started but component already unmounting — stop it. + debug('startServer completed after unmount, stopping'); + void stopServer(); + } + } catch (err) { + if (!cancelled) { + debug('startServer failed', err); + showToast({ + title: t('remoteControl.toast.server_failed_title'), + message: t('remoteControl.toast.server_failed_msg'), + preset: 'error', + }); + } + } + })(); + + return () => { + debug('unmounting: stopping server'); + cancelled = true; + void stopServer(); + }; + }, [startServer, stopServer, t]); + + const handleStop = useCallback(() => { + void stopServer(); + onStop?.(); + }, [stopServer, onStop]); + + useEffect(() => { + if (!isRunning) return; + void fetchLocalIp(); + }, [fetchLocalIp, isRunning]); + + return ( + + + + {t('remoteControl.description')} + + + {!isRunning && ( + + + + + {t('remoteControl.starting')} + + + + )} + + {isRunning && ( + + + {t('remoteControl.url')} + + {serverUrl || + (ipDetectionFailed + ? t('remoteControl.address_unavailable') + : t('remoteControl.loading_address'))} + + {ipDetectionFailed && !serverUrl && ( + + + {t('remoteControl.ip_fallback', { port })} + + + {ipHint} + + + )} + + + + {t('remoteControl.pin')} + + {pin} + + + + {!!qrUrl && ( + + + + )} + + + +