Update UI with immersive video mode, progressive loading, and grayscale theme

This commit is contained in:
Khoa Vo 2026-01-02 08:42:50 +07:00
parent 601ae284b5
commit 03e93fcfa6
23 changed files with 5684 additions and 5285 deletions

View file

@ -45,6 +45,9 @@ class PlaywrightManager:
DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
# Use installed Chrome instead of Playwright's Chromium (avoids slow download)
CHROME_PATH = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
# VNC login state (class-level to persist across requests)
_vnc_playwright = None
_vnc_browser = None
@ -528,6 +531,7 @@ class PlaywrightManager:
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
executable_path=PlaywrightManager.CHROME_PATH,
args=PlaywrightManager.BROWSER_ARGS
)
@ -721,6 +725,7 @@ class PlaywrightManager:
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
executable_path=PlaywrightManager.CHROME_PATH,
args=PlaywrightManager.BROWSER_ARGS
)
@ -811,6 +816,7 @@ class PlaywrightManager:
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
executable_path=PlaywrightManager.CHROME_PATH,
args=PlaywrightManager.BROWSER_ARGS
)
@ -910,6 +916,7 @@ class PlaywrightManager:
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
executable_path=PlaywrightManager.CHROME_PATH,
args=PlaywrightManager.BROWSER_ARGS
)

View file

@ -81,7 +81,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@ -725,9 +724,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
"integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1038,9 +1037,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz",
"integrity": "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
"integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
"cpu": [
"arm"
],
@ -1052,9 +1051,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.5.tgz",
"integrity": "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
"integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
"cpu": [
"arm64"
],
@ -1066,9 +1065,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.5.tgz",
"integrity": "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
"integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
"cpu": [
"arm64"
],
@ -1080,9 +1079,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.5.tgz",
"integrity": "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
"integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
"cpu": [
"x64"
],
@ -1094,9 +1093,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.5.tgz",
"integrity": "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz",
"integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
"cpu": [
"arm64"
],
@ -1108,9 +1107,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.5.tgz",
"integrity": "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz",
"integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
"cpu": [
"x64"
],
@ -1122,9 +1121,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.5.tgz",
"integrity": "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz",
"integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
"cpu": [
"arm"
],
@ -1136,9 +1135,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.5.tgz",
"integrity": "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz",
"integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
"cpu": [
"arm"
],
@ -1150,9 +1149,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.5.tgz",
"integrity": "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz",
"integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
"cpu": [
"arm64"
],
@ -1164,9 +1163,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.5.tgz",
"integrity": "sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz",
"integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
"cpu": [
"arm64"
],
@ -1178,9 +1177,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.5.tgz",
"integrity": "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz",
"integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
"cpu": [
"loong64"
],
@ -1192,9 +1191,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.5.tgz",
"integrity": "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz",
"integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
"cpu": [
"ppc64"
],
@ -1206,9 +1205,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.5.tgz",
"integrity": "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz",
"integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
"cpu": [
"riscv64"
],
@ -1220,9 +1219,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.5.tgz",
"integrity": "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz",
"integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
"cpu": [
"riscv64"
],
@ -1234,9 +1233,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.5.tgz",
"integrity": "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz",
"integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
"cpu": [
"s390x"
],
@ -1248,9 +1247,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.5.tgz",
"integrity": "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz",
"integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
"cpu": [
"x64"
],
@ -1262,9 +1261,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.5.tgz",
"integrity": "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
"integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
"cpu": [
"x64"
],
@ -1276,9 +1275,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.5.tgz",
"integrity": "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz",
"integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
"cpu": [
"arm64"
],
@ -1290,9 +1289,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.5.tgz",
"integrity": "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz",
"integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
"cpu": [
"arm64"
],
@ -1304,9 +1303,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.5.tgz",
"integrity": "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
"integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
"cpu": [
"ia32"
],
@ -1318,9 +1317,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz",
"integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
"integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
"cpu": [
"x64"
],
@ -1332,9 +1331,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz",
"integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
"integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
"cpu": [
"x64"
],
@ -1410,7 +1409,6 @@
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@ -1428,7 +1426,6 @@
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@ -1445,20 +1442,20 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz",
"integrity": "sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg==",
"version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.51.0.tgz",
"integrity": "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/type-utils": "8.50.0",
"@typescript-eslint/utils": "8.50.0",
"@typescript-eslint/visitor-keys": "8.50.0",
"@typescript-eslint/scope-manager": "8.51.0",
"@typescript-eslint/type-utils": "8.51.0",
"@typescript-eslint/utils": "8.51.0",
"@typescript-eslint/visitor-keys": "8.51.0",
"ignore": "^7.0.0",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.1.0"
"ts-api-utils": "^2.2.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1468,7 +1465,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.50.0",
"@typescript-eslint/parser": "^8.51.0",
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
@ -1484,17 +1481,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz",
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.51.0.tgz",
"integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0",
"@typescript-eslint/typescript-estree": "8.50.0",
"@typescript-eslint/visitor-keys": "8.50.0",
"@typescript-eslint/scope-manager": "8.51.0",
"@typescript-eslint/types": "8.51.0",
"@typescript-eslint/typescript-estree": "8.51.0",
"@typescript-eslint/visitor-keys": "8.51.0",
"debug": "^4.3.4"
},
"engines": {
@ -1510,14 +1506,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.50.0.tgz",
"integrity": "sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==",
"version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.51.0.tgz",
"integrity": "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.50.0",
"@typescript-eslint/types": "^8.50.0",
"@typescript-eslint/tsconfig-utils": "^8.51.0",
"@typescript-eslint/types": "^8.51.0",
"debug": "^4.3.4"
},
"engines": {
@ -1532,14 +1528,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.50.0.tgz",
"integrity": "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==",
"version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.51.0.tgz",
"integrity": "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.50.0",
"@typescript-eslint/visitor-keys": "8.50.0"
"@typescript-eslint/types": "8.51.0",
"@typescript-eslint/visitor-keys": "8.51.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1550,9 +1546,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.50.0.tgz",
"integrity": "sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==",
"version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.51.0.tgz",
"integrity": "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==",
"dev": true,
"license": "MIT",
"engines": {
@ -1567,17 +1563,17 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.50.0.tgz",
"integrity": "sha512-7OciHT2lKCewR0mFoBrvZJ4AXTMe/sYOe87289WAViOocEmDjjv8MvIOT2XESuKj9jp8u3SZYUSh89QA4S1kQw==",
"version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.51.0.tgz",
"integrity": "sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.50.0",
"@typescript-eslint/typescript-estree": "8.50.0",
"@typescript-eslint/utils": "8.50.0",
"@typescript-eslint/types": "8.51.0",
"@typescript-eslint/typescript-estree": "8.51.0",
"@typescript-eslint/utils": "8.51.0",
"debug": "^4.3.4",
"ts-api-utils": "^2.1.0"
"ts-api-utils": "^2.2.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1592,9 +1588,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.0.tgz",
"integrity": "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==",
"version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.51.0.tgz",
"integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==",
"dev": true,
"license": "MIT",
"engines": {
@ -1606,21 +1602,21 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.50.0.tgz",
"integrity": "sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==",
"version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.51.0.tgz",
"integrity": "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.50.0",
"@typescript-eslint/tsconfig-utils": "8.50.0",
"@typescript-eslint/types": "8.50.0",
"@typescript-eslint/visitor-keys": "8.50.0",
"@typescript-eslint/project-service": "8.51.0",
"@typescript-eslint/tsconfig-utils": "8.51.0",
"@typescript-eslint/types": "8.51.0",
"@typescript-eslint/visitor-keys": "8.51.0",
"debug": "^4.3.4",
"minimatch": "^9.0.4",
"semver": "^7.6.0",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.1.0"
"ts-api-utils": "^2.2.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1673,16 +1669,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.50.0.tgz",
"integrity": "sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==",
"version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.51.0.tgz",
"integrity": "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0",
"@typescript-eslint/typescript-estree": "8.50.0"
"@typescript-eslint/scope-manager": "8.51.0",
"@typescript-eslint/types": "8.51.0",
"@typescript-eslint/typescript-estree": "8.51.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1697,13 +1693,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.50.0.tgz",
"integrity": "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==",
"version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.51.0.tgz",
"integrity": "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.50.0",
"@typescript-eslint/types": "8.51.0",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
@ -1741,7 +1737,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -1898,9 +1893,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.9",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.9.tgz",
"integrity": "sha512-V8fbOCSeOFvlDj7LLChUcqbZrdKD9RU/VR260piF1790vT0mfLSwGc/Qzxv3IqiTukOpNtItePa0HBpMAj7MDg==",
"version": "2.9.11",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
"integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@ -1964,7 +1959,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -2013,9 +2007,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001760",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz",
"integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==",
"version": "1.0.30001762",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz",
"integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==",
"dev": true,
"funding": [
{
@ -2370,7 +2364,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -2497,9 +2490,9 @@
}
},
"node_modules/esquery": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
"integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@ -2594,9 +2587,9 @@
"license": "MIT"
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
@ -3014,7 +3007,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@ -3515,7 +3507,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -3711,7 +3702,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@ -3724,7 +3714,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@ -3841,9 +3830,9 @@
}
},
"node_modules/rollup": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz",
"integrity": "sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==",
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3857,28 +3846,28 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.53.5",
"@rollup/rollup-android-arm64": "4.53.5",
"@rollup/rollup-darwin-arm64": "4.53.5",
"@rollup/rollup-darwin-x64": "4.53.5",
"@rollup/rollup-freebsd-arm64": "4.53.5",
"@rollup/rollup-freebsd-x64": "4.53.5",
"@rollup/rollup-linux-arm-gnueabihf": "4.53.5",
"@rollup/rollup-linux-arm-musleabihf": "4.53.5",
"@rollup/rollup-linux-arm64-gnu": "4.53.5",
"@rollup/rollup-linux-arm64-musl": "4.53.5",
"@rollup/rollup-linux-loong64-gnu": "4.53.5",
"@rollup/rollup-linux-ppc64-gnu": "4.53.5",
"@rollup/rollup-linux-riscv64-gnu": "4.53.5",
"@rollup/rollup-linux-riscv64-musl": "4.53.5",
"@rollup/rollup-linux-s390x-gnu": "4.53.5",
"@rollup/rollup-linux-x64-gnu": "4.53.5",
"@rollup/rollup-linux-x64-musl": "4.53.5",
"@rollup/rollup-openharmony-arm64": "4.53.5",
"@rollup/rollup-win32-arm64-msvc": "4.53.5",
"@rollup/rollup-win32-ia32-msvc": "4.53.5",
"@rollup/rollup-win32-x64-gnu": "4.53.5",
"@rollup/rollup-win32-x64-msvc": "4.53.5",
"@rollup/rollup-android-arm-eabi": "4.54.0",
"@rollup/rollup-android-arm64": "4.54.0",
"@rollup/rollup-darwin-arm64": "4.54.0",
"@rollup/rollup-darwin-x64": "4.54.0",
"@rollup/rollup-freebsd-arm64": "4.54.0",
"@rollup/rollup-freebsd-x64": "4.54.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.54.0",
"@rollup/rollup-linux-arm-musleabihf": "4.54.0",
"@rollup/rollup-linux-arm64-gnu": "4.54.0",
"@rollup/rollup-linux-arm64-musl": "4.54.0",
"@rollup/rollup-linux-loong64-gnu": "4.54.0",
"@rollup/rollup-linux-ppc64-gnu": "4.54.0",
"@rollup/rollup-linux-riscv64-gnu": "4.54.0",
"@rollup/rollup-linux-riscv64-musl": "4.54.0",
"@rollup/rollup-linux-s390x-gnu": "4.54.0",
"@rollup/rollup-linux-x64-gnu": "4.54.0",
"@rollup/rollup-linux-x64-musl": "4.54.0",
"@rollup/rollup-openharmony-arm64": "4.54.0",
"@rollup/rollup-win32-arm64-msvc": "4.54.0",
"@rollup/rollup-win32-ia32-msvc": "4.54.0",
"@rollup/rollup-win32-x64-gnu": "4.54.0",
"@rollup/rollup-win32-x64-msvc": "4.54.0",
"fsevents": "~2.3.2"
}
},
@ -4132,7 +4121,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -4154,9 +4142,9 @@
}
},
"node_modules/ts-api-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.3.0.tgz",
"integrity": "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==",
"dev": true,
"license": "MIT",
"engines": {
@ -4198,7 +4186,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -4208,16 +4195,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.50.0.tgz",
"integrity": "sha512-Q1/6yNUmCpH94fbgMUMg2/BSAr/6U7GBk61kZTv1/asghQOWOjTlp9K8mixS5NcJmm2creY+UFfGeW/+OcA64A==",
"version": "8.51.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.51.0.tgz",
"integrity": "sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.50.0",
"@typescript-eslint/parser": "8.50.0",
"@typescript-eslint/typescript-estree": "8.50.0",
"@typescript-eslint/utils": "8.50.0"
"@typescript-eslint/eslint-plugin": "8.51.0",
"@typescript-eslint/parser": "8.51.0",
"@typescript-eslint/typescript-estree": "8.51.0",
"@typescript-eslint/utils": "8.51.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -4292,7 +4279,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",

View file

@ -109,6 +109,7 @@ export const Feed: React.FC = () => {
const [suggestedLimit, setSuggestedLimit] = useState(12);
const [showHeader, setShowHeader] = useState(false);
const [isFollowingFeed, setIsFollowingFeed] = useState(false);
const [isVideoPaused, setIsVideoPaused] = useState(true); // Tracks if current video is paused (controls UI visibility)
// Lazy load - start with 12
// Search state
@ -117,10 +118,38 @@ export const Feed: React.FC = () => {
const [isSearching, setIsSearching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const [searchMatchedUser, setSearchMatchedUser] = useState<UserProfile | null>(null);
// Global mute state - persists across video scrolling
const [isMuted, setIsMuted] = useState(true);
// Profile View state - grid of videos from a specific user
const [profileViewUsername, setProfileViewUsername] = useState<string | null>(null);
const [profileVideos, setProfileVideos] = useState<Video[]>([]);
const [profileLoading, setProfileLoading] = useState(false);
const [profileHasMore, setProfileHasMore] = useState(true);
const [profileUserData, setProfileUserData] = useState<UserProfile | null>(null);
const profileGridRef = useRef<HTMLDivElement>(null);
// Loading timer state - shows elapsed time during video crawling
const [loadingElapsed, setLoadingElapsed] = useState(0);
const loadingTimerRef = useRef<NodeJS.Timeout | null>(null);
// Start/stop loading timer helper functions
const startLoadingTimer = () => {
setLoadingElapsed(0);
loadingTimerRef.current = setInterval(() => {
setLoadingElapsed(prev => prev + 1);
}, 1000);
};
const stopLoadingTimer = () => {
if (loadingTimerRef.current) {
clearInterval(loadingTimerRef.current);
loadingTimerRef.current = null;
}
};
// ========== SWIPE LOGIC ==========
const touchStart = useRef<number | null>(null);
const touchEnd = useRef<number | null>(null);
@ -475,6 +504,106 @@ export const Feed: React.FC = () => {
handleSearch(false, `@${username}`);
};
// Open profile view with video grid
const openProfileView = async (username: string) => {
const cleanUsername = username.replace('@', '');
setProfileViewUsername(cleanUsername);
setProfileVideos([]);
setProfileLoading(true);
setProfileHasMore(true);
setProfileUserData(null);
startLoadingTimer(); // Start countdown timer
// Pause the currently playing video by switching active tab temporarily
// This triggers VideoPlayer's isActive=false which pauses the video
setActiveTab('following'); // Switch away from 'foryou' to pause video
try {
// Fetch user profile data first (show header ASAP)
const profileRes = await axios.get(`${API_BASE_URL}/user/profile?username=${cleanUsername}`);
setProfileUserData(profileRes.data);
// Fetch videos progressively - load smaller batches and show immediately
const batchSize = 5;
let totalFetched = 0;
const maxVideos = 20;
while (totalFetched < maxVideos) {
const videosRes = await axios.get(`${API_BASE_URL}/user/videos?username=${cleanUsername}&limit=${batchSize}&offset=${totalFetched}`);
const newVideos = videosRes.data.videos || [];
if (newVideos.length === 0) {
setProfileHasMore(false);
break;
}
// Append videos immediately as they load (progressive loading) - filter duplicates
setProfileVideos(prev => {
const existingIds = new Set(prev.map(v => v.id));
const uniqueNewVideos = newVideos.filter((v: Video) => !existingIds.has(v.id));
return [...prev, ...uniqueNewVideos];
});
totalFetched += newVideos.length;
// If we got less than batch size, no more videos
if (newVideos.length < batchSize) {
setProfileHasMore(false);
break;
}
}
// Check if there might be more videos beyond initial 20
if (totalFetched >= maxVideos) {
setProfileHasMore(true);
}
} catch (err) {
console.error('Error loading profile:', err);
setError('Failed to load profile');
} finally {
setProfileLoading(false);
stopLoadingTimer(); // Stop countdown timer
}
};
// Load more profile videos (lazy load)
const loadMoreProfileVideos = async () => {
if (!profileViewUsername || profileLoading || !profileHasMore) return;
setProfileLoading(true);
try {
// Use offset/cursor for pagination
const offset = profileVideos.length;
const videosRes = await axios.get(`${API_BASE_URL}/user/videos?username=${profileViewUsername}&limit=20&offset=${offset}`);
const newVideos = videosRes.data.videos || [];
if (newVideos.length === 0) {
setProfileHasMore(false);
} else {
setProfileVideos(prev => [...prev, ...newVideos]);
setProfileHasMore(newVideos.length >= 20);
}
} catch (err) {
console.error('Error loading more profile videos:', err);
} finally {
setProfileLoading(false);
}
};
// Close profile view
const closeProfileView = () => {
setProfileViewUsername(null);
setProfileVideos([]);
setProfileUserData(null);
};
// Handle profile grid scroll for lazy loading
const handleProfileGridScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
if (scrollHeight - scrollTop <= clientHeight + 200 && profileHasMore && !profileLoading) {
loadMoreProfileVideos();
}
};
// Direct keyword search
const searchByKeyword = async (keyword: string) => {
setSearchInput(keyword);
@ -488,62 +617,72 @@ export const Feed: React.FC = () => {
setIsSearching(true);
setError(null);
startLoadingTimer(); // Start countdown timer
// Clear previous results immediately if starting a new search
// This ensures the skeleton loader is shown instead of old results
if (!isMore) {
setSearchResults([]);
setSearchMatchedUser(null);
}
try {
const cursor = isMore ? searchCursor : 0;
// "Search must show at least 50 result" - fetching 50 at a time with infinite scroll
const limit = 50;
const startCursor = isMore ? searchCursor : 0;
const cleanQuery = inputToSearch.startsWith('@') ? inputToSearch.substring(1) : inputToSearch;
let endpoint = `${API_BASE_URL}/user/search?query=${encodeURIComponent(inputToSearch)}&limit=${limit}&cursor=${cursor}`;
// If direct username search
if (inputToSearch.startsWith('@')) {
endpoint = `${API_BASE_URL}/user/videos?username=${inputToSearch.substring(1)}&limit=${limit}`;
// Step 1: Try to find a matching user profile (only on first search)
if (!isMore) {
try {
const profileRes = await axios.get(`${API_BASE_URL}/user/profile?username=${encodeURIComponent(cleanQuery)}`);
if (profileRes.data && profileRes.data.username) {
setSearchMatchedUser(profileRes.data);
console.log('Found matching user:', profileRes.data.username);
}
} catch (profileErr) {
console.log('No matching user for:', cleanQuery);
setSearchMatchedUser(null);
}
}
// Step 2: Always fetch related/suggested videos progressively
const batchSize = 10;
let totalFetched = 0;
const maxVideos = 50;
while (totalFetched < maxVideos) {
const endpoint = `${API_BASE_URL}/user/search?query=${encodeURIComponent(cleanQuery)}&limit=${batchSize}&cursor=${startCursor + totalFetched}`;
const { data } = await axios.get(endpoint);
let newVideos = data.videos || [];
const newVideos = data.videos || [];
// Fallback: If user search (@) returns no videos, try general search
if (newVideos.length === 0 && !isMore && inputToSearch.startsWith('@')) {
console.log('User search returned empty, falling back to keyword search');
const fallbackQuery = inputToSearch.substring(1); // Remove @
const fallbackEndpoint = `${API_BASE_URL}/user/search?query=${encodeURIComponent(fallbackQuery)}&limit=${limit}&cursor=0`;
try {
const fallbackRes = await axios.get(fallbackEndpoint);
if (fallbackRes.data.videos && fallbackRes.data.videos.length > 0) {
newVideos = fallbackRes.data.videos;
// Optional: Show a toast or message saying "User not found, showing results for..."
setError(`User '${inputToSearch}' not found. Showing related videos.`);
if (newVideos.length === 0) {
setSearchHasMore(false);
break;
}
} catch (fallbackErr) {
console.error('Fallback search failed', fallbackErr);
// Filter out duplicates before adding
setSearchResults(prev => {
const existingIds = new Set(prev.map(v => v.id));
const uniqueNewVideos = newVideos.filter((v: Video) => !existingIds.has(v.id));
return isMore || totalFetched > 0 ? [...prev, ...uniqueNewVideos] : uniqueNewVideos;
});
totalFetched += newVideos.length;
setSearchCursor(data.cursor || startCursor + totalFetched);
if (newVideos.length < batchSize) {
setSearchHasMore(false);
break;
}
}
if (isMore) {
setSearchResults(prev => [...prev, ...newVideos]);
} else {
setSearchResults(newVideos);
if (totalFetched >= maxVideos) {
setSearchHasMore(true);
}
setSearchCursor(data.cursor || 0);
// If we got results, assume there's more (TikTok has endless content)
// unless the count is very small (e.g. < 5) which might indicate end
setSearchHasMore(newVideos.length >= 5);
} catch (err) {
console.error('Search failed:', err);
setError('Search failed. Please try again.');
} finally {
setIsSearching(false);
stopLoadingTimer(); // Stop countdown timer
}
};
@ -561,8 +700,8 @@ export const Feed: React.FC = () => {
{/* Header */}
<div className="flex-shrink-0 pt-12 pb-6 px-6 text-center">
<div className="relative inline-block mb-4">
<div className="w-16 h-16 bg-gradient-to-r from-cyan-400 to-pink-500 rounded-2xl rotate-12 absolute -inset-1 blur-lg opacity-50" />
<div className="relative w-16 h-16 bg-gradient-to-r from-cyan-400 to-pink-500 rounded-2xl flex items-center justify-center">
<div className="w-16 h-16 bg-gradient-to-r from-gray-400 to-gray-300 rounded-2xl rotate-12 absolute -inset-1 blur-lg opacity-50" />
<div className="relative w-16 h-16 bg-gradient-to-r from-gray-400 to-gray-300 rounded-2xl flex items-center justify-center">
<svg className="w-8 h-8 text-white" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
</svg>
@ -587,7 +726,7 @@ export const Feed: React.FC = () => {
<div className="space-y-3">
<div className="flex items-start gap-3 p-3 bg-white/5 rounded-xl">
<div className="w-7 h-7 bg-cyan-500 rounded-full flex items-center justify-center flex-shrink-0 text-white font-bold text-sm">1</div>
<div className="w-7 h-7 bg-gray-500 rounded-full flex items-center justify-center flex-shrink-0 text-white font-bold text-sm">1</div>
<div>
<p className="text-white text-sm font-medium">Open TikTok in browser</p>
<p className="text-gray-500 text-xs mt-0.5">Use Chrome/Safari on your phone or computer</p>
@ -595,7 +734,7 @@ export const Feed: React.FC = () => {
</div>
<div className="flex items-start gap-3 p-3 bg-white/5 rounded-xl">
<div className="w-7 h-7 bg-pink-500 rounded-full flex items-center justify-center flex-shrink-0 text-white font-bold text-sm">2</div>
<div className="w-7 h-7 bg-gray-500 rounded-full flex items-center justify-center flex-shrink-0 text-white font-bold text-sm">2</div>
<div>
<p className="text-white text-sm font-medium">Export your cookies</p>
<p className="text-gray-500 text-xs mt-0.5">Use "Cookie-Editor" extension (Chrome/Firefox)</p>
@ -618,7 +757,7 @@ export const Feed: React.FC = () => {
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
placeholder='Paste your cookie JSON here...'
className="w-full h-32 bg-black/60 border-2 border-white/10 rounded-2xl p-4 text-white text-sm font-mono resize-none focus:outline-none focus:border-cyan-500/50 placeholder:text-gray-600"
className="w-full h-32 bg-black/60 border-2 border-white/10 rounded-2xl p-4 text-white text-sm font-mono resize-none focus:outline-none focus:border-gray-400/50 placeholder:text-gray-600"
/>
</div>
@ -627,7 +766,7 @@ export const Feed: React.FC = () => {
onClick={handleJsonLogin}
disabled={!jsonInput.trim()}
className={`w-full py-4 text-white font-semibold rounded-2xl transition-all transform active:scale-[0.98] shadow-lg text-base ${jsonInput.trim()
? 'bg-gradient-to-r from-cyan-500 to-pink-500 hover:from-cyan-400 hover:to-pink-400 shadow-pink-500/20'
? 'bg-gradient-to-r from-gray-500 to-gray-400 hover:from-gray-400 hover:to-gray-300 shadow-gray-500/20'
: 'bg-gray-700 cursor-not-allowed'
}`}
>
@ -640,7 +779,7 @@ export const Feed: React.FC = () => {
href="https://chrome.google.com/webstore/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm"
target="_blank"
rel="noopener noreferrer"
className="text-cyan-400 text-sm underline"
className="text-white/70 text-sm underline"
>
Get Cookie-Editor Extension
</a>
@ -681,10 +820,10 @@ export const Feed: React.FC = () => {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-black to-gray-950 flex flex-col items-center justify-center">
<div className="relative mb-8">
<div className="absolute inset-0 blur-xl bg-gradient-to-r from-cyan-500/30 via-pink-500/30 to-cyan-500/30 animate-pulse rounded-full scale-150" />
<div className="absolute inset-0 blur-xl bg-gradient-to-r from-gray-400/30 via-gray-300/30 to-gray-400/30 animate-pulse rounded-full scale-150" />
<div className="relative w-20 h-20 flex items-center justify-center">
<div className="absolute w-16 h-16 bg-cyan-400 rounded-xl rotate-12 animate-pulse" />
<div className="absolute w-16 h-16 bg-pink-500 rounded-xl -rotate-12 animate-pulse" style={{ animationDelay: '0.3s' }} />
<div className="absolute w-16 h-16 bg-gray-400 rounded-xl rotate-12 animate-pulse" />
<div className="absolute w-16 h-16 bg-gray-500 rounded-xl -rotate-12 animate-pulse" style={{ animationDelay: '0.3s' }} />
<div className="absolute w-16 h-16 bg-white rounded-xl flex items-center justify-center z-10">
<svg className="w-8 h-8 text-black" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
@ -774,22 +913,22 @@ export const Feed: React.FC = () => {
? '-translate-x-full opacity-0 pointer-events-none'
: 'translate-x-full opacity-0 pointer-events-none'
}`}>
{/* Video Counter */}
<div className="absolute bottom-6 right-4 z-40 px-3 py-1.5 bg-black/60 backdrop-blur-sm rounded-full border border-white/10">
{/* Video Counter - Shows loading state with blink effect */}
<div className={`absolute bottom-6 right-4 z-40 px-3 py-1.5 bg-black/60 backdrop-blur-sm rounded-full border border-white/10 transition-all ${isFetching ? 'animate-pulse border-gray-400/50' : ''}`}>
<span className="text-xs text-white/60 font-medium">
{isFetching ? (
<span className="text-white/70">
Loading {currentIndex + 1}/{videos.length}...
</span>
) : (
<>
{currentIndex + 1} / {videos.length}
{hasMore && <span className="text-cyan-400 ml-1">+</span>}
{hasMore && <span className="text-white/70 ml-1">+</span>}
</>
)}
</span>
</div>
{/* Loading Indicator */}
{isFetching && (
<div className="absolute top-16 left-1/2 -translate-x-1/2 z-40 px-4 py-2 bg-black/80 backdrop-blur-md rounded-full border border-white/10 flex items-center gap-2">
<div className="w-2 h-2 bg-cyan-400 rounded-full animate-ping" />
<span className="text-xs text-white/70">Loading more...</span>
</div>
)}
{/* Video Feed */}
<div
ref={containerRef}
@ -798,16 +937,20 @@ export const Feed: React.FC = () => {
style={{ scrollbarWidth: 'none' }}
>
{videos.map((video, index) => (
<div key={video.id} className="w-full h-screen snap-start snap-always bg-black">
<div key={video.id} className="w-full h-screen-safe snap-start snap-always bg-black">
{Math.abs(index - currentIndex) <= 1 ? (
<VideoPlayer
video={video}
isActive={activeTab === 'foryou' && index === currentIndex}
isFollowing={following.includes(video.author)}
onFollow={handleFollow}
onAuthorClick={(author) => searchByUsername(author)}
onAuthorClick={(author) => openProfileView(author)}
isMuted={isMuted}
onMuteToggle={() => setIsMuted(prev => !prev)}
onPauseChange={(paused) => {
setIsVideoPaused(paused);
setShowHeader(paused); // Show top bar when video is paused
}}
/>
) : (
/* Lightweight Placeholder */
@ -907,7 +1050,7 @@ export const Feed: React.FC = () => {
{loadingProfiles && (
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-white/10 border-t-cyan-500 rounded-full animate-spin"></div>
<div className="w-8 h-8 border-2 border-white/10 border-t-gray-400 rounded-full animate-spin"></div>
</div>
)}
@ -925,7 +1068,7 @@ export const Feed: React.FC = () => {
<img
src={profile.avatar}
alt={username}
className="w-14 h-14 rounded-full object-cover border-2 border-transparent group-hover:border-pink-500/50 transition-colors"
className="w-14 h-14 rounded-full object-cover border-2 border-transparent group-hover:border-gray-400/50 transition-colors"
/>
) : (
<div className="w-14 h-14 rounded-full bg-white/10 flex items-center justify-center text-white/60 text-lg font-medium group-hover:bg-white/20 transition-colors">
@ -993,9 +1136,19 @@ export const Feed: React.FC = () => {
<p className="text-white/20 text-xs mt-2">@username · video link · keyword</p>
</div>
{/* Loading Animation - Skeleton Grid */}
{/* Loading Animation - Skeleton Grid with Timer */}
{isSearching && (
<div className="mt-8">
{/* Loading Timer Display */}
<div className="flex items-center justify-center gap-3 mb-6">
<div className="w-5 h-5 border-2 border-white/20 border-t-gray-400 rounded-full animate-spin" />
<span className="text-white/60 text-sm">
Loading videos...
</span>
<span className="text-white/70 font-mono text-sm tabular-nums">
{Math.floor(loadingElapsed / 60)}:{(loadingElapsed % 60).toString().padStart(2, '0')}
</span>
</div>
<div className="grid grid-cols-3 gap-1 animate-pulse">
{[...Array(12)].map((_, i) => (
<div key={i} className="aspect-[9/16] bg-white/5 rounded-sm"></div>
@ -1025,9 +1178,72 @@ export const Feed: React.FC = () => {
</>
)}
{/* Matched User Profile Card */}
{searchMatchedUser && (
<div className="mb-6 p-4 bg-gradient-to-r from-white/5 to-white/10 rounded-2xl border border-white/10">
<div className="flex items-center gap-4">
{/* Avatar */}
{searchMatchedUser.avatar ? (
<img
src={searchMatchedUser.avatar}
alt={searchMatchedUser.username}
className="w-16 h-16 rounded-full object-cover border-2 border-white/20"
/>
) : (
<div className="w-16 h-16 rounded-full bg-gradient-to-r from-gray-500 to-gray-400 flex items-center justify-center text-white text-2xl font-bold">
{searchMatchedUser.username.charAt(0).toUpperCase()}
</div>
)}
{/* User Info */}
<div className="flex-1 min-w-0">
<h3 className="text-white font-bold text-lg truncate flex items-center gap-2">
@{searchMatchedUser.username}
{searchMatchedUser.verified && (
<svg className="w-4 h-4 text-white/70" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
</h3>
{searchMatchedUser.nickname && (
<p className="text-white/60 text-sm truncate">{searchMatchedUser.nickname}</p>
)}
<div className="flex items-center gap-3 mt-1 text-white/50 text-xs">
{searchMatchedUser.followers !== undefined && (
<span>
{searchMatchedUser.followers >= 1000000
? `${(searchMatchedUser.followers / 1000000).toFixed(1)}M`
: searchMatchedUser.followers >= 1000
? `${(searchMatchedUser.followers / 1000).toFixed(0)}K`
: searchMatchedUser.followers} followers
</span>
)}
{searchMatchedUser.likes !== undefined && (
<span>
{searchMatchedUser.likes >= 1000000
? `${(searchMatchedUser.likes / 1000000).toFixed(1)}M`
: searchMatchedUser.likes >= 1000
? `${(searchMatchedUser.likes / 1000).toFixed(0)}K`
: searchMatchedUser.likes} likes
</span>
)}
</div>
</div>
{/* View Profile Button */}
<button
onClick={() => openProfileView(searchMatchedUser.username)}
className="px-4 py-2 bg-gradient-to-r from-gray-500 to-gray-400 rounded-full text-white text-sm font-medium hover:opacity-90 transition-opacity"
>
View Profile
</button>
</div>
</div>
)}
{/* Search Results */}
{searchResults.length > 0 && (
<div className="mt-8">
<div className="mt-4">
<div className="flex items-center justify-between mb-4">
<span className="text-white/50 text-sm">{searchResults.length} videos</span>
<div className="flex items-center gap-2">
@ -1036,7 +1252,7 @@ export const Feed: React.FC = () => {
onClick={() => handleFollow(searchInput.substring(1))}
className={`px-3 py-1 rounded-full text-xs font-medium transition-all ${following.includes(searchInput.substring(1))
? 'bg-white/10 text-white border border-white/20'
: 'bg-pink-500 text-white'
: 'bg-gray-500 text-white'
}`}
>
{following.includes(searchInput.substring(1)) ? 'Following' : 'Follow'}
@ -1051,7 +1267,7 @@ export const Feed: React.FC = () => {
setActiveTab('foryou');
}
}}
className="px-3 py-1 bg-gradient-to-r from-cyan-500 to-pink-500 rounded-full text-xs font-medium text-white"
className="px-3 py-1 bg-gradient-to-r from-gray-500 to-gray-400 rounded-full text-xs font-medium text-white"
>
Play All
</button>
@ -1083,7 +1299,7 @@ export const Feed: React.FC = () => {
/>
) : (
<div className="w-full h-full bg-white/5 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-white/20 border-t-cyan-500 rounded-full animate-spin"></div>
<div className="w-6 h-6 border-2 border-white/20 border-t-gray-400 rounded-full animate-spin"></div>
</div>
)}
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/80 to-transparent">
@ -1112,6 +1328,186 @@ export const Feed: React.FC = () => {
<X size={20} />
</button>
)}
{/* Profile View Overlay */}
{profileViewUsername && (
<div className="fixed inset-0 z-[70] bg-black/95 overflow-hidden">
{/* Profile Header */}
<div className="sticky top-0 z-10 bg-gradient-to-b from-black via-black/90 to-transparent pt-6 pb-8 px-4">
<div className="max-w-lg mx-auto">
<div className="flex items-center gap-4">
{/* Back Button */}
<button
onClick={closeProfileView}
className="w-10 h-10 flex items-center justify-center bg-white/10 hover:bg-white/20 rounded-full text-white transition-all"
>
<X size={20} />
</button>
{/* Avatar */}
{profileUserData?.avatar ? (
<img
src={profileUserData.avatar}
alt={profileViewUsername}
className="w-16 h-16 rounded-full object-cover border-2 border-white/20"
/>
) : (
<div className="w-16 h-16 rounded-full bg-gradient-to-r from-gray-500 to-gray-400 flex items-center justify-center text-white text-2xl font-bold">
{profileViewUsername.charAt(0).toUpperCase()}
</div>
)}
{/* User Info */}
<div className="flex-1 min-w-0">
<h2 className="text-white font-bold text-base truncate">
@{profileViewUsername}
</h2>
{profileUserData?.nickname && (
<p className="text-white/60 text-xs truncate">{profileUserData.nickname}</p>
)}
<div className="flex items-center gap-3 mt-1">
{profileUserData?.followers !== undefined && (
<span className="text-white/50 text-[10px]">
{profileUserData.followers >= 1000000
? `${(profileUserData.followers / 1000000).toFixed(1)}M`
: profileUserData.followers >= 1000
? `${(profileUserData.followers / 1000).toFixed(0)}K`
: profileUserData.followers} followers
</span>
)}
{profileVideos.length > 0 && (
<span className="text-white/50 text-xs">
{profileVideos.length} videos
</span>
)}
</div>
</div>
{/* Follow Button */}
<button
onClick={() => handleFollow(profileViewUsername)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-all ${following.includes(profileViewUsername)
? 'bg-white/10 text-white border border-white/20'
: 'bg-gradient-to-r from-gray-500 to-gray-400 text-white'
}`}
>
{following.includes(profileViewUsername) ? 'Following' : 'Follow'}
</button>
</div>
</div>
</div>
{/* Video Grid */}
<div
ref={profileGridRef}
className="h-[calc(100vh-120px)] overflow-y-auto px-1"
onScroll={handleProfileGridScroll}
>
<div className="max-w-lg mx-auto">
{/* Loading Skeleton (Initial Load) with Timer */}
{profileLoading && profileVideos.length === 0 && (
<div>
{/* Loading Timer Display */}
<div className="flex items-center justify-center gap-3 mb-6">
<div className="w-5 h-5 border-2 border-white/20 border-t-gray-400 rounded-full animate-spin" />
<span className="text-white/60 text-sm">
Loading profile...
</span>
<span className="text-white/70 font-mono text-sm tabular-nums">
{Math.floor(loadingElapsed / 60)}:{(loadingElapsed % 60).toString().padStart(2, '0')}
</span>
</div>
<div className="grid grid-cols-3 gap-1 animate-pulse">
{[...Array(12)].map((_, i) => (
<div key={i} className="aspect-[9/16] bg-white/5 rounded-sm" />
))}
</div>
</div>
)}
{/* Video Grid */}
{profileVideos.length > 0 && (
<div className="grid grid-cols-3 gap-1">
{profileVideos.map((video) => (
<div
key={video.id}
className="relative aspect-[9/16] overflow-hidden group cursor-pointer"
onClick={() => {
if (!video.url) return;
// Save current state for back navigation
setOriginalVideos(videos);
setOriginalIndex(currentIndex);
// Set videos to profile videos and play
const playableVideos = profileVideos.filter(v => v.url);
setVideos(playableVideos);
const newIndex = playableVideos.findIndex(v => v.id === video.id);
setCurrentIndex(newIndex >= 0 ? newIndex : 0);
setIsInSearchPlayback(true);
setActiveTab('foryou');
closeProfileView();
}}
>
{video.thumbnail ? (
<img
src={video.thumbnail}
alt={video.description || video.author}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
loading="lazy"
/>
) : (
<div className="w-full h-full bg-white/5 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-white/20 border-t-gray-400 rounded-full animate-spin" />
</div>
)}
{/* Hover Overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center">
<svg className="w-10 h-10 text-white opacity-0 group-hover:opacity-80 transition-opacity" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
</div>
{/* Views Badge */}
{video.views && (
<div className="absolute bottom-1 left-1 text-white/80 text-xs font-medium drop-shadow-lg">
{video.views >= 1000000
? `${(video.views / 1000000).toFixed(1)}M`
: video.views >= 1000
? `${(video.views / 1000).toFixed(0)}K`
: video.views}
</div>
)}
</div>
))}
</div>
)}
{/* Loading More Indicator */}
{profileLoading && profileVideos.length > 0 && (
<div className="flex justify-center py-6">
<div className="w-6 h-6 border-2 border-white/10 border-t-gray-400 rounded-full animate-spin" />
</div>
)}
{/* No More Videos */}
{!profileHasMore && profileVideos.length > 0 && (
<p className="text-center text-white/30 text-sm py-6">No more videos</p>
)}
{/* Empty State */}
{!profileLoading && profileVideos.length === 0 && (
<div className="flex flex-col items-center justify-center py-16">
<div className="w-16 h-16 rounded-full bg-white/5 flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-white/30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="2" width="20" height="20" rx="2" />
<path d="M10 8l6 4-6 4V8z" />
</svg>
</div>
<p className="text-white/50 text-sm">No videos found</p>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};

View file

@ -23,7 +23,7 @@ export const SearchBar: React.FC<SearchBarProps> = ({ onSearch }) => {
{/* Search Icon / Toggle */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`text-white p-3 hover:text-pink-500 transition-colors duration-300 ${isOpen ? '' : 'w-full h-full flex items-center justify-center'}`}
className={`text-white p-3 hover:text-white transition-colors duration-300 ${isOpen ? '' : 'w-full h-full flex items-center justify-center'}`}
>
<Search size={isOpen ? 18 : 20} className={isOpen ? 'opacity-60' : ''} />
</button>
@ -41,7 +41,7 @@ export const SearchBar: React.FC<SearchBarProps> = ({ onSearch }) => {
</form>
{isOpen && (
<div className="absolute inset-0 bg-gradient-to-r from-pink-500/10 to-violet-500/10 pointer-events-none" />
<div className="absolute inset-0 bg-gradient-to-r from-gray-400/10 to-gray-300/10 pointer-events-none" />
)}
</div>
);

View file

@ -25,6 +25,7 @@ interface VideoPlayerProps {
onAuthorClick?: (author: string) => void; // In-app navigation to creator
isMuted?: boolean; // Global mute state from parent
onMuteToggle?: () => void; // Callback to toggle parent mute state
onPauseChange?: (isPaused: boolean) => void; // Notify parent when play/pause state changes
}
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
@ -34,14 +35,15 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
onFollow,
onAuthorClick,
isMuted: externalMuted,
onMuteToggle
onMuteToggle,
onPauseChange
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
const [isPaused, setIsPaused] = useState(false);
const [showControls, setShowControls] = useState(false);
const [objectFit] = useState<'cover' | 'contain'>('contain');
const [objectFit] = useState<'cover' | 'contain'>('cover');
const [progress, setProgress] = useState(0);
const [duration, setDuration] = useState(0);
const [isSeeking, setIsSeeking] = useState(false);
@ -206,9 +208,11 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
if (videoRef.current.paused) {
videoRef.current.play();
setIsPaused(false);
onPauseChange?.(false);
} else {
videoRef.current.pause();
setIsPaused(true);
onPauseChange?.(true);
}
};
@ -390,7 +394,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
{/* Loading Overlay - Subtle pulsing logo */}
{isLoading && !codecError && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 z-20">
<div className="w-16 h-16 bg-gradient-to-r from-cyan-400/80 to-pink-500/80 rounded-2xl flex items-center justify-center animate-pulse">
<div className="w-16 h-16 bg-gradient-to-r from-gray-400/80 to-gray-300/80 rounded-2xl flex items-center justify-center animate-pulse">
<svg className="w-8 h-8 text-white" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
</svg>
@ -409,7 +413,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
<a
href={downloadUrl}
download
className="px-4 py-2 bg-gradient-to-r from-cyan-500 to-pink-500 text-white text-sm font-medium rounded-full hover:opacity-90 transition-opacity"
className="px-4 py-2 bg-gradient-to-r from-gray-500 to-gray-400 text-white text-sm font-medium rounded-full hover:opacity-90 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
Download Video
@ -427,7 +431,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
top: heart.y - 24,
}}
>
<svg className="w-16 h-16 text-pink-500 drop-shadow-xl filter drop-shadow-lg" viewBox="0 0 24 24" fill="currentColor">
<svg className="w-16 h-16 text-white drop-shadow-xl filter drop-shadow-lg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
</div>
@ -459,7 +463,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
onTouchEnd={handleSeekEnd}
>
<div
className="h-full bg-gradient-to-r from-cyan-400 to-pink-500 transition-all pointer-events-none"
className="h-full bg-gradient-to-r from-gray-400 to-gray-300 transition-all pointer-events-none"
style={{ width: duration ? `${(progress / duration) * 100}%` : '0%' }}
/>
{/* Scrubber Thumb (always visible when seeking or on hover) */}
@ -478,9 +482,9 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
)}
</div>
{/* Side Controls - Hidden by default, reveal on swipe/interaction */}
{/* Side Controls - Only show when video is paused */}
<div
className={`absolute bottom-36 right-4 flex flex-col gap-3 transition-all duration-300 transform ${showSidebar ? 'translate-x-0 opacity-100' : 'translate-x-[200%] opacity-0'
className={`absolute bottom-36 right-4 flex flex-col gap-3 transition-all duration-300 transform ${isPaused && showSidebar ? 'translate-x-0 opacity-100' : 'translate-x-[200%] opacity-0'
}`}
>
{/* Follow Button */}
@ -488,7 +492,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
<button
onClick={(e) => { e.stopPropagation(); onFollow(video.author); }}
className={`w-12 h-12 flex items-center justify-center backdrop-blur-xl border border-white/10 rounded-full transition-all ${isFollowing
? 'bg-pink-500 text-white'
? 'bg-gray-500 text-white'
: 'bg-white/10 hover:bg-white/20 text-white'
}`}
title={isFollowing ? 'Following' : 'Follow'}
@ -517,15 +521,15 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
</button>
</div>
{/* Author Info */}
<div className="absolute bottom-10 left-4 right-20 z-10">
{/* Author Info - Only show when video is paused */}
<div className={`absolute bottom-10 left-4 right-20 z-10 transition-opacity duration-300 ${isPaused ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
onAuthorClick?.(video.author);
}}
className="text-white font-semibold text-sm truncate hover:text-cyan-400 transition-colors inline-flex items-center gap-1"
className="text-white font-semibold text-sm truncate hover:text-white/70 transition-colors inline-flex items-center gap-1"
>
@{video.author}
<svg className="w-3 h-3 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@ -551,12 +555,12 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
)}
</div>
{/* Bottom Gradient */}
<div className="absolute bottom-0 left-0 w-full h-32 bg-gradient-to-t from-black/80 to-transparent pointer-events-none" />
{/* Bottom Gradient - Only show when video is paused */}
<div className={`absolute bottom-0 left-0 w-full h-32 bg-gradient-to-t from-black/80 to-transparent pointer-events-none transition-opacity duration-300 ${isPaused ? 'opacity-100' : 'opacity-0'}`} />
{/* Right Sidebar Hint - Small vertical bar/glow on edge */}
{/* Right Sidebar Hint - Only show when video is paused */}
<div
className={`absolute top-0 right-0 w-6 h-full z-40 flex items-center justify-end cursor-pointer ${showSidebar ? 'pointer-events-none' : ''}`}
className={`absolute top-0 right-0 w-6 h-full z-40 flex items-center justify-end cursor-pointer transition-opacity duration-300 ${isPaused ? 'opacity-100' : 'opacity-0 pointer-events-none'} ${showSidebar ? 'pointer-events-none' : ''}`}
onClick={(e) => { e.stopPropagation(); setShowSidebar(true); }}
onTouchEnd={() => {
// Check if it was a swipe logic here or just rely on the click/tap

View file

@ -9,6 +9,12 @@
#root {
@apply h-full overflow-hidden;
}
/* Mobile-safe full height - accounts for browser chrome */
.h-screen-safe {
height: 100vh;
height: 100dvh;
}
}
@layer utilities {