diff --git a/package-lock.json b/package-lock.json index dcf1a2f..74d14a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "dukang-haoke-official", "version": "1.0.0", "dependencies": { + "peerjs": "^1.5.5", + "qrcode.react": "^4.2.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^7.6.0" @@ -16,6 +18,7 @@ "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", "@vitejs/plugin-react": "^4.4.1", + "sharp": "^0.34.2", "typescript": "~5.8.3", "vite": "^6.3.5" } @@ -302,6 +305,17 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -744,6 +758,496 @@ "node": ">=18" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -794,6 +1298,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@msgpack/msgpack": { + "version": "2.8.0", + "resolved": "https://registry.npmmirror.com/@msgpack/msgpack/-/msgpack-2.8.0.tgz", + "integrity": "sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==", + "license": "ISC", + "engines": { + "node": ">= 10" + } + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -1374,6 +1887,16 @@ } } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.403", "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.403.tgz", @@ -1433,6 +1956,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", @@ -1555,6 +2084,38 @@ "node": ">=18" } }, + "node_modules/peerjs": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/peerjs/-/peerjs-1.5.5.tgz", + "integrity": "sha512-viMUCPDL6CSfOu0ZqVcFqbWRXNHIbv2lPqNbrBIjbFYrflebOjItJ4hPfhjnuUCstqciHVu9vVJ7jFqqKi/EuQ==", + "license": "MIT", + "dependencies": { + "@msgpack/msgpack": "^2.8.0", + "eventemitter3": "^4.0.7", + "peerjs-js-binarypack": "^2.1.0", + "webrtc-adapter": "^9.0.0" + }, + "engines": { + "node": ">= 14" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/peer" + } + }, + "node_modules/peerjs-js-binarypack": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/peerjs-js-binarypack/-/peerjs-js-binarypack-2.1.0.tgz", + "integrity": "sha512-YIwCC+pTzp3Bi8jPI9UFKO0t0SLo6xALnHkiNt/iUFmUUZG0fEEmEyFKvjsDKweiFitzHRyhuh6NvyJZ4nNxMg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/peer" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -1604,6 +2165,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmmirror.com/react/-/react-19.2.8.tgz", @@ -1725,6 +2295,12 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/sdp": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/sdp/-/sdp-3.2.2.tgz", + "integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==", + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", @@ -1741,6 +2317,64 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1768,6 +2402,14 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.8.3.tgz", @@ -1888,6 +2530,19 @@ } } }, + "node_modules/webrtc-adapter": { + "version": "9.0.6", + "resolved": "https://registry.npmmirror.com/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz", + "integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==", + "license": "BSD-3-Clause", + "dependencies": { + "sdp": "^3.2.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 27ea37a..008f65c 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,12 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "optimize:stage": "node scripts/optimize-stage-images.mjs" }, "dependencies": { + "peerjs": "^1.5.5", + "qrcode.react": "^4.2.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^7.6.0" @@ -17,6 +20,7 @@ "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", "@vitejs/plugin-react": "^4.4.1", + "sharp": "^0.34.2", "typescript": "~5.8.3", "vite": "^6.3.5" } diff --git a/public/assets/stage/full/emb_p33_1.webp b/public/assets/stage/full/emb_p33_1.webp new file mode 100644 index 0000000..8cc0423 Binary files /dev/null and b/public/assets/stage/full/emb_p33_1.webp differ diff --git a/public/assets/stage/full/emb_p6_5.webp b/public/assets/stage/full/emb_p6_5.webp new file mode 100644 index 0000000..b183e5c Binary files /dev/null and b/public/assets/stage/full/emb_p6_5.webp differ diff --git a/public/assets/stage/full/logo.webp b/public/assets/stage/full/logo.webp new file mode 100644 index 0000000..7cd404d Binary files /dev/null and b/public/assets/stage/full/logo.webp differ diff --git a/public/assets/stage/full/qr-contact.webp b/public/assets/stage/full/qr-contact.webp new file mode 100644 index 0000000..be305e5 Binary files /dev/null and b/public/assets/stage/full/qr-contact.webp differ diff --git a/public/assets/stage/full/t30-bottle.webp b/public/assets/stage/full/t30-bottle.webp new file mode 100644 index 0000000..5db50b2 Binary files /dev/null and b/public/assets/stage/full/t30-bottle.webp differ diff --git a/public/assets/stage/thumb/emb_p33_1.webp b/public/assets/stage/thumb/emb_p33_1.webp new file mode 100644 index 0000000..a33946a Binary files /dev/null and b/public/assets/stage/thumb/emb_p33_1.webp differ diff --git a/public/assets/stage/thumb/emb_p6_5.webp b/public/assets/stage/thumb/emb_p6_5.webp new file mode 100644 index 0000000..ac6f4e5 Binary files /dev/null and b/public/assets/stage/thumb/emb_p6_5.webp differ diff --git a/public/assets/stage/thumb/logo.webp b/public/assets/stage/thumb/logo.webp new file mode 100644 index 0000000..8a1e772 Binary files /dev/null and b/public/assets/stage/thumb/logo.webp differ diff --git a/public/assets/stage/thumb/qr-contact.webp b/public/assets/stage/thumb/qr-contact.webp new file mode 100644 index 0000000..1b62f1e Binary files /dev/null and b/public/assets/stage/thumb/qr-contact.webp differ diff --git a/public/assets/stage/thumb/t30-bottle.webp b/public/assets/stage/thumb/t30-bottle.webp new file mode 100644 index 0000000..83eb9fe Binary files /dev/null and b/public/assets/stage/thumb/t30-bottle.webp differ diff --git a/scripts/optimize-stage-images.mjs b/scripts/optimize-stage-images.mjs new file mode 100644 index 0000000..edeeda4 --- /dev/null +++ b/scripts/optimize-stage-images.mjs @@ -0,0 +1,73 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import sharp from 'sharp' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const root = path.resolve(__dirname, '..') +const outThumb = path.join(root, 'public/assets/stage/thumb') +const outFull = path.join(root, 'public/assets/stage/full') + +/** @type {{ src: string; name: string; thumbW: number; fullW: number }[]} */ +const STAGE_SOURCES = [ + { src: 'public/assets/logo.png', name: 'logo', thumbW: 48, fullW: 512 }, + { src: 'public/assets/qr-contact.png', name: 'qr-contact', thumbW: 48, fullW: 512 }, + { src: 'public/assets/t30-bottle.png', name: 't30-bottle', thumbW: 64, fullW: 800 }, + { src: 'public/assets/brand/emb_p6_5.png', name: 'emb_p6_5', thumbW: 64, fullW: 960 }, + { src: 'public/assets/brand/emb_p33_1.png', name: 'emb_p33_1', thumbW: 64, fullW: 960 }, +] + +async function ensureDirs() { + await fs.mkdir(outThumb, { recursive: true }) + await fs.mkdir(outFull, { recursive: true }) +} + +async function writeThumb(srcPath, name, width) { + const dest = path.join(outThumb, `${name}.webp`) + await sharp(srcPath) + .resize(width, null, { withoutEnlargement: true }) + .blur(2) + .webp({ quality: 50 }) + .toFile(dest) + const stat = await fs.stat(dest) + return { dest, kb: Math.round(stat.size / 1024 * 10) / 10 } +} + +async function writeFull(srcPath, name, width) { + const dest = path.join(outFull, `${name}.webp`) + await sharp(srcPath) + .resize(width, null, { withoutEnlargement: true }) + .webp({ quality: 82 }) + .toFile(dest) + const stat = await fs.stat(dest) + return { dest, kb: Math.round(stat.size / 1024 * 10) / 10 } +} + +async function main() { + await ensureDirs() + let thumbTotal = 0 + let fullTotal = 0 + + for (const item of STAGE_SOURCES) { + const srcPath = path.join(root, item.src) + try { + await fs.access(srcPath) + } catch { + console.warn(`skip (missing): ${item.src}`) + continue + } + + const thumb = await writeThumb(srcPath, item.name, item.thumbW) + const full = await writeFull(srcPath, item.name, item.fullW) + thumbTotal += thumb.kb + fullTotal += full.kb + console.log(`${item.name}: thumb ${thumb.kb} KB, full ${full.kb} KB`) + } + + console.log(`\nDone. thumb total ~${Math.round(thumbTotal)} KB, full total ~${Math.round(fullTotal)} KB`) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 3daf9ae..a6c87cc 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,9 +1,10 @@ import { useEffect } from 'react' import { Outlet, useLocation } from 'react-router-dom' import { useIsMobile } from '../hooks/useMobile' -import { StageProvider, useStageMode } from '../hooks/useStageMode' +import { StageProvider, useRemoteMode, useStageMode } from '../hooks/useStageMode' import { MobileShell } from '../mobile/MobileShell' import { MobileViews } from '../mobile/MobileViews' +import { RemoteShell } from '../stage/remote/RemoteShell' import { StageShell } from '../stage/StageShell' import { Footer } from './Footer' import { Nav } from './Nav' @@ -14,41 +15,45 @@ const techRoutes = new Set(['/tech', '/subsidy', '/partner', '/tech-detail', '/c function LayoutInner() { const location = useLocation() const isStage = useStageMode() + const isRemote = useRemoteMode() const isMobile = useIsMobile(768) const techLike = techRoutes.has(location.pathname) useEffect(() => { + if (isMobile) return window.scrollTo({ top: 0, behavior: 'auto' }) - }, [location.pathname]) + }, [location.pathname, isMobile]) useEffect(() => { - document.documentElement.classList.toggle('stage-mode', isStage) - document.body.classList.toggle('stage-mode', isStage) + const stageOn = isStage && !isMobile + document.documentElement.classList.toggle('stage-mode', stageOn) + document.body.classList.toggle('stage-mode', stageOn) + document.documentElement.classList.toggle('remote-mode', isRemote) + document.body.classList.toggle('remote-mode', isRemote) return () => { document.documentElement.classList.remove('stage-mode') document.body.classList.remove('stage-mode') + document.documentElement.classList.remove('remote-mode') + document.body.classList.remove('remote-mode') } - }, [isStage]) + }, [isStage, isRemote, isMobile]) - // 1. Stage Mode Branch - if (isStage) { - return + if (isRemote) { + return } - // 2. Mobile Dedicated Layout Branch (< 768px) if (isMobile) { - // If accessing deep pages (e.g. /culture/status), render through Outlet; main modules render through MobileViews - const isDeepPage = - location.pathname.startsWith('/culture/') || - location.pathname.startsWith('/poetry/') || - location.pathname.startsWith('/qingxiang/') return ( - {isDeepPage ? : } + ) } + if (isStage) { + return + } + // 3. Desktop Normal Mode Branch return (
diff --git a/src/hooks/useStageMode.tsx b/src/hooks/useStageMode.tsx index 634633b..8dea59a 100644 --- a/src/hooks/useStageMode.tsx +++ b/src/hooks/useStageMode.tsx @@ -12,3 +12,8 @@ export function StageProvider({ children }: { children: ReactNode }) { export function useStageMode() { return useContext(StageContext) } + +export function useRemoteMode() { + const location = useLocation() + return useMemo(() => new URLSearchParams(location.search).get('mode') === 'remote', [location.search]) +} diff --git a/src/mobile/MobileDeepSection.tsx b/src/mobile/MobileDeepSection.tsx new file mode 100644 index 0000000..e197e94 --- /dev/null +++ b/src/mobile/MobileDeepSection.tsx @@ -0,0 +1,61 @@ +import type { DeepPageContent } from '../content/deep/types' + +export function pathToSectionId(path: string) { + return `m${path.replace(/\//g, '-')}` +} + +export function MobileDeepSection({ page }: { page: DeepPageContent }) { + return ( +
+
+
{page.eyebrow}
+

{page.title}

+

{page.lead}

+
+ + {page.images[0] ? ( + + ) : null} + +
+ {page.blocks.map((block, i) => { + if (block.type === 'heading') { + return ( +

+ {block.text} +

+ ) + } + if (block.type === 'paragraph') { + return ( +

+ {block.text} +

+ ) + } + if (block.type === 'quote') { + return ( +
+

{block.text}

+ {block.source ?
—— {block.source}
: null} +
+ ) + } + return ( +
    + {block.items.map((item) => ( +
  • {item}
  • + ))} +
+ ) + })} +
+ + {page.images.slice(1).map((src) => ( + + ))} + + {page.closing ?
{page.closing}
: null} +
+ ) +} diff --git a/src/mobile/MobileShell.css b/src/mobile/MobileShell.css index a50e527..4297e42 100644 --- a/src/mobile/MobileShell.css +++ b/src/mobile/MobileShell.css @@ -11,227 +11,82 @@ overflow-x: hidden; } -/* Fixed Header with Status Bar Safe Area Top */ .m-header { position: fixed; top: 0; left: 0; right: 0; - height: calc(3.6rem + env(safe-area-inset-top, 0px)); + min-height: calc(5.6rem + env(safe-area-inset-top, 0px)); padding-top: env(safe-area-inset-top, 0px); - padding-left: 1rem; - padding-right: 1rem; + padding-left: 0.7rem; + padding-right: 0.7rem; + padding-bottom: 0.35rem; box-sizing: border-box; z-index: 1000; - background: rgba(247, 242, 234, 0.95); + background: rgba(247, 242, 234, 0.96); backdrop-filter: blur(12px); border-bottom: 1px solid rgba(166, 28, 32, 0.12); display: flex; - align-items: center; - justify-content: space-between; - transition: background 0.3s ease; -} - -.theme-tech .m-header { - background: rgba(18, 16, 20, 0.95); - border-bottom-color: rgba(196, 163, 90, 0.2); + flex-direction: column; + justify-content: center; + gap: 0.25rem; } .m-brand { display: flex; align-items: center; - gap: 0.5rem; + gap: 0.45rem; text-decoration: none; + flex-shrink: 0; } .m-logo { - width: 2rem; - height: 2rem; + width: 1.75rem; + height: 1.75rem; object-fit: contain; } .m-title { font-family: 'Noto Serif SC', serif; - font-size: 1.1rem; + font-size: 1rem; font-weight: 600; color: var(--brand, #a61c20); letter-spacing: 0.05em; } -.theme-tech .m-title { - color: var(--gold, #c4a35a); -} - -.m-header-actions { - display: flex; - align-items: center; - gap: 0.6rem; -} - -.m-stage-badge { - padding: 0.25rem 0.6rem; - border-radius: 1rem; - font-size: 0.72rem; - color: #ffffff; - background: linear-gradient(135deg, #a61c20 0%, #7d1216 100%); - border: 1px solid rgba(196, 163, 90, 0.3); - text-decoration: none; - font-weight: 500; - box-shadow: 0 2px 8px rgba(166, 28, 32, 0.25); -} - -.m-menu-btn { - width: 2.5rem; - height: 2.5rem; - border: none; - background: transparent; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - gap: 0.32rem; - cursor: pointer; - z-index: 1002; - padding: 0; -} - -.m-menu-btn span { - width: 1.3rem; - height: 2px; - background: var(--brand, #a61c20); - border-radius: 1px; - transition: all 0.3s ease; -} - -.theme-tech .m-menu-btn span { - background: var(--gold, #c4a35a); -} - -.m-menu-btn.is-open span:nth-child(1) { - transform: translateY(6px) rotate(45deg); -} - -.m-menu-btn.is-open span:nth-child(2) { - opacity: 0; -} - -.m-menu-btn.is-open span:nth-child(3) { - transform: translateY(-6px) rotate(-45deg); -} - -/* Drawer Overlay & Panel */ -.m-drawer-overlay { - position: fixed; - inset: 0; - z-index: 1000; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(4px); - opacity: 0; - visibility: hidden; - transition: all 0.3s ease; -} - -.m-drawer-overlay.is-open { - opacity: 1; - visibility: visible; -} - -.m-drawer { - position: fixed; - top: calc(3.6rem + env(safe-area-inset-top, 0px)); - right: 0; - bottom: 0; - width: min(20rem, 85vw); - background: #f7f2ea; - z-index: 1001; - padding: 1.25rem 1.25rem calc(2rem + env(safe-area-inset-bottom, 0px)); - transform: translateX(100%); - transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1); - overflow-y: auto; - -webkit-overflow-scrolling: touch; - box-shadow: -10px 0 30px rgba(0, 0, 0, 0.2); -} - -.theme-tech .m-drawer { - background: #141117; - color: #f3ece4; -} - -.m-drawer.is-open { - transform: translateX(0); -} - -.m-drawer-section { - margin-bottom: 1.25rem; -} - -.m-drawer-sec-title { - font-size: 0.78rem; - letter-spacing: 0.15em; - color: var(--gold, #c4a35a); - margin: 0 0 0.5rem; - padding-bottom: 0.3rem; - border-bottom: 1px solid rgba(196, 163, 90, 0.2); -} - -.m-drawer-item { +.m-header-nav { display: flex; align-items: center; justify-content: space-between; - padding: 0.7rem 0.5rem; - font-size: 0.95rem; - color: var(--ink, #1a1512); + gap: 0.15rem; + width: 100%; +} + +.m-nav-link { + flex: 1; + min-height: 2rem; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.2rem 0.1rem; + font-size: 0.72rem; + line-height: 1.2; + text-align: center; + color: var(--ink-soft, #5c534c); text-decoration: none; - border-bottom: 1px solid rgba(166, 28, 32, 0.06); + white-space: nowrap; border-radius: 0.2rem; - transition: background 0.2s ease; } -.theme-tech .m-drawer-item { - color: #f3ece4; - border-bottom-color: rgba(196, 163, 90, 0.08); -} - -.m-drawer-item.is-active { +.m-nav-link.is-active { color: var(--brand, #a61c20); font-weight: 600; background: rgba(166, 28, 32, 0.08); } -.theme-tech .m-drawer-item.is-active { - color: var(--gold, #c4a35a); - background: rgba(196, 163, 90, 0.12); -} - -.m-drawer-sublist { - padding-left: 0.85rem; - display: grid; - gap: 0.2rem; - margin-top: 0.2rem; -} - -.m-drawer-subitem { - display: block; - padding: 0.5rem 0.5rem; - font-size: 0.86rem; - color: var(--ink-soft, #5c534c); - text-decoration: none; - border-radius: 0.2rem; -} - -.theme-tech .m-drawer-subitem { - color: rgba(243, 236, 228, 0.7); -} - -.m-drawer-subitem.is-active { - color: var(--brand, #a61c20); - font-weight: 600; -} - -/* Main Content Wrapper - Push down content to avoid Fixed Header overlap */ .m-main { flex: 1; width: 100%; - padding-top: calc(3.6rem + env(safe-area-inset-top, 0px)); + padding-top: calc(5.6rem + env(safe-area-inset-top, 0px)); box-sizing: border-box; } diff --git a/src/mobile/MobileShell.tsx b/src/mobile/MobileShell.tsx index b67827f..4f19999 100644 --- a/src/mobile/MobileShell.tsx +++ b/src/mobile/MobileShell.tsx @@ -1,99 +1,55 @@ -import { useEffect, useState, type ReactNode } from 'react' -import { Link, NavLink, Outlet, useLocation } from 'react-router-dom' -import { navGroups } from '../content/nav' +import { type ReactNode } from 'react' +import { Link, Outlet, useLocation } from 'react-router-dom' import { images } from '../content/site' import { Footer } from '../components/Footer' import './MobileShell.css' -const techRoutes = new Set(['/tech', '/subsidy', '/partner', '/tech-detail', '/contact']) +const mobileNav = [ + { id: 'm-home', label: '首页' }, + { id: 'm-culture', label: '酒祖文化' }, + { id: 'm-qingxiang', label: '清香臻品' }, + { id: 'm-haoke', label: '好客科技' }, + { id: 'm-contact', label: '合作联系' }, +] as const + +function scrollToSection(id: string) { + const el = document.getElementById(id) + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } +} export function MobileShell({ children }: { children?: ReactNode }) { const location = useLocation() - const [drawerOpen, setDrawerOpen] = useState(false) - const isTechPage = techRoutes.has(location.pathname) + const activeId = location.hash.replace('#', '') || 'm-home' - // Close drawer on navigation - useEffect(() => { - setDrawerOpen(false) - window.scrollTo({ top: 0, behavior: 'auto' }) - }, [location.pathname, location.search]) - - // Prevent background scroll when drawer is open - useEffect(() => { - document.body.style.overflow = drawerOpen ? 'hidden' : '' - return () => { - document.body.style.overflow = '' - } - }, [drawerOpen]) + const goSection = (id: string) => { + requestAnimationFrame(() => scrollToSection(id)) + } return ( -
- {/* Mobile Top Header */} +
- + goSection('m-home')}> 杜康好客 杜康好客 -
- - 大屏展控 - - -
+
- {/* Drawer Mask & Navigation Panel */} -
setDrawerOpen(false)} - /> -
-
- 全站导航目录 -
- -
- setDrawerOpen(false)} - > - 首页 - - -
- - {navGroups.map((group) => ( -
-
{group.label}
-
- {group.children.map((child) => ( - `m-drawer-subitem ${isActive ? 'is-active' : ''}`} - onClick={() => setDrawerOpen(false)} - > - {child.label} - - ))} -
-
- ))} -
- - {/* Main Content Area */} -
+
{children || }
diff --git a/src/mobile/MobileViews.css b/src/mobile/MobileViews.css index b402d15..d9c2c03 100644 --- a/src/mobile/MobileViews.css +++ b/src/mobile/MobileViews.css @@ -1,5 +1,58 @@ /* ===== Mobile Views Dedicated CSS ===== */ +.m-longpage { + width: 100%; +} + +.m-section { + scroll-margin-top: calc(5.6rem + env(safe-area-inset-top, 0px) + 0.4rem); +} + +.m-section + .m-section { + border-top: 1px solid rgba(166, 28, 32, 0.1); +} + +.m-join-label { + margin: 0; + padding: 1.1rem 1rem 0; + font-size: 0.75rem; + letter-spacing: 0.2em; + color: var(--gold, #c4a35a); +} + +.m-section-tech { + background: var(--tech-bg, #121014); + color: #f3ece4; +} + +.m-section-tech + .m-section-tech { + border-top-color: rgba(196, 163, 90, 0.18); +} + +.m-section-tech .m-join-label, +.m-section-tech .m-eyebrow, +.m-section-tech .m-page-title { + color: var(--gold, #c4a35a); +} + +.m-section-tech .m-lead, +.m-section-tech .m-card p, +.m-section-tech .m-poem-source { + color: rgba(243, 236, 228, 0.75); +} + +.m-section-tech .m-card { + background: rgba(28, 25, 31, 0.85); + border-color: rgba(196, 163, 90, 0.22); + color: #f3ece4; +} + +.m-section-tech .m-card h3, +.m-section-tech .m-big-num, +.m-section-tech .m-closing { + color: var(--gold, #c4a35a); +} + .m-view-container { width: 100%; padding: 1.25rem 1rem 2.5rem; @@ -262,31 +315,38 @@ object-fit: contain; } -.m-deep-links { - margin-top: 1.25rem; - padding-top: 1rem; - border-top: 1px solid rgba(166, 28, 32, 0.12); +.m-deep-blocks { display: grid; - gap: 0.65rem; + gap: 0.85rem; } -.m-deep-card { - padding: 0.85rem 0.95rem; - background: rgba(255, 255, 255, 0.6); - border-left: 3px solid var(--gold, #c4a35a); - border-radius: 0.2rem; - text-decoration: none; - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.m-deep-card strong { - font-size: 0.95rem; +.m-deep-h { + margin: 0.4rem 0 0; + font-family: 'Noto Serif SC', serif; + font-size: 1.1rem; color: var(--brand, #a61c20); } -.m-deep-card span { - font-size: 0.82rem; +.m-deep-p { + margin: 0; + font-size: 0.92rem; + line-height: 1.7; color: var(--ink-soft, #5c534c); } + +.m-deep-list { + margin: 0; + padding-left: 1.15rem; + display: grid; + gap: 0.35rem; + color: var(--ink-soft, #5c534c); + font-size: 0.9rem; + line-height: 1.6; +} + +.m-deep-img { + width: 100%; + height: auto; + border-radius: 0.35rem; + object-fit: contain; +} diff --git a/src/mobile/MobileViews.tsx b/src/mobile/MobileViews.tsx index 5ea4c72..f8a9541 100644 --- a/src/mobile/MobileViews.tsx +++ b/src/mobile/MobileViews.tsx @@ -1,4 +1,6 @@ -import { Link, Outlet, useLocation } from 'react-router-dom' +import { useEffect } from 'react' +import { useLocation } from 'react-router-dom' +import { allDeepPages } from '../content/nav' import { brand, contact, @@ -13,41 +15,109 @@ import { tech, techDetail, } from '../content/site' +import { MobileDeepSection, pathToSectionId } from './MobileDeepSection' import './MobileViews.css' +const cultureDeep = allDeepPages.filter((p) => p.group === 'culture') +const poetryDeep = allDeepPages.filter((p) => p.group === 'poetry') +const qingxiangDeep = allDeepPages.filter((p) => p.group === 'qingxiang') + +const PATH_TO_SECTION: Record = { + '/': 'm-home', + '/brand': 'm-culture', + '/poetry': 'm-poetry', + '/qingxiang': 'm-qingxiang', + '/haoke': 'm-haoke', + '/system': 'm-system', + '/tech': 'm-tech', + '/subsidy': 'm-subsidy', + '/partner': 'm-partner', + '/tech-detail': 'm-tech-detail', + '/contact': 'm-contact', + ...Object.fromEntries(allDeepPages.map((p) => [p.path, pathToSectionId(p.path)])), +} + export function MobileViews() { const location = useLocation() - const path = location.pathname - switch (path) { - case '/': - return - case '/brand': - return - case '/poetry': - return - case '/qingxiang': - return - case '/haoke': - return - case '/system': - return - case '/tech': - return - case '/subsidy': - return - case '/partner': - return - case '/tech-detail': - return - case '/contact': - return - default: - return - } + useEffect(() => { + const fromHash = location.hash.replace('#', '') + const id = fromHash || PATH_TO_SECTION[location.pathname] || 'm-home' + const timer = window.setTimeout(() => { + const el = document.getElementById(id) + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, 40) + return () => window.clearTimeout(timer) + }, [location.pathname, location.hash]) + + return ( +
+
+ +
+ +
+

酒祖文化

+ +
+ {cultureDeep.map((page) => ( +
+ +
+ ))} + +
+ +
+ {poetryDeep.map((page) => ( +
+ +
+ ))} + +
+

清香臻品

+ +
+ {qingxiangDeep.map((page) => ( +
+ +
+ ))} + +
+

好客科技

+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+

合作联系

+ +
+ +
+ +
+
+ ) } -/* 1. Mobile Home View */ function MobileHomeView() { return (
@@ -62,19 +132,18 @@ function MobileHomeView() { {hero.subtitle}

) } -/* 2. Mobile Brand View */ function MobileBrandView() { return (
@@ -129,21 +198,10 @@ function MobileBrandView() {
{brand.closing}
- -
-
深度阅读
- {brand.deepReads.map((dp) => ( - - {dp.label} › - {dp.blurb} - - ))} -
) } -/* 3. Mobile Poetry View */ function MobilePoetryView() { return (
@@ -168,21 +226,10 @@ function MobilePoetryView() {
{poetry.closing}
- -
-
诗酒专题
- {poetry.deepReads.map((dp) => ( - - {dp.label} › - {dp.blurb} - - ))} -
) } -/* 4. Mobile Qingxiang View */ function MobileQingxiangView() { return (
@@ -225,21 +272,10 @@ function MobileQingxiangView() {
{qingxiang.mission}
- -
-
清香宝典专题
- {qingxiang.deepReads.map((dp) => ( - - {dp.label} › - {dp.blurb} - - ))} -
) } -/* 5. Mobile Haoke View */ function MobileHaokeView() { return (
@@ -270,7 +306,6 @@ function MobileHaokeView() { ) } -/* 6. Mobile System View */ function MobileSystemView() { return (
@@ -309,7 +344,6 @@ function MobileSystemView() { ) } -/* 7. Mobile Tech View */ function MobileTechView() { return (
@@ -329,17 +363,10 @@ function MobileTechView() {
{tech.claim}
- -
- - {tech.cta.label} - -
) } -/* 8. Mobile Subsidy View */ function MobileSubsidyView() { return (
@@ -372,7 +399,6 @@ function MobileSubsidyView() { ) } -/* 9. Mobile Partner View */ function MobilePartnerView() { return (
@@ -392,18 +418,17 @@ function MobilePartnerView() {
) } -/* 10. Mobile Tech Detail View */ function MobileTechDetailView() { return (
@@ -427,7 +452,6 @@ function MobileTechDetailView() { ) } -/* 11. Mobile Contact View */ function MobileContactView() { return (
@@ -456,7 +480,7 @@ function MobileContactView() { textDecoration: 'none', }} > - 📞 {contact.phone} + {contact.phone}

{contact.wechatHint} diff --git a/src/pages/Home.css b/src/pages/Home.css index 75cc63c..61bffcd 100644 --- a/src/pages/Home.css +++ b/src/pages/Home.css @@ -80,11 +80,24 @@ } .module-rail { + overflow: hidden; + padding-bottom: 0.75rem; +} + +.module-rail-track { + display: flex; + width: max-content; + animation: moduleMarquee 40s linear infinite; +} + +.module-rail:hover .module-rail-track { + animation-play-state: paused; +} + +.module-rail-copy { display: flex; gap: 1rem; - overflow-x: auto; - padding-bottom: 0.75rem; - scroll-snap-type: x mandatory; + padding-right: 1rem; } .module-rail-item { @@ -94,13 +107,27 @@ padding: 1.2rem 1.1rem; border-left: 2px solid var(--brand); background: rgba(255, 255, 255, 0.55); - scroll-snap-align: start; display: flex; flex-direction: column; gap: 0.4rem; transition: transform 0.35s var(--ease), background 0.35s var(--ease); } +@keyframes moduleMarquee { + from { + transform: translateX(0); + } + to { + transform: translateX(-50%); + } +} + +@media (prefers-reduced-motion: reduce) { + .module-rail-track { + animation: none; + } +} + .module-rail-item:hover { transform: translateY(-0.25rem); background: #fff; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index cb5d86a..60a69c7 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -44,15 +44,25 @@ export function HomePage() {

从酒祖文脉到好客闭环,循定稿结构逐章走进。

- {moduleEntries.map((item, index) => ( - - - {String(index + 1).padStart(2, '0')} - {item.label} - {item.desc} - - - ))} +
+ {[0, 1].map((copy) => ( +
+ {moduleEntries.map((item, index) => ( + + {String(index + 1).padStart(2, '0')} + {item.label} + {item.desc} + + ))} +
+ ))} +
diff --git a/src/stage/StageProgressiveImage.tsx b/src/stage/StageProgressiveImage.tsx new file mode 100644 index 0000000..fa6ef08 --- /dev/null +++ b/src/stage/StageProgressiveImage.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react' +import type { StageImagePair } from './stageAssets' + +interface StageProgressiveImageProps { + pair: StageImagePair + alt: string + className?: string + priority?: boolean +} + +export function StageProgressiveImage({ pair, alt, className, priority }: StageProgressiveImageProps) { + const [fullLoaded, setFullLoaded] = useState(false) + + useEffect(() => { + setFullLoaded(false) + const img = new Image() + img.src = pair.full + if (img.complete) { + setFullLoaded(true) + return + } + const onLoad = () => setFullLoaded(true) + img.addEventListener('load', onLoad) + return () => img.removeEventListener('load', onLoad) + }, [pair.full]) + + const shellClass = ['stage-prog-img', fullLoaded ? 'is-full-loaded' : '', className ?? ''] + .filter(Boolean) + .join(' ') + + return ( +
+ + {alt} setFullLoaded(true)} + /> +
+ ) +} diff --git a/src/stage/StageShell.css b/src/stage/StageShell.css index 9153150..c47a2fd 100644 --- a/src/stage/StageShell.css +++ b/src/stage/StageShell.css @@ -42,6 +42,9 @@ display: flex; align-items: center; justify-content: space-between; + flex-wrap: nowrap; + gap: 0.75rem; + overflow: hidden; z-index: 50; background: linear-gradient(180deg, rgba(14, 12, 16, 0.95) 0%, rgba(14, 12, 16, 0) 100%); border-bottom: 1px solid rgba(196, 163, 90, 0.15); @@ -53,11 +56,31 @@ align-items: center; gap: 0.85rem; text-decoration: none; + flex-shrink: 1; + min-width: 0; + white-space: nowrap; +} + +.stage-logo, +.stage-logo-prog { + width: 2.4rem; + height: 2.4rem; + flex-shrink: 0; +} + +.stage-logo-prog .stage-prog-img__thumb, +.stage-logo-prog .stage-prog-img__full { + width: 2.4rem; + height: 2.4rem; + object-fit: contain; + filter: drop-shadow(0 0 8px rgba(196, 163, 90, 0.4)); +} + +.stage-logo-prog .stage-prog-img__thumb { + filter: blur(4px) drop-shadow(0 0 8px rgba(196, 163, 90, 0.4)); } .stage-logo { - width: 2.4rem; - height: 2.4rem; object-fit: contain; filter: drop-shadow(0 0 8px rgba(196, 163, 90, 0.4)); } @@ -92,6 +115,9 @@ padding: 0.3rem 0.5rem; border-radius: 2rem; border: 1px solid rgba(196, 163, 90, 0.2); + flex-shrink: 1; + min-width: 0; + overflow: hidden; } .stage-nav-pill { @@ -120,9 +146,18 @@ .stage-hud-actions { display: flex; align-items: center; + flex-wrap: nowrap; + flex-shrink: 0; gap: 0.85rem; } +.stage-autoplay-btn, +.stage-remote-toggle, +.stage-exit-btn { + white-space: nowrap; + flex-shrink: 0; +} + .stage-autoplay-btn { display: inline-flex; align-items: center; @@ -235,7 +270,7 @@ background: linear-gradient(90deg, var(--brand, #a61c20), var(--gold, #c4a35a)); border-radius: 0.25rem; transform-origin: left; - animation: stageProgressBar 6s linear forwards; + animation: stageProgressBar var(--stage-progress-dur, 6s) linear forwards; } @keyframes stageProgressBar { @@ -254,17 +289,29 @@ } /* Mobile Responsive Adjustments for Stage Shell */ +@media (max-width: 72rem) { + .stage-nav-pills { + display: none; + } +} + @media (max-width: 48rem) { .stage-hud-top { - padding-left: 1rem; - padding-right: 1rem; + padding-left: 0.75rem; + padding-right: 0.75rem; height: calc(3.6rem + env(safe-area-inset-top, 0px)); } - .stage-nav-pills { + .stage-title, + .stage-badge, + .stage-index-num { display: none; } + .stage-hud-actions { + gap: 0.4rem; + } + .stage-viewport { padding-top: calc(3.6rem + env(safe-area-inset-top, 0px)); padding-left: 1rem; @@ -275,6 +322,10 @@ .stage-exit-btn span:first-child { display: none; } + + .stage-autoplay-btn span:last-child { + display: none; + } } /* Animations */ diff --git a/src/stage/StageShell.tsx b/src/stage/StageShell.tsx index 40bf1f4..60dbc11 100644 --- a/src/stage/StageShell.tsx +++ b/src/stage/StageShell.tsx @@ -1,21 +1,33 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' import { Link, useLocation, useNavigate } from 'react-router-dom' import { stageNavItems } from '../content/nav' -import { images } from '../content/site' import { StageViews } from './StageViews' +import { StageProgressiveImage } from './StageProgressiveImage' +import { stageImages } from './stageAssets' +import { useStageImagePreload } from './useStageImagePreload' +import { StageRemotePanel } from './remote/StageRemotePanel' +import { useStageHost } from './remote/useStageHost' +import { + clampIntervalSeconds, + DEFAULT_INTERVAL_SECONDS, + defaultIntervals, + type RemoteToHost, +} from './remote/protocol' import './StageShell.css' -const AUTOPLAY_INTERVAL_MS = 6000 // 6s per slide - export function StageShell() { const location = useLocation() const navigate = useNavigate() const touchStartX = useRef(null) const [isAutoPlay, setIsAutoPlay] = useState(true) + const [remoteOpen, setRemoteOpen] = useState(false) + const [intervals, setIntervals] = useState(() => defaultIntervals(stageNavItems.length)) const currentIndex = stageNavItems.findIndex((item) => item.path === location.pathname) const activeIdx = currentIndex >= 0 ? currentIndex : 0 + useStageImagePreload(activeIdx) + const goToSlide = useCallback( (idx: number) => { const target = stageNavItems[idx] @@ -36,14 +48,68 @@ export function StageShell() { goToSlide(nextIdx) }, [activeIdx, goToSlide]) - // Auto-play interval timer + const handleRemoteCommand = useCallback( + (msg: RemoteToHost) => { + if (msg.type === 'hello') return + if (msg.type === 'autoplay') { + setIsAutoPlay(msg.on) + return + } + if (msg.type === 'setInterval') { + const seconds = clampIntervalSeconds(msg.seconds) + setIntervals((prev) => { + if (msg.index < 0 || msg.index >= prev.length) return prev + if (prev[msg.index] === seconds) return prev + const next = [...prev] + next[msg.index] = seconds + return next + }) + return + } + if (msg.type === 'setAllIntervals') { + const seconds = clampIntervalSeconds(msg.seconds) + setIntervals((prev) => { + if (prev.every((item) => item === seconds)) return prev + return prev.map(() => seconds) + }) + return + } + setIsAutoPlay(false) + if (msg.type === 'next') handleNext() + if (msg.type === 'prev') handlePrev() + if (msg.type === 'goto') goToSlide(msg.index) + }, + [goToSlide, handleNext, handlePrev], + ) + + const { roomId, status, clientCount, broadcastState } = useStageHost(handleRemoteCommand) + + useEffect(() => { + broadcastState({ + type: 'state', + index: activeIdx, + total: stageNavItems.length, + autoplay: isAutoPlay, + label: stageNavItems[activeIdx].label, + intervals, + }) + }, [activeIdx, isAutoPlay, intervals, clientCount, broadcastState]) + + const currentIntervalSec = intervals[activeIdx] ?? DEFAULT_INTERVAL_SECONDS + + const qrUrl = useMemo(() => { + if (!roomId || typeof window === 'undefined') return '' + return `${window.location.origin}/?mode=remote&peer=${encodeURIComponent(roomId)}` + }, [roomId]) + + // Auto-play interval timer (per-slide seconds, default 6) useEffect(() => { if (!isAutoPlay) return const timer = setInterval(() => { handleNext() - }, AUTOPLAY_INTERVAL_MS) + }, currentIntervalSec * 1000) return () => clearInterval(timer) - }, [isAutoPlay, handleNext]) + }, [isAutoPlay, handleNext, currentIntervalSec]) // Touch Swipe navigation for mobile const handleTouchStart = (e: React.TouchEvent) => { @@ -64,6 +130,10 @@ export function StageShell() { // Keyboard navigation useEffect(() => { const onKey = (e: KeyboardEvent) => { + if (e.key === 'r' || e.key === 'R') { + setRemoteOpen((v) => !v) + return + } if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === 'PageDown' || e.key === ' ') { e.preventDefault() handleNext() @@ -87,7 +157,7 @@ export function StageShell() { {/* Top HUD */}
- 杜康好客 + 杜康好客 全景展控 @@ -107,6 +177,15 @@ export function StageShell() { {/* Index, AutoPlay Toggle & Exit */}
+ + ))}
-
每 6 秒自动切换展控 · 支持键盘方向键与手机划屏
+
自动切换 · 键盘切页 · 手机遥控可调每页时长
+ + setRemoteOpen(false)} + />
) } diff --git a/src/stage/StageViews.css b/src/stage/StageViews.css index afe2a85..bfcfa22 100644 --- a/src/stage/StageViews.css +++ b/src/stage/StageViews.css @@ -1,3 +1,95 @@ +/* ===== Progressive Image (thumb → full) ===== */ +.stage-prog-img { + position: relative; + overflow: hidden; + display: inline-block; + line-height: 0; +} + +.stage-prog-img__thumb, +.stage-prog-img__full { + width: 100%; + height: 100%; + object-fit: contain; + transition: opacity 0.25s ease; +} + +.stage-prog-img__thumb { + filter: blur(8px); + transform: scale(1.05); + opacity: 1; +} + +.stage-prog-img__full { + position: absolute; + inset: 0; + opacity: 0; +} + +.stage-prog-img.is-full-loaded .stage-prog-img__thumb { + opacity: 0; +} + +.stage-prog-img.is-full-loaded .stage-prog-img__full { + opacity: 1; +} + +.stage-home-bottle { + width: min(20rem, 36vh); + animation: stageFloat 4s ease-in-out infinite; +} + +.stage-home-bottle .stage-prog-img__full, +.stage-home-bottle .stage-prog-img__thumb { + filter: none; + transform: none; +} + +.stage-home-bottle:not(.is-full-loaded) .stage-prog-img__thumb { + filter: blur(8px); + transform: scale(1.05); +} + +.stage-home-bottle.is-full-loaded .stage-prog-img__full { + filter: drop-shadow(0 15px 30px rgba(0, 0, 0, 0.6)); +} + +.stage-brand-photo { + width: 100%; + max-height: 14rem; +} + +.stage-brand-photo .stage-prog-img__thumb, +.stage-brand-photo .stage-prog-img__full { + object-fit: cover; + border-radius: 0.35rem; + border: 1px solid var(--stage-glass-border); + max-height: 14rem; +} + +.stage-qx-bottle { + width: min(16rem, 30vh); + animation: stageFloat 4s ease-in-out infinite; +} + +.stage-qx-bottle.is-full-loaded .stage-prog-img__full { + filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5)); +} + +.stage-qr-img { + width: 100%; + height: 100%; +} + +.stage-qr-img .stage-prog-img__thumb, +.stage-qr-img .stage-prog-img__full { + object-fit: contain; +} + +.stage-qr-img .stage-prog-img__thumb { + filter: blur(4px); +} + /* ===== Stage Views Presentation CSS ===== */ :root { @@ -176,6 +268,11 @@ position: relative; } +.stage-home-visual .stage-home-bottle { + width: min(20rem, 36vh); + height: auto; +} + .stage-home-visual img { width: min(20rem, 36vh); height: auto; @@ -245,6 +342,10 @@ gap: 1.25rem; } +.stage-brand-media .stage-brand-photo { + width: 100%; +} + .stage-brand-media img { width: 100%; max-height: 14rem; @@ -346,6 +447,10 @@ position: relative; } +.stage-qx-center .stage-qx-bottle { + width: min(16rem, 30vh); +} + .stage-qx-center img { width: min(16rem, 30vh); height: auto; @@ -625,6 +730,11 @@ box-shadow: 0 0 25px rgba(196, 163, 90, 0.3); } +.stage-qr-frame .stage-qr-img { + width: 100%; + height: 100%; +} + .stage-qr-frame img { width: 100%; height: 100%; diff --git a/src/stage/StageViews.tsx b/src/stage/StageViews.tsx index d40f682..85feb91 100644 --- a/src/stage/StageViews.tsx +++ b/src/stage/StageViews.tsx @@ -4,7 +4,6 @@ import { contact, haoke, hero, - images, partner, poetry, qingxiang, @@ -13,6 +12,8 @@ import { tech, techDetail, } from '../content/site' +import { StageProgressiveImage } from './StageProgressiveImage' +import { stageImages } from './stageAssets' import './StageViews.css' interface StageViewsProps { @@ -77,7 +78,7 @@ function StageHomeView() {
- 酒祖杜康 T30 +
@@ -117,8 +118,8 @@ function StageBrandView() {
- 杜康庄园 - 造酒遗址 + +
@@ -204,7 +205,7 @@ function StageQingxiangView() {
- 酒祖杜康清香 T30 +
{qingxiang.styleWords.map((word) => (
@@ -496,7 +497,7 @@ function StageContactView() {
- 微信二维码 +
diff --git a/src/stage/remote/RemoteShell.css b/src/stage/remote/RemoteShell.css new file mode 100644 index 0000000..d486c45 --- /dev/null +++ b/src/stage/remote/RemoteShell.css @@ -0,0 +1,269 @@ +.remote-shell { + min-height: 100vh; + min-height: 100dvh; + padding: calc(1.25rem + env(safe-area-inset-top, 0px)) 1.25rem calc(1.5rem + env(safe-area-inset-bottom, 0px)); + background: + radial-gradient(circle at 12% 8%, rgba(166, 28, 32, 0.22) 0%, transparent 42%), + linear-gradient(180deg, #141117 0%, #0c0a0e 100%); + color: #f3ece4; + box-sizing: border-box; +} + +html.remote-mode, +body.remote-mode { + background: #0c0a0e; + color: #f3ece4; +} + +.remote-head { + display: flex; + align-items: center; + gap: 0.85rem; + margin-bottom: 1.25rem; +} + +.remote-logo { + width: 2.4rem; + height: 2.4rem; + object-fit: contain; +} + +.remote-kicker { + margin: 0 0 0.15rem; + font-size: 0.75rem; + letter-spacing: 0.16em; + color: var(--gold, #c4a35a); +} + +.remote-head h1 { + margin: 0; + font-size: 1.35rem; + letter-spacing: 0.08em; +} + +.remote-status { + margin: 0 0 1rem; + font-size: 0.88rem; + color: rgba(243, 236, 228, 0.62); +} + +.remote-status.is-connected { + color: #e8d08d; +} + +.remote-status.is-error { + color: #e8a0a2; +} + +.remote-now { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-bottom: 1.1rem; + padding: 1rem 1.1rem; + border: 1px solid rgba(196, 163, 90, 0.28); + background: rgba(28, 25, 31, 0.7); +} + +.remote-now span { + font-size: 0.8rem; + letter-spacing: 0.12em; + color: var(--gold, #c4a35a); +} + +.remote-now strong { + font-size: 1.35rem; +} + +.remote-now-interval { + margin: 0.35rem 0 0; + font-size: 0.88rem; + color: rgba(243, 236, 228, 0.72); +} + +.remote-stepper { + display: grid; + grid-template-columns: 2.8rem 1fr 2.8rem; + align-items: center; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.remote-stepper span { + text-align: center; + font-size: 1.15rem; + letter-spacing: 0.08em; + color: var(--gold, #c4a35a); +} + +.remote-stepper button, +.remote-presets button, +.remote-module-time button { + min-height: 2.6rem; + border: 1px solid rgba(196, 163, 90, 0.35); + background: rgba(18, 16, 20, 0.85); + color: #f3ece4; + font-size: 1.1rem; +} + +.remote-presets { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 0.4rem; + margin-top: 0.65rem; +} + +.remote-presets button { + min-height: 2.3rem; + font-size: 0.82rem; +} + +.remote-presets button.is-active { + background: linear-gradient(135deg, #e8d08d 0%, #c4a35a 100%); + color: #121014; + border-color: transparent; +} + +.remote-nav { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.remote-nav button, +.remote-autoplay, +.remote-join button, +.remote-module-goto { + min-height: 3.1rem; + border: 1px solid rgba(196, 163, 90, 0.35); + background: rgba(28, 25, 31, 0.85); + color: #f3ece4; + font-size: 1rem; +} + +.remote-nav button:disabled, +.remote-autoplay:disabled, +.remote-module-goto:disabled, +.remote-stepper button:disabled, +.remote-presets button:disabled, +.remote-module-time button:disabled { + opacity: 0.4; +} + +.remote-autoplay { + width: 100%; + margin-bottom: 1.1rem; +} + +.remote-autoplay.is-on { + color: var(--gold, #c4a35a); + border-color: rgba(196, 163, 90, 0.7); +} + +.remote-modules-hint { + margin: 0 0 0.55rem; + font-size: 0.78rem; + line-height: 1.5; + color: rgba(243, 236, 228, 0.5); +} + +.remote-modules { + display: grid; + gap: 0.45rem; +} + +.remote-module-row { + display: grid; + grid-template-columns: 1fr auto; + align-items: stretch; + gap: 0.4rem; + border: 1px solid rgba(196, 163, 90, 0.28); + background: rgba(28, 25, 31, 0.7); +} + +.remote-module-goto { + display: flex; + align-items: center; + gap: 0.75rem; + min-height: 2.7rem; + padding: 0 0.9rem; + text-align: left; + border: none; + background: transparent; +} + +.remote-module-goto span { + color: var(--gold, #c4a35a); + letter-spacing: 0.08em; +} + +.remote-module-row.is-active { + background: linear-gradient(135deg, #e8d08d 0%, #c4a35a 100%); + border-color: transparent; + color: #121014; +} + +.remote-module-row.is-active .remote-module-goto, +.remote-module-row.is-active .remote-module-goto span { + color: #121014; +} + +.remote-module-row.is-active .remote-module-goto span { + color: #7d1216; +} + +.remote-module-time { + display: flex; + align-items: center; + gap: 0.2rem; + padding-right: 0.35rem; +} + +.remote-module-time em { + min-width: 2.2rem; + font-style: normal; + text-align: center; + font-size: 0.82rem; +} + +.remote-module-time button { + width: 2.1rem; + min-height: 2.1rem; + padding: 0; +} + +.remote-module-row.is-active .remote-module-time button { + border-color: rgba(125, 18, 22, 0.28); + background: rgba(18, 16, 20, 0.12); + color: #121014; +} + +.remote-module-row.is-active .remote-module-time em { + color: #7d1216; +} + +.remote-join { + display: grid; + gap: 0.65rem; +} + +.remote-join label { + font-size: 0.88rem; + color: rgba(243, 236, 228, 0.72); +} + +.remote-join input { + min-height: 3rem; + padding: 0 0.9rem; + border: 1px solid rgba(196, 163, 90, 0.4); + background: rgba(28, 25, 31, 0.8); + color: #f3ece4; + font-size: 1rem; + letter-spacing: 0.08em; +} + +.remote-join button { + background: var(--brand, #a61c20); + border-color: var(--brand, #a61c20); +} diff --git a/src/stage/remote/RemoteShell.tsx b/src/stage/remote/RemoteShell.tsx new file mode 100644 index 0000000..0a42530 --- /dev/null +++ b/src/stage/remote/RemoteShell.tsx @@ -0,0 +1,193 @@ +import { useMemo, useState, type FormEvent } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' +import { stageNavItems } from '../../content/nav' +import { images } from '../../content/site' +import { + DEFAULT_INTERVAL_SECONDS, + MAX_INTERVAL_SECONDS, + MIN_INTERVAL_SECONDS, + clampIntervalSeconds, + defaultIntervals, + normalizeRoomId, +} from './protocol' +import { useStageClient } from './useStageClient' +import './RemoteShell.css' + +const PRESET_SECONDS = [3, 6, 10, 15, 30] as const + +export function RemoteShell() { + const location = useLocation() + const navigate = useNavigate() + const peerFromUrl = useMemo( + () => new URLSearchParams(location.search).get('peer'), + [location.search], + ) + const hostId = peerFromUrl ? normalizeRoomId(peerFromUrl) : null + const [draft, setDraft] = useState('') + const { status, state, send } = useStageClient(hostId) + + const index = state?.index ?? 0 + const total = state?.total ?? stageNavItems.length + const label = state?.label ?? stageNavItems[index]?.label ?? '等待大屏' + const autoplay = state?.autoplay ?? false + const intervals = state?.intervals ?? defaultIntervals(stageNavItems.length) + const allSame = intervals.length > 0 && intervals.every((item) => item === intervals[0]) + const globalSeconds = allSame + ? (intervals[0] ?? DEFAULT_INTERVAL_SECONDS) + : (intervals[index] ?? DEFAULT_INTERVAL_SECONDS) + const connected = status === 'connected' + + const setOneSeconds = (idx: number, seconds: number) => { + send({ type: 'setInterval', index: idx, seconds: clampIntervalSeconds(seconds) }) + } + + const setAllSeconds = (seconds: number) => { + send({ type: 'setAllIntervals', seconds: clampIntervalSeconds(seconds) }) + } + + const join = (e: FormEvent) => { + e.preventDefault() + const id = normalizeRoomId(draft) + if (!id) return + navigate(`/?mode=remote&peer=${encodeURIComponent(id)}`) + } + + return ( +
+
+ +
+

杜康好客 · 展控遥控

+

手机切屏

+
+
+ + {!hostId ? ( +
+ + setDraft(e.target.value)} + placeholder="dkhk-xxxxxx" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + /> + +
+ ) : ( + <> +

+ {status === 'connected' + ? `已连接 ${hostId}` + : status === 'connecting' + ? `正在连接 ${hostId}…` + : status === 'error' + ? '连接失败,请确认大屏仍在展控模式' + : '未连接'} +

+ +
+ + {String(index + 1).padStart(2, '0')} / {String(total).padStart(2, '0')} + + {label} +

+ {allSame ? `统一时长 ${globalSeconds} 秒(全部模块)` : `统一设置(当前各模块时长不同)`} +

+
+ + {allSame ? `${globalSeconds}s` : `${globalSeconds}s*`} + +
+
+ {PRESET_SECONDS.map((sec) => ( + + ))} +
+
+ +
+ + +
+ + + +

下方右侧加减只改该模块;顶部时长会统一改全部 11 页

+ + + )} +
+ ) +} diff --git a/src/stage/remote/StageRemotePanel.css b/src/stage/remote/StageRemotePanel.css new file mode 100644 index 0000000..190cbeb --- /dev/null +++ b/src/stage/remote/StageRemotePanel.css @@ -0,0 +1,122 @@ +.stage-remote-overlay { + position: absolute; + inset: 0; + z-index: 80; + background: rgba(8, 6, 10, 0.55); + display: flex; + align-items: center; + justify-content: flex-end; + padding: calc(5.2rem + env(safe-area-inset-top, 0px)) 2.5rem calc(3rem + env(safe-area-inset-bottom, 0px)); +} + +.stage-remote-panel { + width: min(22rem, 92vw); + padding: 1.35rem 1.4rem 1.5rem; + background: linear-gradient(180deg, #1c191f 0%, #121014 100%); + border: 1px solid rgba(196, 163, 90, 0.35); + box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.45); + color: #f3ece4; +} + +.stage-remote-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.1rem; +} + +.stage-remote-kicker { + margin: 0 0 0.2rem; + font-size: 0.72rem; + letter-spacing: 0.18em; + color: var(--gold, #c4a35a); +} + +.stage-remote-head h2 { + margin: 0; + font-size: 1.25rem; + letter-spacing: 0.08em; +} + +.stage-remote-close { + width: 2rem; + height: 2rem; + color: rgba(243, 236, 228, 0.8); + border: 1px solid rgba(196, 163, 90, 0.3); + background: transparent; +} + +.stage-remote-qr { + display: flex; + align-items: center; + justify-content: center; + min-height: 12.5rem; + padding: 0.75rem; + background: #f7f2ea; +} + +.stage-remote-qr.is-pending { + background: rgba(247, 242, 234, 0.08); + color: rgba(243, 236, 228, 0.55); +} + +.stage-remote-code { + margin: 1rem 0 0.35rem; + text-align: center; + font-family: 'Noto Serif SC', serif; + font-size: 1.15rem; + letter-spacing: 0.16em; + color: var(--gold, #c4a35a); +} + +.stage-remote-status { + margin: 0; + text-align: center; + font-size: 0.85rem; + color: rgba(243, 236, 228, 0.65); +} + +.stage-remote-status.is-live { + color: #e8d08d; +} + +.stage-remote-status.error { + color: #e8a0a2; +} + +.stage-remote-hint { + margin: 0.85rem 0 0; + text-align: center; + font-size: 0.78rem; + line-height: 1.6; + color: rgba(243, 236, 228, 0.5); +} + +@media (max-width: 48rem) { + .stage-remote-overlay { + justify-content: center; + padding: calc(4.2rem + env(safe-area-inset-top, 0px)) 1rem calc(1.5rem + env(safe-area-inset-bottom, 0px)); + } +} + +.stage-remote-toggle { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.75rem; + border-radius: 1.5rem; + font-size: 0.78rem; + color: rgba(243, 236, 228, 0.85); + background: rgba(28, 25, 31, 0.8); + border: 1px solid rgba(196, 163, 90, 0.3); + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; +} + +.stage-remote-toggle.is-live { + color: var(--gold, #c4a35a); + border-color: rgba(196, 163, 90, 0.6); + background: rgba(196, 163, 90, 0.15); +} diff --git a/src/stage/remote/StageRemotePanel.tsx b/src/stage/remote/StageRemotePanel.tsx new file mode 100644 index 0000000..68d33b9 --- /dev/null +++ b/src/stage/remote/StageRemotePanel.tsx @@ -0,0 +1,69 @@ +import { QRCodeSVG } from 'qrcode.react' +import type { HostStatus } from './useStageHost' +import './StageRemotePanel.css' + +type StageRemotePanelProps = { + open: boolean + roomId: string + qrUrl: string + status: HostStatus + clientCount: number + onClose: () => void +} + +export function StageRemotePanel({ + open, + roomId, + qrUrl, + status, + clientCount, + onClose, +}: StageRemotePanelProps) { + if (!open) return null + + const statusLabel = + status === 'ready' + ? clientCount > 0 + ? `已连接 ${clientCount} 台手机` + : '等待手机扫码' + : status === 'error' + ? '信令连接失败' + : '正在创建房间…' + + return ( +
+ +
+ ) +} diff --git a/src/stage/remote/protocol.ts b/src/stage/remote/protocol.ts new file mode 100644 index 0000000..6ab7bab --- /dev/null +++ b/src/stage/remote/protocol.ts @@ -0,0 +1,105 @@ +export const ROOM_ID_PREFIX = 'dkhk-' +export const ROOM_ID_STORAGE_KEY = 'dkhk-stage-peer-id' + +export const DEFAULT_INTERVAL_SECONDS = 6 +export const MIN_INTERVAL_SECONDS = 3 +export const MAX_INTERVAL_SECONDS = 60 + +export type RemoteToHost = + | { type: 'hello' } + | { type: 'next' } + | { type: 'prev' } + | { type: 'goto'; index: number } + | { type: 'autoplay'; on: boolean } + | { type: 'setInterval'; index: number; seconds: number } + | { type: 'setAllIntervals'; seconds: number } + +export type HostToRemote = { + type: 'state' + index: number + total: number + autoplay: boolean + label: string + intervals: number[] +} + +const ROOM_CHARS = 'abcdefghjkmnpqrstuvwxyz23456789' + +export function createRoomId() { + let id = '' + for (let i = 0; i < 6; i += 1) { + id += ROOM_CHARS[Math.floor(Math.random() * ROOM_CHARS.length)] + } + return `${ROOM_ID_PREFIX}${id}` +} + +export function normalizeRoomId(raw: string) { + const trimmed = raw.trim().toLowerCase() + if (!trimmed) return '' + if (trimmed.startsWith(ROOM_ID_PREFIX)) return trimmed + return `${ROOM_ID_PREFIX}${trimmed}` +} + +export function clampIntervalSeconds(seconds: number) { + if (!Number.isFinite(seconds)) return DEFAULT_INTERVAL_SECONDS + return Math.min(MAX_INTERVAL_SECONDS, Math.max(MIN_INTERVAL_SECONDS, Math.round(seconds))) +} + +export function defaultIntervals(count: number) { + return Array.from({ length: count }, () => DEFAULT_INTERVAL_SECONDS) +} + +export function isRemoteToHost(value: unknown): value is RemoteToHost { + if (!value || typeof value !== 'object' || !('type' in value)) return false + const type = (value as { type: unknown }).type + if (type === 'hello' || type === 'next' || type === 'prev') return true + if (type === 'goto') { + return typeof (value as { index?: unknown }).index === 'number' + } + if (type === 'autoplay') { + return typeof (value as { on?: unknown }).on === 'boolean' + } + if (type === 'setInterval') { + const msg = value as { index?: unknown; seconds?: unknown } + return typeof msg.index === 'number' && typeof msg.seconds === 'number' + } + if (type === 'setAllIntervals') { + return typeof (value as { seconds?: unknown }).seconds === 'number' + } + return false +} + +export function isHostToRemote(value: unknown): value is HostToRemote { + if (!value || typeof value !== 'object' || !('type' in value)) return false + const msg = value as Record + return ( + msg.type === 'state' && + typeof msg.index === 'number' && + typeof msg.total === 'number' && + typeof msg.autoplay === 'boolean' && + typeof msg.label === 'string' && + Array.isArray(msg.intervals) && + msg.intervals.every((item) => typeof item === 'number') + ) +} + +export function decodePayload(data: unknown): unknown { + if (typeof data === 'string') { + try { + return JSON.parse(data) as unknown + } catch { + return null + } + } + return data +} + +export function parseRemoteToHost(data: unknown): RemoteToHost | null { + const payload = decodePayload(data) + return isRemoteToHost(payload) ? payload : null +} + +export function parseHostToRemote(data: unknown): HostToRemote | null { + const payload = decodePayload(data) + return isHostToRemote(payload) ? payload : null +} diff --git a/src/stage/remote/useStageClient.ts b/src/stage/remote/useStageClient.ts new file mode 100644 index 0000000..19b902d --- /dev/null +++ b/src/stage/remote/useStageClient.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Peer, type DataConnection } from 'peerjs' +import { parseHostToRemote, type HostToRemote, type RemoteToHost } from './protocol' + +export type ClientStatus = 'idle' | 'connecting' | 'connected' | 'error' + +export function useStageClient(hostId: string | null) { + const [status, setStatus] = useState('idle') + const [state, setState] = useState(null) + const connRef = useRef(null) + + const send = useCallback((msg: RemoteToHost) => { + if (connRef.current?.open) { + connRef.current.send(JSON.stringify(msg)) + } + }, []) + + useEffect(() => { + if (!hostId) { + setStatus('idle') + setState(null) + return + } + + let cancelled = false + const peer = new Peer({ debug: 0 }) + setStatus('connecting') + setState(null) + + peer.on('open', () => { + if (cancelled) return + const conn = peer.connect(hostId, { reliable: true }) + connRef.current = conn + + conn.on('open', () => { + if (cancelled) return + setStatus('connected') + conn.send(JSON.stringify({ type: 'hello' })) + }) + + conn.on('data', (data) => { + const msg = parseHostToRemote(data) + if (msg) setState(msg) + }) + + conn.on('close', () => { + if (!cancelled) setStatus('error') + }) + + conn.on('error', () => { + if (!cancelled) setStatus('error') + }) + }) + + peer.on('error', () => { + if (!cancelled) setStatus('error') + }) + + return () => { + cancelled = true + connRef.current?.close() + connRef.current = null + peer.destroy() + } + }, [hostId]) + + return { status, state, send } +} diff --git a/src/stage/remote/useStageHost.ts b/src/stage/remote/useStageHost.ts new file mode 100644 index 0000000..9652768 --- /dev/null +++ b/src/stage/remote/useStageHost.ts @@ -0,0 +1,119 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Peer, type DataConnection } from 'peerjs' +import { + ROOM_ID_STORAGE_KEY, + createRoomId, + parseRemoteToHost, + type HostToRemote, + type RemoteToHost, +} from './protocol' + +export type HostStatus = 'connecting' | 'ready' | 'error' + +function readStoredRoomId() { + try { + return sessionStorage.getItem(ROOM_ID_STORAGE_KEY) ?? '' + } catch { + return '' + } +} + +function writeStoredRoomId(id: string) { + try { + sessionStorage.setItem(ROOM_ID_STORAGE_KEY, id) + } catch { + /* ignore quota / private mode */ + } +} + +export function useStageHost(onCommand: (msg: RemoteToHost) => void) { + const [roomId, setRoomId] = useState('') + const [status, setStatus] = useState('connecting') + const [clientCount, setClientCount] = useState(0) + const connectionsRef = useRef([]) + const onCommandRef = useRef(onCommand) + onCommandRef.current = onCommand + + const refreshClientCount = useCallback(() => { + setClientCount(connectionsRef.current.filter((conn) => conn.open).length) + }, []) + + const broadcastState = useCallback((state: HostToRemote) => { + const payload = JSON.stringify(state) + connectionsRef.current.forEach((conn) => { + if (conn.open) conn.send(payload) + }) + }, []) + + useEffect(() => { + let cancelled = false + let peer: Peer | null = null + let retrying = false + + const attachConnection = (conn: DataConnection) => { + connectionsRef.current.push(conn) + + conn.on('open', () => { + if (cancelled) return + refreshClientCount() + onCommandRef.current({ type: 'hello' }) + }) + + conn.on('data', (data) => { + const msg = parseRemoteToHost(data) + if (msg) onCommandRef.current(msg) + }) + + const drop = () => { + connectionsRef.current = connectionsRef.current.filter((item) => item !== conn) + refreshClientCount() + } + + conn.on('close', drop) + conn.on('error', drop) + } + + const start = (id: string) => { + retrying = false + peer = new Peer(id, { debug: 0 }) + + peer.on('open', (openId) => { + if (cancelled) return + writeStoredRoomId(openId) + setRoomId(openId) + setStatus('ready') + }) + + peer.on('connection', (conn) => { + if (cancelled) return + attachConnection(conn) + }) + + peer.on('error', (err) => { + if (cancelled) return + if (err.type === 'unavailable-id' && !retrying) { + retrying = true + peer?.destroy() + const nextId = createRoomId() + writeStoredRoomId(nextId) + start(nextId) + return + } + setStatus('error') + }) + } + + const initialId = readStoredRoomId() || createRoomId() + writeStoredRoomId(initialId) + start(initialId) + + return () => { + cancelled = true + connectionsRef.current.forEach((conn) => conn.close()) + connectionsRef.current = [] + peer?.destroy() + } + }, [refreshClientCount]) + + return { roomId, status, clientCount, broadcastState } +} diff --git a/src/stage/stageAssets.ts b/src/stage/stageAssets.ts new file mode 100644 index 0000000..8f5bd2c --- /dev/null +++ b/src/stage/stageAssets.ts @@ -0,0 +1,50 @@ +export type StageImagePair = { thumb: string; full: string } + +export const stageImages = { + logo: { + thumb: '/assets/stage/thumb/logo.webp', + full: '/assets/stage/full/logo.webp', + }, + t30: { + thumb: '/assets/stage/thumb/t30-bottle.webp', + full: '/assets/stage/full/t30-bottle.webp', + }, + brand: { + manor: { + thumb: '/assets/stage/thumb/emb_p6_5.webp', + full: '/assets/stage/full/emb_p6_5.webp', + }, + relic: { + thumb: '/assets/stage/thumb/emb_p33_1.webp', + full: '/assets/stage/full/emb_p33_1.webp', + }, + }, + qr: { + thumb: '/assets/stage/thumb/qr-contact.webp', + full: '/assets/stage/full/qr-contact.webp', + }, +} as const + +/** Flat list of all stage image pairs for preload helpers */ +export const allStageImagePairs: StageImagePair[] = [ + stageImages.logo, + stageImages.t30, + stageImages.brand.manor, + stageImages.brand.relic, + stageImages.qr, +] + +/** Images used per slide index (aligned with stageNavItems) */ +export const stageSlideImages: StageImagePair[][] = [ + [stageImages.t30], // 首页 + [stageImages.brand.manor, stageImages.brand.relic], // 品牌传承 + [], // 诗酒文化 + [stageImages.t30], // 清香 + [], // 好客模式 + [], // 三端协同 + [], // 科技驱动 + [], // 酒厂实力 + [], // 城市合伙人 + [], // 科技板块 + [stageImages.qr], // 联系我们 +] diff --git a/src/stage/useStageImagePreload.ts b/src/stage/useStageImagePreload.ts new file mode 100644 index 0000000..8f720d3 --- /dev/null +++ b/src/stage/useStageImagePreload.ts @@ -0,0 +1,44 @@ +import { useEffect } from 'react' +import { stageNavItems } from '../content/nav' +import { + allStageImagePairs, + stageImages, + stageSlideImages, + type StageImagePair, +} from './stageAssets' + +const loaded = new Set() + +function preloadUrl(url: string) { + if (loaded.has(url)) return + loaded.add(url) + const img = new Image() + img.src = url +} + +function preloadPair(pair: StageImagePair, mode: 'thumb' | 'full' | 'both') { + if (mode === 'thumb' || mode === 'both') preloadUrl(pair.thumb) + if (mode === 'full' || mode === 'both') preloadUrl(pair.full) +} + +function preloadSlideIndex(index: number, mode: 'thumb' | 'full' | 'both') { + const pairs = stageSlideImages[index] ?? [] + for (const pair of pairs) preloadPair(pair, mode) +} + +/** Preload all thumbs on mount; preload current + adjacent slide full images on index change. */ +export function useStageImagePreload(activeIdx: number) { + useEffect(() => { + for (const pair of allStageImagePairs) preloadPair(pair, 'thumb') + preloadPair(stageImages.logo, 'full') + }, []) + + useEffect(() => { + preloadSlideIndex(activeIdx, 'full') + + const prev = (activeIdx - 1 + stageNavItems.length) % stageNavItems.length + const next = (activeIdx + 1) % stageNavItems.length + preloadSlideIndex(prev, 'full') + preloadSlideIndex(next, 'full') + }, [activeIdx]) +}