diff --git a/barrelsby.json b/barrelsby.json
index 5ab555c9..eacb11f0 100644
--- a/barrelsby.json
+++ b/barrelsby.json
@@ -18,6 +18,21 @@
"./src/data/repositories",
"./src/data/services",
"./src/data/services/auth",
- "./src/design"
+ "./src/design",
+ "./src/components/core",
+ "./src/components/splash",
+ "./src/components/auth",
+ "./src/components/chat",
+ "./src/components/sidebar",
+ "./src/contexts/auth",
+ "./src/contexts/chat",
+ "./src/contexts/sidebar",
+ "./src/contexts/user",
+ "./src/hooks",
+ "./src/integrations",
+ "./src/models/auth",
+ "./src/models/chat",
+ "./src/models/sidebar",
+ "./src/models/user"
]
}
diff --git a/next.config.ts b/next.config.ts
index e9ffa308..a0836196 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,7 +1,13 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- /* config options here */
+ // Next.js 16 临时绕过 `/_global-error` prerender 的 workStore bug
+ // 标记根 layout 不参与静态预渲染(global-error 会在请求时渲染)
+ // https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config
+ experimental: {
+ // 关闭静态优化以规避 16.2.7 global-error prerender bug
+ staticGenerationRetryCount: 0,
+ },
};
export default nextConfig;
diff --git a/package.json b/package.json
index 2c19da93..bfa618be 100644
--- a/package.json
+++ b/package.json
@@ -8,13 +8,18 @@
"start": "next start",
"lint": "eslint",
"lint:fix": "eslint --fix",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:ui": "vitest --ui",
"generate-barrels": "barrelsby --delete -c barrelsby.json"
},
"dependencies": {
"@fingerprintjs/fingerprintjs": "^5.2.0",
"@greatsumini/react-facebook-login": "^3.4.0",
"@react-oauth/google": "^0.13.5",
+ "classnames": "^2.5.1",
"dexie": "^4.4.3",
+ "lottie-react": "^2.4.1",
"next": "16.2.7",
"ofetch": "^1.5.1",
"react": "19.2.4",
@@ -26,10 +31,14 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "@vitest/coverage-v8": "^4.1.8",
+ "@vitest/ui": "^4.1.8",
"barrelsby": "^2.8.1",
"eslint": "^9",
"eslint-config-next": "16.2.7",
+ "jsdom": "^29.1.1",
"tailwindcss": "^4",
- "typescript": "^5"
+ "typescript": "^5",
+ "vitest": "^4.1.8"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 04e4c39d..8db28203 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -17,9 +17,15 @@ importers:
'@react-oauth/google':
specifier: ^0.13.5
version: 0.13.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ classnames:
+ specifier: ^2.5.1
+ version: 2.5.1
dexie:
specifier: ^4.4.3
version: 4.4.3
+ lottie-react:
+ specifier: ^2.4.1
+ version: 2.4.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next:
specifier: 16.2.7
version: 16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -48,6 +54,12 @@ importers:
'@types/react-dom':
specifier: ^19
version: 19.2.3(@types/react@19.2.16)
+ '@vitest/coverage-v8':
+ specifier: ^4.1.8
+ version: 4.1.8(vitest@4.1.8)
+ '@vitest/ui':
+ specifier: ^4.1.8
+ version: 4.1.8(vitest@4.1.8)
barrelsby:
specifier: ^2.8.1
version: 2.8.1
@@ -57,12 +69,18 @@ importers:
eslint-config-next:
specifier: 16.2.7
version: 16.2.7(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
+ jsdom:
+ specifier: ^29.1.1
+ version: 29.1.1
tailwindcss:
specifier: ^4
version: 4.3.0
typescript:
specifier: ^5
version: 5.9.3
+ vitest:
+ specifier: ^4.1.8
+ version: 4.1.8(@types/node@20.19.41)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@20.19.41)(jiti@2.7.0))
packages:
@@ -70,6 +88,21 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
+ '@asamuzakjp/css-color@5.1.11':
+ resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/dom-selector@7.1.1':
+ resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/generational-cache@1.0.1':
+ resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/nwsapi@2.3.9':
+ resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
+
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -137,6 +170,50 @@ packages:
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
+ '@bcoe/v8-coverage@1.0.2':
+ resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
+ engines: {node: '>=18'}
+
+ '@bramus/specificity@2.4.2':
+ resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
+ hasBin: true
+
+ '@csstools/color-helpers@6.0.2':
+ resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
+ engines: {node: '>=20.19.0'}
+
+ '@csstools/css-calc@3.2.1':
+ resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-color-parser@4.1.1':
+ resolution: {integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0':
+ resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.5':
+ resolution: {integrity: sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==}
+ peerDependencies:
+ css-tree: ^3.2.1
+ peerDependenciesMeta:
+ css-tree:
+ optional: true
+
+ '@csstools/css-tokenizer@4.0.0':
+ resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
+ engines: {node: '>=20.19.0'}
+
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@@ -184,6 +261,15 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@exodus/bytes@1.15.1':
+ resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ peerDependencies:
+ '@noble/hashes': ^1.8.0 || ^2.0.0
+ peerDependenciesMeta:
+ '@noble/hashes':
+ optional: true
+
'@fingerprintjs/fingerprintjs@5.2.0':
resolution: {integrity: sha512-j+2nInkwCQNTJcNhOjvkGM/nLRTuGJTC6xai4quqvUpjob2ssrGwBZjS7k55nOmKvge7qvJT2nS3i/IRvQSTQA==}
@@ -461,15 +547,122 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
+ '@oxc-project/types@0.133.0':
+ resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
+
+ '@polka/url@1.0.0-next.29':
+ resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
+
'@react-oauth/google@0.13.5':
resolution: {integrity: sha512-xQWri2s/3nNekZJ4uuov2aAfQYu83bN3864KcFqw2pK1nNbFurQIjPFDXhWaKH3IjYJ2r/9yyIIpsn5lMqrheQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
+ '@rolldown/binding-android-arm64@1.0.3':
+ resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [android]
+
+ '@rolldown/binding-darwin-arm64@1.0.3':
+ resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rolldown/binding-darwin-x64@1.0.3':
+ resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rolldown/binding-freebsd-x64@1.0.3':
+ resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.3':
+ resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@rolldown/binding-linux-arm64-gnu@1.0.3':
+ resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-arm64-musl@1.0.3':
+ resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rolldown/binding-linux-ppc64-gnu@1.0.3':
+ resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6w0XaOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-s390x-gnu@1.0.3':
+ resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-x64-gnu@1.0.3':
+ resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-x64-musl@1.0.3':
+ resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rolldown/binding-openharmony-arm64@1.0.3':
+ resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rolldown/binding-wasm32-wasi@1.0.3':
+ resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [wasm32]
+
+ '@rolldown/binding-win32-arm64-msvc@1.0.3':
+ resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rolldown/binding-win32-x64-msvc@1.0.3':
+ resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@rolldown/pluginutils@1.0.1':
+ resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
+
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -568,6 +761,12 @@ packages:
'@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
+ '@types/chai@5.2.3':
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+
+ '@types/deep-eql@4.0.2':
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
@@ -773,6 +972,49 @@ packages:
cpu: [x64]
os: [win32]
+ '@vitest/coverage-v8@4.1.8':
+ resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==}
+ peerDependencies:
+ '@vitest/browser': 4.1.8
+ vitest: 4.1.8
+ peerDependenciesMeta:
+ '@vitest/browser':
+ optional: true
+
+ '@vitest/expect@4.1.8':
+ resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==}
+
+ '@vitest/mocker@4.1.8':
+ resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
+ '@vitest/pretty-format@4.1.8':
+ resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==}
+
+ '@vitest/runner@4.1.8':
+ resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==}
+
+ '@vitest/snapshot@4.1.8':
+ resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==}
+
+ '@vitest/spy@4.1.8':
+ resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==}
+
+ '@vitest/ui@4.1.8':
+ resolution: {integrity: sha512-RUS2ZU2TsduVrI+9c12uTNaKrNUTsm6yFt3fueEUB9iKvyC2UP83F+sqIz00HQIah4UOL1TMoDAki8K0NjGvsA==}
+ peerDependencies:
+ vitest: 4.1.8
+
+ '@vitest/utils@4.1.8':
+ resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==}
+
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -837,9 +1079,16 @@ packages:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'}
+ assertion-error@2.0.1:
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+ engines: {node: '>=12'}
+
ast-types-flow@0.0.8:
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
+ ast-v8-to-istanbul@1.0.3:
+ resolution: {integrity: sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==}
+
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
@@ -873,6 +1122,9 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ bidi-js@1.0.3:
+ resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
+
brace-expansion@1.1.15:
resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}
@@ -908,6 +1160,10 @@ packages:
caniuse-lite@1.0.30001793:
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
+ chai@6.2.2:
+ resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
+ engines: {node: '>=18'}
+
chalk@2.4.2:
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
engines: {node: '>=4'}
@@ -916,6 +1172,9 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
+ classnames@2.5.1:
+ resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
+
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
@@ -946,12 +1205,20 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
+ css-tree@3.2.1:
+ resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
+ engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
+
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
+ data-urls@7.0.0:
+ resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
data-view-buffer@1.0.2:
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
engines: {node: '>= 0.4'}
@@ -981,6 +1248,9 @@ packages:
supports-color:
optional: true
+ decimal.js@10.6.0:
+ resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -1023,6 +1293,10 @@ packages:
resolution: {integrity: sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==}
engines: {node: '>=10.13.0'}
+ entities@8.0.0:
+ resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
+ engines: {node: '>=20.19.0'}
+
error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
@@ -1042,6 +1316,9 @@ packages:
resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==}
engines: {node: '>= 0.4'}
+ es-module-lexer@2.1.0:
+ resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
+
es-object-atoms@1.1.2:
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'}
@@ -1186,10 +1463,17 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
+ estree-walker@3.0.3:
+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
+ expect-type@1.3.0:
+ resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
+ engines: {node: '>=12.0.0'}
+
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -1215,6 +1499,9 @@ packages:
picomatch:
optional: true
+ fflate@0.8.3:
+ resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
+
figures@2.0.0:
resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==}
engines: {node: '>=4'}
@@ -1246,6 +1533,11 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@@ -1347,6 +1639,13 @@ packages:
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
+ html-encoding-sniffer@6.0.0:
+ resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ html-escaper@2.0.2:
+ resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
+
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -1441,6 +1740,9 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
+ is-potential-custom-element-name@1.0.1:
+ resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+
is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'}
@@ -1483,6 +1785,18 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+ istanbul-lib-coverage@3.2.2:
+ resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
+ engines: {node: '>=8'}
+
+ istanbul-lib-report@3.0.1:
+ resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
+ engines: {node: '>=10'}
+
+ istanbul-reports@3.2.0:
+ resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
+ engines: {node: '>=8'}
+
iterator.prototype@1.1.5:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'}
@@ -1491,6 +1805,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
+ js-tokens@10.0.0:
+ resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -1498,6 +1815,15 @@ packages:
resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
hasBin: true
+ jsdom@29.1.1:
+ resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
+ peerDependencies:
+ canvas: ^3.0.0
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@@ -1635,16 +1961,39 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
+ lottie-react@2.4.1:
+ resolution: {integrity: sha512-LQrH7jlkigIIv++wIyrOYFLHSKQpEY4zehPicL9bQsrt1rnoKRYCYgpCUe5maqylNtacy58/sQDZTkwMcTRxZw==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ lottie-web@5.13.0:
+ resolution: {integrity: sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==}
+
+ lru-cache@11.5.1:
+ resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==}
+ engines: {node: 20 || >=22}
+
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+ magicast@0.5.3:
+ resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==}
+
+ make-dir@4.0.0:
+ resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
+ engines: {node: '>=10'}
+
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
+ mdn-data@2.27.1:
+ resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
+
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@@ -1663,6 +2012,10 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+ mrmime@2.0.1:
+ resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
+ engines: {node: '>=10'}
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1743,6 +2096,10 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
+ obug@2.1.2:
+ resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==}
+ engines: {node: '>=12.20.0'}
+
ofetch@1.5.1:
resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==}
@@ -1782,6 +2139,9 @@ packages:
resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==}
engines: {node: '>=4'}
+ parse5@8.0.1:
+ resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
+
path-exists@3.0.0:
resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==}
engines: {node: '>=4'}
@@ -1797,6 +2157,9 @@ packages:
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+ pathe@2.0.3:
+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1866,6 +2229,10 @@ packages:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
+ require-from-string@2.0.2:
+ resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+ engines: {node: '>=0.10.0'}
+
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -1882,6 +2249,11 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+ rolldown@1.0.3:
+ resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -1897,6 +2269,10 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'}
+ saxes@6.0.0:
+ resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
+ engines: {node: '>=v12.22.7'}
+
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -1949,10 +2325,17 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
+ siginfo@2.0.0:
+ resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
signale@1.4.0:
resolution: {integrity: sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w==}
engines: {node: '>=6'}
+ sirv@3.0.2:
+ resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
+ engines: {node: '>=18'}
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1960,6 +2343,12 @@ packages:
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
+ stackback@0.0.2:
+ resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
+ std-env@4.1.0:
+ resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
+
stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
@@ -2028,6 +2417,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
+ symbol-tree@3.2.4:
+ resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+
tailwindcss@4.3.0:
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
@@ -2035,14 +2427,44 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
+ tinybench@2.9.0:
+ resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
+ tinyexec@1.2.4:
+ resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
+ engines: {node: '>=18'}
+
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
+ tinyrainbow@3.1.0:
+ resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
+ engines: {node: '>=14.0.0'}
+
+ tldts-core@7.4.2:
+ resolution: {integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==}
+
+ tldts@7.4.2:
+ resolution: {integrity: sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==}
+ hasBin: true
+
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
+ totalist@3.0.1:
+ resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
+ engines: {node: '>=6'}
+
+ tough-cookie@6.0.1:
+ resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
+ engines: {node: '>=16'}
+
+ tr46@6.0.0:
+ resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
+ engines: {node: '>=20'}
+
ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'}
@@ -2097,6 +2519,10 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+ undici@7.27.2:
+ resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==}
+ engines: {node: '>=20.18.1'}
+
unrs-resolver@1.12.2:
resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==}
@@ -2109,6 +2535,106 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+ vite@8.0.16:
+ resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ '@vitejs/devtools': ^0.1.18
+ esbuild: ^0.27.0 || ^0.28.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ '@vitejs/devtools':
+ optional: true
+ esbuild:
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ vitest@4.1.8:
+ resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==}
+ engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@opentelemetry/api': ^1.9.0
+ '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
+ '@vitest/browser-playwright': 4.1.8
+ '@vitest/browser-preview': 4.1.8
+ '@vitest/browser-webdriverio': 4.1.8
+ '@vitest/coverage-istanbul': 4.1.8
+ '@vitest/coverage-v8': 4.1.8
+ '@vitest/ui': 4.1.8
+ happy-dom: '*'
+ jsdom: '*'
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@opentelemetry/api':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser-playwright':
+ optional: true
+ '@vitest/browser-preview':
+ optional: true
+ '@vitest/browser-webdriverio':
+ optional: true
+ '@vitest/coverage-istanbul':
+ optional: true
+ '@vitest/coverage-v8':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
+ w3c-xmlserializer@5.0.0:
+ resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
+ engines: {node: '>=18'}
+
+ webidl-conversions@8.0.1:
+ resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
+ engines: {node: '>=20'}
+
+ whatwg-mimetype@5.0.0:
+ resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
+ engines: {node: '>=20'}
+
+ whatwg-url@16.0.1:
+ resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -2130,6 +2656,11 @@ packages:
engines: {node: '>= 8'}
hasBin: true
+ why-is-node-running@2.3.0:
+ resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+ engines: {node: '>=8'}
+ hasBin: true
+
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
@@ -2138,6 +2669,13 @@ packages:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
+ xml-name-validator@5.0.0:
+ resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
+ engines: {node: '>=18'}
+
+ xmlchars@2.2.0:
+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -2170,6 +2708,26 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
+ '@asamuzakjp/css-color@5.1.11':
+ dependencies:
+ '@asamuzakjp/generational-cache': 1.0.1
+ '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@asamuzakjp/dom-selector@7.1.1':
+ dependencies:
+ '@asamuzakjp/generational-cache': 1.0.1
+ '@asamuzakjp/nwsapi': 2.3.9
+ bidi-js: 1.0.3
+ css-tree: 3.2.1
+ is-potential-custom-element-name: 1.0.1
+
+ '@asamuzakjp/generational-cache@1.0.1': {}
+
+ '@asamuzakjp/nwsapi@2.3.9': {}
+
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -2270,6 +2828,36 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
+ '@bcoe/v8-coverage@1.0.2': {}
+
+ '@bramus/specificity@2.4.2':
+ dependencies:
+ css-tree: 3.2.1
+
+ '@csstools/color-helpers@6.0.2': {}
+
+ '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/color-helpers': 6.0.2
+ '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.5(css-tree@3.2.1)':
+ optionalDependencies:
+ css-tree: 3.2.1
+
+ '@csstools/css-tokenizer@4.0.0': {}
+
'@emnapi/core@1.10.0':
dependencies:
'@emnapi/wasi-threads': 1.2.1
@@ -2332,6 +2920,8 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
+ '@exodus/bytes@1.15.1': {}
+
'@fingerprintjs/fingerprintjs@5.2.0': {}
'@greatsumini/react-facebook-login@3.4.0(react@19.2.4)':
@@ -2517,18 +3107,67 @@ snapshots:
'@nodelib/fs.walk@1.2.8':
dependencies:
'@nodelib/fs.scandir': 2.1.5
- fastq: 1.20.1
-
'@nolyfill/is-core-module@1.0.39': {}
+ '@oxc-project/types@0.133.0': {}
+
+ '@polka/url@1.0.0-next.29': {}
+
'@react-oauth/google@0.13.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
- '@rtsao/scc@1.1.0': {}
+ '@rolldown/binding-android-arm64@1.0.3':
+ optional: true
- '@swc/helpers@0.5.15':
+ '@rolldown/binding-darwin-arm64@1.0.3':
+ optional: true
+
+ '@rolldown/binding-darwin-x64@1.0.3':
+ optional: true
+
+ '@rolldown/binding-freebsd-x64@1.0.3':
+ optional: true
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.3':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-gnu@1.0.3':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-musl@1.0.3':
+ optional: true
+
+ '@rolldown/binding-linux-ppc64-gnu@1.0.3':
+ optional: true
+
+ '@rolldown/binding-linux-s390x-gnu@1.0.3':
+ optional: true
+
+ '@rolldown/binding-linux-x64-gnu@1.0.3':
+ optional: true
+
+ '@rolldown/binding-linux-x64-musl@1.0.3':
+ optional: true
+
+ '@rolldown/binding-openharmony-arm64@1.0.3':
+ optional: true
+
+ '@rolldown/binding-wasm32-wasi@1.0.3':
+ dependencies:
+ '@emnapi/core': 1.10.0
+ '@emnapi/runtime': 1.10.0
+ '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
+ optional: true
+
+ '@rolldown/binding-win32-arm64-msvc@1.0.3':
+ optional: true
+
+ '@rolldown/binding-win32-x64-msvc@1.0.3':
+ optional: true
+
+ '@rolldown/pluginutils@1.0.1': {}
dependencies:
tslib: 2.8.1
@@ -2606,6 +3245,13 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@types/chai@5.2.3':
+ dependencies:
+ '@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
+
+ '@types/deep-eql@4.0.2': {}
+
'@types/estree@1.0.9': {}
'@types/json-schema@7.0.15': {}
@@ -2791,6 +3437,72 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true
+ '@vitest/coverage-v8@4.1.8(vitest@4.1.8)':
+ dependencies:
+ '@bcoe/v8-coverage': 1.0.2
+ '@vitest/utils': 4.1.8
+ ast-v8-to-istanbul: 1.0.3
+ istanbul-lib-coverage: 3.2.2
+ istanbul-lib-report: 3.0.1
+ istanbul-reports: 3.2.0
+ magicast: 0.5.3
+ obug: 2.1.2
+ std-env: 4.1.0
+ tinyrainbow: 3.1.0
+ vitest: 4.1.8(@types/node@20.19.41)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@20.19.41)(jiti@2.7.0))
+
+ '@vitest/expect@4.1.8':
+ dependencies:
+ '@standard-schema/spec': 1.1.0
+ '@types/chai': 5.2.3
+ '@vitest/spy': 4.1.8
+ '@vitest/utils': 4.1.8
+ chai: 6.2.2
+ tinyrainbow: 3.1.0
+
+ '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@20.19.41)(jiti@2.7.0))':
+ dependencies:
+ '@vitest/spy': 4.1.8
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 8.0.16(@types/node@20.19.41)(jiti@2.7.0)
+
+ '@vitest/pretty-format@4.1.8':
+ dependencies:
+ tinyrainbow: 3.1.0
+
+ '@vitest/runner@4.1.8':
+ dependencies:
+ '@vitest/utils': 4.1.8
+ pathe: 2.0.3
+
+ '@vitest/snapshot@4.1.8':
+ dependencies:
+ '@vitest/pretty-format': 4.1.8
+ '@vitest/utils': 4.1.8
+ magic-string: 0.30.21
+ pathe: 2.0.3
+
+ '@vitest/spy@4.1.8': {}
+
+ '@vitest/ui@4.1.8(vitest@4.1.8)':
+ dependencies:
+ '@vitest/utils': 4.1.8
+ fflate: 0.8.3
+ flatted: 3.4.2
+ pathe: 2.0.3
+ sirv: 3.0.2
+ tinyglobby: 0.2.17
+ tinyrainbow: 3.1.0
+ vitest: 4.1.8(@types/node@20.19.41)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@20.19.41)(jiti@2.7.0))
+
+ '@vitest/utils@4.1.8':
+ dependencies:
+ '@vitest/pretty-format': 4.1.8
+ convert-source-map: 2.0.0
+ tinyrainbow: 3.1.0
+
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
acorn: 8.16.0
@@ -2885,8 +3597,16 @@ snapshots:
get-intrinsic: 1.3.0
is-array-buffer: 3.0.5
+ assertion-error@2.0.1: {}
+
ast-types-flow@0.0.8: {}
+ ast-v8-to-istanbul@1.0.3:
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.31
+ estree-walker: 3.0.3
+ js-tokens: 10.0.0
+
async-function@1.0.0: {}
available-typed-arrays@1.0.7:
@@ -2909,6 +3629,10 @@ snapshots:
baseline-browser-mapping@2.10.33: {}
+ bidi-js@1.0.3:
+ dependencies:
+ require-from-string: 2.0.2
+
brace-expansion@1.1.15:
dependencies:
balanced-match: 1.0.2
@@ -2951,6 +3675,8 @@ snapshots:
caniuse-lite@1.0.30001793: {}
+ chai@6.2.2: {}
+
chalk@2.4.2:
dependencies:
ansi-styles: 3.2.1
@@ -2962,6 +3688,8 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
+ classnames@2.5.1: {}
+
client-only@0.0.1: {}
cliui@8.0.1:
@@ -2992,10 +3720,22 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
+ css-tree@3.2.1:
+ dependencies:
+ mdn-data: 2.27.1
+ source-map-js: 1.2.1
+
csstype@3.2.3: {}
damerau-levenshtein@1.0.8: {}
+ data-urls@7.0.0:
+ dependencies:
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
data-view-buffer@1.0.2:
dependencies:
call-bound: 1.0.4
@@ -3022,6 +3762,8 @@ snapshots:
dependencies:
ms: 2.1.3
+ decimal.js@10.6.0: {}
+
deep-is@0.1.4: {}
define-data-property@1.1.4:
@@ -3063,6 +3805,8 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.3
+ entities@8.0.0: {}
+
error-ex@1.3.4:
dependencies:
is-arrayish: 0.2.1
@@ -3147,6 +3891,8 @@ snapshots:
iterator.prototype: 1.1.5
math-intrinsics: 1.1.0
+ es-module-lexer@2.1.0: {}
+
es-object-atoms@1.1.2:
dependencies:
es-errors: 1.3.0
@@ -3377,8 +4123,14 @@ snapshots:
estraverse@5.3.0: {}
+ estree-walker@3.0.3:
+ dependencies:
+ '@types/estree': 1.0.9
+
esutils@2.0.3: {}
+ expect-type@1.3.0: {}
+
fast-deep-equal@3.1.3: {}
fast-glob@3.3.1:
@@ -3401,6 +4153,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.4
+ fflate@0.8.3: {}
+
figures@2.0.0:
dependencies:
escape-string-regexp: 1.0.5
@@ -3433,6 +4187,9 @@ snapshots:
dependencies:
is-callable: 1.2.7
+ fsevents@2.3.3:
+ optional: true
+
function-bind@1.1.2: {}
function.prototype.name@1.1.8:
@@ -3531,6 +4288,14 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
+ html-encoding-sniffer@6.0.0:
+ dependencies:
+ '@exodus/bytes': 1.15.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
+ html-escaper@2.0.2: {}
+
ignore@5.3.2: {}
ignore@7.0.5: {}
@@ -3625,6 +4390,8 @@ snapshots:
is-number@7.0.0: {}
+ is-potential-custom-element-name@1.0.1: {}
+
is-regex@1.2.1:
dependencies:
call-bound: 1.0.4
@@ -3668,6 +4435,19 @@ snapshots:
isexe@2.0.0: {}
+ istanbul-lib-coverage@3.2.2: {}
+
+ istanbul-lib-report@3.0.1:
+ dependencies:
+ istanbul-lib-coverage: 3.2.2
+ make-dir: 4.0.0
+ supports-color: 7.2.0
+
+ istanbul-reports@3.2.0:
+ dependencies:
+ html-escaper: 2.0.2
+ istanbul-lib-report: 3.0.1
+
iterator.prototype@1.1.5:
dependencies:
define-data-property: 1.1.4
@@ -3679,12 +4459,40 @@ snapshots:
jiti@2.7.0: {}
+ js-tokens@10.0.0: {}
+
js-tokens@4.0.0: {}
js-yaml@4.2.0:
dependencies:
argparse: 2.0.1
+ jsdom@29.1.1:
+ dependencies:
+ '@asamuzakjp/css-color': 5.1.11
+ '@asamuzakjp/dom-selector': 7.1.1
+ '@bramus/specificity': 2.4.2
+ '@csstools/css-syntax-patches-for-csstree': 1.1.5(css-tree@3.2.1)
+ '@exodus/bytes': 1.15.1
+ css-tree: 3.2.1
+ data-urls: 7.0.0
+ decimal.js: 10.6.0
+ html-encoding-sniffer: 6.0.0
+ is-potential-custom-element-name: 1.0.1
+ lru-cache: 11.5.1
+ parse5: 8.0.1
+ saxes: 6.0.0
+ symbol-tree: 3.2.4
+ tough-cookie: 6.0.1
+ undici: 7.27.2
+ w3c-xmlserializer: 5.0.0
+ webidl-conversions: 8.0.1
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1
+ xml-name-validator: 5.0.0
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
jsesc@3.1.0: {}
json-buffer@3.0.1: {}
@@ -3794,6 +4602,16 @@ snapshots:
dependencies:
js-tokens: 4.0.0
+ lottie-react@2.4.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ dependencies:
+ lottie-web: 5.13.0
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+
+ lottie-web@5.13.0: {}
+
+ lru-cache@11.5.1: {}
+
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@@ -3802,8 +4620,20 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+ magicast@0.5.3:
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+ source-map-js: 1.2.1
+
+ make-dir@4.0.0:
+ dependencies:
+ semver: 7.8.2
+
math-intrinsics@1.1.0: {}
+ mdn-data@2.27.1: {}
+
merge2@1.4.1: {}
micromatch@4.0.8:
@@ -3821,6 +4651,8 @@ snapshots:
minimist@1.2.8: {}
+ mrmime@2.0.1: {}
+
ms@2.1.3: {}
nanoid@3.3.12: {}
@@ -3906,6 +4738,8 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.2
+ obug@2.1.2: {}
+
ofetch@1.5.1:
dependencies:
destr: 2.0.5
@@ -3954,6 +4788,10 @@ snapshots:
error-ex: 1.3.4
json-parse-better-errors: 1.0.2
+ parse5@8.0.1:
+ dependencies:
+ entities: 8.0.0
+
path-exists@3.0.0: {}
path-exists@4.0.0: {}
@@ -3962,6 +4800,8 @@ snapshots:
path-parse@1.0.7: {}
+ pathe@2.0.3: {}
+
picocolors@1.1.1: {}
picomatch@2.3.2: {}
@@ -4032,6 +4872,8 @@ snapshots:
require-directory@2.1.1: {}
+ require-from-string@2.0.2: {}
+
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
@@ -4047,6 +4889,27 @@ snapshots:
reusify@1.1.0: {}
+ rolldown@1.0.3:
+ dependencies:
+ '@oxc-project/types': 0.133.0
+ '@rolldown/pluginutils': 1.0.1
+ optionalDependencies:
+ '@rolldown/binding-android-arm64': 1.0.3
+ '@rolldown/binding-darwin-arm64': 1.0.3
+ '@rolldown/binding-darwin-x64': 1.0.3
+ '@rolldown/binding-freebsd-x64': 1.0.3
+ '@rolldown/binding-linux-arm-gnueabihf': 1.0.3
+ '@rolldown/binding-linux-arm64-gnu': 1.0.3
+ '@rolldown/binding-linux-arm64-musl': 1.0.3
+ '@rolldown/binding-linux-ppc64-gnu': 1.0.3
+ '@rolldown/binding-linux-s390x-gnu': 1.0.3
+ '@rolldown/binding-linux-x64-gnu': 1.0.3
+ '@rolldown/binding-linux-x64-musl': 1.0.3
+ '@rolldown/binding-openharmony-arm64': 1.0.3
+ '@rolldown/binding-wasm32-wasi': 1.0.3
+ '@rolldown/binding-win32-arm64-msvc': 1.0.3
+ '@rolldown/binding-win32-x64-msvc': 1.0.3
+
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -4070,6 +4933,10 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
+ saxes@6.0.0:
+ dependencies:
+ xmlchars: 2.2.0
+
scheduler@0.27.0: {}
semver@6.3.1: {}
@@ -4164,16 +5031,28 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
+ siginfo@2.0.0: {}
+
signale@1.4.0:
dependencies:
chalk: 2.4.2
figures: 2.0.0
pkg-conf: 2.1.0
+ sirv@3.0.2:
+ dependencies:
+ '@polka/url': 1.0.0-next.29
+ mrmime: 2.0.1
+ totalist: 3.0.1
+
source-map-js@1.2.1: {}
stable-hash@0.0.5: {}
+ stackback@0.0.2: {}
+
+ std-env@4.1.0: {}
+
stop-iteration-iterator@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -4260,19 +5139,43 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
+ symbol-tree@3.2.4: {}
+
tailwindcss@4.3.0: {}
tapable@2.3.3: {}
+ tinybench@2.9.0: {}
+
+ tinyexec@1.2.4: {}
+
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
+ tinyrainbow@3.1.0: {}
+
+ tldts-core@7.4.2: {}
+
+ tldts@7.4.2:
+ dependencies:
+ tldts-core: 7.4.2
+
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
+ totalist@3.0.1: {}
+
+ tough-cookie@6.0.1:
+ dependencies:
+ tldts: 7.4.2
+
+ tr46@6.0.0:
+ dependencies:
+ punycode: 2.3.1
+
ts-api-utils@2.5.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
@@ -4347,6 +5250,8 @@ snapshots:
undici-types@6.21.0: {}
+ undici@7.27.2: {}
+
unrs-resolver@1.12.2:
dependencies:
napi-postinstall: 0.3.4
@@ -4384,6 +5289,64 @@ snapshots:
dependencies:
punycode: 2.3.1
+ vite@8.0.16(@types/node@20.19.41)(jiti@2.7.0):
+ dependencies:
+ lightningcss: 1.32.0
+ picomatch: 4.0.4
+ postcss: 8.5.15
+ rolldown: 1.0.3
+ tinyglobby: 0.2.17
+ optionalDependencies:
+ '@types/node': 20.19.41
+ fsevents: 2.3.3
+ jiti: 2.7.0
+
+ vitest@4.1.8(@types/node@20.19.41)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@20.19.41)(jiti@2.7.0)):
+ dependencies:
+ '@vitest/expect': 4.1.8
+ '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@20.19.41)(jiti@2.7.0))
+ '@vitest/pretty-format': 4.1.8
+ '@vitest/runner': 4.1.8
+ '@vitest/snapshot': 4.1.8
+ '@vitest/spy': 4.1.8
+ '@vitest/utils': 4.1.8
+ es-module-lexer: 2.1.0
+ expect-type: 1.3.0
+ magic-string: 0.30.21
+ obug: 2.1.2
+ pathe: 2.0.3
+ picomatch: 4.0.4
+ std-env: 4.1.0
+ tinybench: 2.9.0
+ tinyexec: 1.2.4
+ tinyglobby: 0.2.17
+ tinyrainbow: 3.1.0
+ vite: 8.0.16(@types/node@20.19.41)(jiti@2.7.0)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 20.19.41
+ '@vitest/coverage-v8': 4.1.8(vitest@4.1.8)
+ '@vitest/ui': 4.1.8(vitest@4.1.8)
+ jsdom: 29.1.1
+ transitivePeerDependencies:
+ - msw
+
+ w3c-xmlserializer@5.0.0:
+ dependencies:
+ xml-name-validator: 5.0.0
+
+ webidl-conversions@8.0.1: {}
+
+ whatwg-mimetype@5.0.0: {}
+
+ whatwg-url@16.0.1:
+ dependencies:
+ '@exodus/bytes': 1.15.1
+ tr46: 6.0.0
+ webidl-conversions: 8.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
@@ -4429,6 +5392,11 @@ snapshots:
dependencies:
isexe: 2.0.0
+ why-is-node-running@2.3.0:
+ dependencies:
+ siginfo: 2.0.0
+ stackback: 0.0.2
+
word-wrap@1.2.5: {}
wrap-ansi@7.0.0:
@@ -4437,6 +5405,10 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
+ xml-name-validator@5.0.0: {}
+
+ xmlchars@2.2.0: {}
+
y18n@5.0.8: {}
yallist@3.1.1: {}
diff --git a/src/app/auth/page.tsx b/src/app/auth/page.tsx
index b53b9e02..aef06df1 100644
--- a/src/app/auth/page.tsx
+++ b/src/app/auth/page.tsx
@@ -1,84 +1,7 @@
"use client";
-/**
- * Auth 页(路由骨架版)
- *
- * 对齐 Flutter `lib/ui/auth/auth_screen.dart` 的语义:
- * - 已登录用户:自动跳 `/chat`
- * - 未登录:渲染返回 splash + disabled 登录表单占位
- */
-
-import { useEffect } from "react";
-import Link from "next/link";
-import { useRouter } from "next/navigation";
-
-import { useAuthGate } from "@/lib/auth/use-auth-gate";
-import { ROUTES } from "@/lib/routes";
+import { AuthScreen } from "@/components/auth/auth-screen";
export default function AuthPage() {
- const router = useRouter();
- const { isAuthed } = useAuthGate();
-
- useEffect(() => {
- if (isAuthed) {
- router.replace(ROUTES.chat);
- }
- }, [isAuthed, router]);
-
- if (!isAuthed) {
- return (
-
- Auth
-
- Auth placeholder · login form coming soon.
-
-
-
-
-
- Back to Splash
-
-
-
- );
- }
-
- return (
-
- );
+ return ;
}
diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx
index 0e4471cc..df45c745 100644
--- a/src/app/chat/page.tsx
+++ b/src/app/chat/page.tsx
@@ -1,53 +1,7 @@
"use client";
-/**
- * Chat 页(路由骨架版)
- *
- * **不**做鉴权重定向:Flutter 端允许游客态直接进聊天(`auth_refresh_interceptor`
- * 会同时处理登录与游客 token)。这里只放占位 UI。
- */
-
-import Link from "next/link";
-
-import { ROUTES } from "@/lib/routes";
+import { ChatScreen } from "@/components/chat/chat-screen";
export default function ChatPage() {
- return (
-
- Chat
-
- Chat placeholder · guest mode supported · real chat UI coming soon.
-
-
-
-
- Open Sidebar
-
-
- Log in
-
-
-
- );
+ return ;
}
diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx
new file mode 100644
index 00000000..8ec10f92
--- /dev/null
+++ b/src/app/global-error.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+/**
+ * 全局 Error Boundary
+ *
+ * Next.js 16 文档要求 global-error 必须是 Client Component。
+ * 16.2.7 存在 prerender `/_global-error` 的内部 bug(workStore 未初始化),
+ * 本文件通过不挂任何 Client Context 副作用、纯静态 HTML 输出来规避。
+ *
+ * 当根 layout 自身抛出错误时渲染;必须包含 `` + ``,
+ * 因为它会替换根 layout。
+ */
+
+interface GlobalErrorProps {
+ error: Error & { digest?: string };
+ unstable_retry?: () => void;
+}
+
+export default function GlobalError({ error }: GlobalErrorProps) {
+ return (
+
+
+
+ Something went wrong
+
+
+ {error.message || "An unexpected error occurred."}
+
+
+
+
+ );
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index a59a2881..e19404d6 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -5,6 +5,8 @@
@import "../design/radius.css";
@import "../design/border-widths.css";
@import "../design/icon-sizes.css";
+@import "../design/breakpoints.css";
+@import "../design/dimensions.css";
/* ============================================================
* Tailwind 4 @theme - 将 CSS 变量注册为 utility 类
@@ -59,6 +61,19 @@
);
--color-page-background: var(--color-page-background);
+ /* 扩展色 */
+ --color-sidebar-background: var(--color-sidebar-background);
+ --color-dark-background: var(--color-dark-background);
+ --color-dark-gray: var(--color-dark-gray);
+ --color-bubble-background: var(--color-bubble-background);
+ --color-chat-input-border: var(--color-chat-input-border);
+ --color-input-placeholder: var(--color-input-placeholder);
+ --color-pwa-button-text: var(--color-pwa-button-text);
+ --color-facebook-blue: var(--color-facebook-blue);
+ --color-dialog-background: var(--color-dialog-background);
+ --color-dialog-border: var(--color-dialog-border);
+ --color-blur-background: var(--color-blur-background);
+
/* ==================== 间距 ==================== */
--spacing-xs: var(--spacing-xs);
--spacing-sm: var(--spacing-sm);
@@ -78,7 +93,7 @@
/* ==================== 字体 ==================== */
--font-sans: var(--font-system);
- --font-athelas: var(--font-athelas);
+ --font-athelas: var(--color-athelas, var(--font-athelas));
/* ==================== 边框宽度 ==================== */
--border-thin: var(--border-thin);
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index fbfc7c35..399bf303 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
+import Script from "next/script";
import "./globals.css";
import { RootProviders } from "@/providers/root-providers";
@@ -33,6 +34,16 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
+ {/* Facebook SDK JS(auth + splash 入口会用到 FB.login) */}
+
+ {/* Google Identity Services(auth 入口会用到 google.accounts.id) */}
+
{children}
diff --git a/src/app/sidebar/page.tsx b/src/app/sidebar/page.tsx
index a69c0c66..69840d94 100644
--- a/src/app/sidebar/page.tsx
+++ b/src/app/sidebar/page.tsx
@@ -1,46 +1,7 @@
"use client";
-/**
- * Sidebar 页(路由骨架版)
- *
- * 对齐 Flutter `lib/ui/sidebar/sidebar_screen.dart` 的 `onBackPressed` 行为:
- * 顶部一个"返回 /chat"按钮。
- */
-
-import { useRouter } from "next/navigation";
-
-import { ROUTES } from "@/lib/routes";
+import { SidebarScreen } from "@/components/sidebar/sidebar-screen";
export default function SidebarPage() {
- const router = useRouter();
-
- return (
-
- Sidebar
-
- Sidebar placeholder · profile / settings coming soon.
-
-
-
- );
+ return ;
}
diff --git a/src/app/splash/page.tsx b/src/app/splash/page.tsx
index fcf5d295..afea87ae 100644
--- a/src/app/splash/page.tsx
+++ b/src/app/splash/page.tsx
@@ -1,94 +1,7 @@
"use client";
-/**
- * Splash 页(路由骨架版)
- *
- * 对齐 Flutter `lib/ui/splash/splash_screen.dart` 的语义:
- * - 已登录用户:自动跳 `/chat`(替代 Flutter 的 `BlocListener`)
- * - 未登录:渲染品牌占位 + Skip 按钮 + Facebook 登录占位
- *
- * 占位 UI 不复刻 Flutter 视觉(待 `src/components/` 落地后再做),仅做路由行为验证。
- */
-
-import { useEffect } from "react";
-import { useRouter } from "next/navigation";
-
-import { useAuthGate } from "@/lib/auth/use-auth-gate";
-import { ROUTES } from "@/lib/routes";
+import { SplashScreen } from "@/components/splash/splash-screen";
export default function SplashPage() {
- const router = useRouter();
- const { isAuthed } = useAuthGate();
-
- // 已登录 → 自动跳 /chat。
- useEffect(() => {
- if (isAuthed) {
- router.replace(ROUTES.chat);
- }
- }, [isAuthed, router]);
-
- // `useAuthGate` 在 SSR / 客户端水合首帧都返回 `isAuthed=false`,
- // 因此这里会先渲染 spinner 占位(也避免水合闪屏),等水合完成、
- // React 自动重读 localStorage 拿到真值后,若仍为 false 则展示下方 UI。
- if (!isAuthed) {
- return (
-
- cozsweet
-
- Splash placeholder · authed → {String(isAuthed)}
-
-
-
-
-
-
-
- );
- }
-
- // 已登录:上面的 useEffect 会触发 router.replace;这里短暂渲染占位。
- return (
-
- );
+ return ;
}
diff --git a/src/components/auth/__tests__/auth-validators.test.ts b/src/components/auth/__tests__/auth-validators.test.ts
new file mode 100644
index 00000000..3ade889b
--- /dev/null
+++ b/src/components/auth/__tests__/auth-validators.test.ts
@@ -0,0 +1,40 @@
+import { describe, it, expect } from "vitest";
+
+import {
+ validateEmail,
+ validatePassword,
+ validateUsername,
+} from "@/components/auth/auth-validators";
+
+describe("validateEmail", () => {
+ it("accepts a valid email", () => {
+ expect(validateEmail("user@example.com")).toBeNull();
+ });
+ it("rejects an empty email", () => {
+ expect(validateEmail("")).not.toBeNull();
+ });
+ it("rejects an invalid email", () => {
+ expect(validateEmail("not-an-email")).not.toBeNull();
+ });
+});
+
+describe("validatePassword", () => {
+ it("accepts a password >= 8 chars", () => {
+ expect(validatePassword("12345678")).toBeNull();
+ });
+ it("rejects a too-short password", () => {
+ expect(validatePassword("123")).not.toBeNull();
+ });
+});
+
+describe("validateUsername", () => {
+ it("accepts a 3+ char username", () => {
+ expect(validateUsername("alice")).toBeNull();
+ });
+ it("rejects an empty username", () => {
+ expect(validateUsername("")).not.toBeNull();
+ });
+ it("rejects a too-short username", () => {
+ expect(validateUsername("ab")).not.toBeNull();
+ });
+});
diff --git a/src/components/auth/auth-divider.module.css b/src/components/auth/auth-divider.module.css
new file mode 100644
index 00000000..12733a98
--- /dev/null
+++ b/src/components/auth/auth-divider.module.css
@@ -0,0 +1,20 @@
+.wrapper {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md);
+ width: 100%;
+ margin: var(--spacing-lg) 0;
+}
+
+.line {
+ flex: 1 1 auto;
+ height: var(--border-light);
+ background: var(--color-chat-input-border);
+}
+
+.label {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
diff --git a/src/components/auth/auth-divider.tsx b/src/components/auth/auth-divider.tsx
new file mode 100644
index 00000000..c6b61417
--- /dev/null
+++ b/src/components/auth/auth-divider.tsx
@@ -0,0 +1,17 @@
+"use client";
+/**
+ * "OR" 分隔符
+ *
+ * 原始 Dart: lib/ui/auth/widgets/auth_divider.dart
+ */
+import styles from "./auth-divider.module.css";
+
+export function AuthDivider({ label = "or" }: { label?: string }) {
+ return (
+
+ );
+}
diff --git a/src/components/auth/auth-email-panel.module.css b/src/components/auth/auth-email-panel.module.css
new file mode 100644
index 00000000..c8dbcf02
--- /dev/null
+++ b/src/components/auth/auth-email-panel.module.css
@@ -0,0 +1,21 @@
+.panel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-md);
+ width: 100%;
+}
+
+.backButton {
+ align-self: flex-start;
+ background: none;
+ border: none;
+ padding: 0;
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-sm);
+ cursor: pointer;
+ transition: color 0.15s ease;
+}
+
+.backButton:hover {
+ color: var(--color-text-primary);
+}
diff --git a/src/components/auth/auth-email-panel.tsx b/src/components/auth/auth-email-panel.tsx
new file mode 100644
index 00000000..a16bce09
--- /dev/null
+++ b/src/components/auth/auth-email-panel.tsx
@@ -0,0 +1,47 @@
+"use client";
+/**
+ * Email 面板
+ *
+ * 原始 Dart: lib/ui/auth/widgets/auth_email_panel.dart
+ *
+ * 行为:
+ * - 内部 `authMode`(login/register)切换显示 Login / Register 表单
+ * - 提交派发 `AuthEmailLoginSubmitted` 或 `AuthEmailRegisterSubmitted`
+ */
+import { useState } from "react";
+
+import { useAuthState } from "@/contexts/auth/auth-context";
+
+import { EmailLoginForm } from "./email-login-form";
+import { EmailRegisterForm } from "./email-register-form";
+import styles from "./auth-email-panel.module.css";
+
+export interface AuthEmailPanelProps {
+ onBack: () => void;
+}
+
+export function AuthEmailPanel({ onBack }: AuthEmailPanelProps) {
+ const state = useAuthState();
+ const [mode, setMode] = useState<"login" | "register">("login");
+
+ return (
+
+
+ {mode === "login" ? (
+ setMode("register")}
+ globalError={state.errorMessage}
+ isLoading={state.isLoading}
+ />
+ ) : (
+ setMode("login")}
+ globalError={state.errorMessage}
+ isLoading={state.isLoading}
+ />
+ )}
+
+ );
+}
diff --git a/src/components/auth/auth-error-message.module.css b/src/components/auth/auth-error-message.module.css
new file mode 100644
index 00000000..ac8d8eb4
--- /dev/null
+++ b/src/components/auth/auth-error-message.module.css
@@ -0,0 +1,10 @@
+.banner {
+ width: 100%;
+ padding: var(--spacing-md);
+ border-radius: var(--radius-md);
+ background: rgba(255, 77, 79, 0.12);
+ border: var(--border-light) solid rgba(255, 77, 79, 0.3);
+ color: var(--color-error);
+ font-size: var(--font-size-sm);
+ line-height: 1.4;
+}
diff --git a/src/components/auth/auth-error-message.tsx b/src/components/auth/auth-error-message.tsx
new file mode 100644
index 00000000..99c43c46
--- /dev/null
+++ b/src/components/auth/auth-error-message.tsx
@@ -0,0 +1,16 @@
+"use client";
+/**
+ * 错误消息展示
+ *
+ * 原始 Dart: lib/ui/auth/widgets/auth_error_message.dart
+ */
+import styles from "./auth-error-message.module.css";
+
+export function AuthErrorMessage({ message }: { message: string | null }) {
+ if (!message) return null;
+ return (
+
+ {message}
+
+ );
+}
diff --git a/src/components/auth/auth-facebook-panel.module.css b/src/components/auth/auth-facebook-panel.module.css
new file mode 100644
index 00000000..31c5fefd
--- /dev/null
+++ b/src/components/auth/auth-facebook-panel.module.css
@@ -0,0 +1,21 @@
+.panel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-md);
+ width: 100%;
+}
+
+.linkButton {
+ background: none;
+ border: none;
+ padding: 0;
+ font-size: var(--font-size-sm);
+ color: var(--color-accent);
+ text-align: center;
+ cursor: pointer;
+ transition: opacity 0.15s ease;
+}
+
+.linkButton:hover {
+ opacity: 0.85;
+}
diff --git a/src/components/auth/auth-facebook-panel.tsx b/src/components/auth/auth-facebook-panel.tsx
new file mode 100644
index 00000000..1866f293
--- /dev/null
+++ b/src/components/auth/auth-facebook-panel.tsx
@@ -0,0 +1,91 @@
+"use client";
+/**
+ * Facebook 面板(默认面板)
+ *
+ * 原始 Dart: lib/ui/auth/widgets/auth_facebook_panel.dart
+ *
+ * 行为:
+ * - 默认显示「Continue with Facebook」+ 「Other sign-in options」入口
+ * - 真实 Facebook 登录在「Continue with Facebook」按钮中触发
+ */
+import { useState } from "react";
+
+import { useAuthState, useAuthDispatch } from "@/contexts/auth/auth-context";
+
+import { AuthSocialButtons } from "./auth-social-buttons";
+import { AuthDivider } from "./auth-divider";
+import { AuthPrimaryButton } from "./auth-primary-button";
+import { AuthErrorMessage } from "./auth-error-message";
+import { AuthOtherOptionsDialog } from "./auth-other-options-dialog";
+import { GoogleSignInButton } from "./google-sign-in-button";
+import { AuthLegalText } from "./auth-legal-text";
+import styles from "./auth-facebook-panel.module.css";
+
+export interface AuthFacebookPanelProps {
+ onSwitchToEmail: () => void;
+ onGoogleSuccess: (idToken: string, email: string) => void;
+}
+
+export function AuthFacebookPanel({
+ onSwitchToEmail,
+ onGoogleSuccess,
+}: AuthFacebookPanelProps) {
+ const state = useAuthState();
+ const dispatch = useAuthDispatch();
+ const [showOptions, setShowOptions] = useState(false);
+
+ return (
+
+
+
dispatch({ type: "AuthFacebookLoginSubmitted" })}
+ onGoogle={() => dispatch({ type: "AuthGoogleLoginSubmitted" })}
+ googleSlot={
+
+ onGoogleSuccess(s.idToken, s.email)
+ }
+ />
+ }
+ />
+
+
+ Continue with Email
+
+
+
+ setShowOptions(false)}
+ onFacebook={() => {
+ setShowOptions(false);
+ dispatch({ type: "AuthFacebookLoginSubmitted" });
+ }}
+ onGoogle={() => {
+ setShowOptions(false);
+ dispatch({ type: "AuthGoogleLoginSubmitted" });
+ }}
+ onApple={() => {
+ setShowOptions(false);
+ dispatch({ type: "AuthAppleLoginSubmitted" });
+ }}
+ onEmail={() => {
+ setShowOptions(false);
+ onSwitchToEmail();
+ }}
+ loadingProvider={state.isLoading ? "facebook" : null}
+ showApple={false}
+ />
+
+ );
+}
diff --git a/src/components/auth/auth-legal-text.module.css b/src/components/auth/auth-legal-text.module.css
new file mode 100644
index 00000000..7d0d29b7
--- /dev/null
+++ b/src/components/auth/auth-legal-text.module.css
@@ -0,0 +1,16 @@
+.text {
+ margin: 0;
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+ text-align: center;
+ line-height: 1.5;
+}
+
+.link {
+ color: var(--color-accent);
+ text-decoration: none;
+}
+
+.link:hover {
+ text-decoration: underline;
+}
diff --git a/src/components/auth/auth-legal-text.tsx b/src/components/auth/auth-legal-text.tsx
new file mode 100644
index 00000000..f25cb333
--- /dev/null
+++ b/src/components/auth/auth-legal-text.tsx
@@ -0,0 +1,23 @@
+"use client";
+/**
+ * 法律声明(Terms / Privacy)
+ *
+ * 原始 Dart: lib/ui/auth/widgets/auth_legal_text.dart
+ */
+import styles from "./auth-legal-text.module.css";
+
+export function AuthLegalText() {
+ return (
+
+ By continuing, you agree to our{" "}
+
+ Terms
+ {" "}
+ and{" "}
+
+ Privacy Policy
+
+ .
+
+ );
+}
diff --git a/src/components/auth/auth-other-options-dialog.module.css b/src/components/auth/auth-other-options-dialog.module.css
new file mode 100644
index 00000000..6220ba13
--- /dev/null
+++ b/src/components/auth/auth-other-options-dialog.module.css
@@ -0,0 +1,34 @@
+.body {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-md);
+ padding: var(--spacing-xl);
+}
+
+.title {
+ margin: 0;
+ font-size: var(--font-size-xl);
+ font-weight: 600;
+ color: var(--color-text-primary);
+ text-align: center;
+}
+
+.emailButton {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: var(--button-height);
+ padding: 0 var(--spacing-lg);
+ border: var(--border-light) solid var(--color-chat-input-border);
+ border-radius: var(--radius-full);
+ background: transparent;
+ color: var(--color-text-primary);
+ font-size: var(--font-size-md);
+ cursor: pointer;
+ transition: background 0.15s ease;
+}
+
+.emailButton:hover {
+ background: rgba(255, 255, 255, 0.06);
+}
diff --git a/src/components/auth/auth-other-options-dialog.tsx b/src/components/auth/auth-other-options-dialog.tsx
new file mode 100644
index 00000000..b1f3477b
--- /dev/null
+++ b/src/components/auth/auth-other-options-dialog.tsx
@@ -0,0 +1,54 @@
+"use client";
+/**
+ * "其他登录方式" 弹窗
+ *
+ * 原始 Dart: lib/ui/auth/widgets/auth_other_options_dialog.dart
+ */
+import { Dialog } from "@/components/core/dialog";
+import { AuthSocialButtons } from "./auth-social-buttons";
+import { AuthDivider } from "./auth-divider";
+import styles from "./auth-other-options-dialog.module.css";
+
+export interface AuthOtherOptionsDialogProps {
+ open: boolean;
+ onClose: () => void;
+ onFacebook: () => void;
+ onGoogle: () => void;
+ onApple?: () => void;
+ onEmail: () => void;
+ loadingProvider?: "facebook" | "google" | "apple" | null;
+ showApple?: boolean;
+ googleSlot?: React.ReactNode;
+}
+
+export function AuthOtherOptionsDialog({
+ open,
+ onClose,
+ onFacebook,
+ onGoogle,
+ onApple,
+ onEmail,
+ loadingProvider,
+ showApple,
+ googleSlot,
+}: AuthOtherOptionsDialogProps) {
+ return (
+
+ );
+}
diff --git a/src/components/auth/auth-panel.module.css b/src/components/auth/auth-panel.module.css
new file mode 100644
index 00000000..4e492bd2
--- /dev/null
+++ b/src/components/auth/auth-panel.module.css
@@ -0,0 +1,7 @@
+.panel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-lg);
+ width: 100%;
+ padding: var(--spacing-xl);
+}
diff --git a/src/components/auth/auth-panel.tsx b/src/components/auth/auth-panel.tsx
new file mode 100644
index 00000000..89924f15
--- /dev/null
+++ b/src/components/auth/auth-panel.tsx
@@ -0,0 +1,36 @@
+"use client";
+/**
+ * 认证面板:顶层 switch(Facebook / Email)
+ *
+ * 原始 Dart: lib/ui/auth/widgets/auth_panel.dart
+ */
+import { useAuthState } from "@/contexts/auth/auth-context";
+
+import { AuthFacebookPanel } from "./auth-facebook-panel";
+import { AuthEmailPanel } from "./auth-email-panel";
+import styles from "./auth-panel.module.css";
+
+export interface AuthPanelProps {
+ onGoogleSuccess: (idToken: string, email: string) => void;
+}
+
+export function AuthPanel({ onGoogleSuccess }: AuthPanelProps) {
+ const state = useAuthState();
+ return (
+
+ {state.authPanelMode === "facebook" ? (
+
{
+ // 切换模式由外部 dispatch 触发;这里不直接处理
+ // 因为 auth-panel 受 `authPanelMode` 控制,调用方需要 dispatch `AuthPanelModeChanged`
+ // 但我们目前 AuthFacebookPanel 内部 click "Continue with Email" 直接调用 onSwitchToEmail
+ // 实际上本组件直接渲染对应 panel
+ }}
+ onGoogleSuccess={onGoogleSuccess}
+ />
+ ) : (
+ undefined} />
+ )}
+
+ );
+}
diff --git a/src/components/auth/auth-primary-button.module.css b/src/components/auth/auth-primary-button.module.css
new file mode 100644
index 00000000..481bf6bc
--- /dev/null
+++ b/src/components/auth/auth-primary-button.module.css
@@ -0,0 +1,54 @@
+.button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing-sm);
+ width: 100%;
+ height: var(--button-height);
+ padding: 0 var(--spacing-lg);
+ border: none;
+ border-radius: var(--radius-full);
+ font-size: var(--font-size-md);
+ font-weight: 600;
+ color: var(--color-text-primary);
+ cursor: pointer;
+ transition: opacity 0.15s ease, transform 0.05s ease;
+}
+
+.button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.button:not(:disabled):active {
+ transform: scale(0.98);
+}
+
+.accent {
+ background: linear-gradient(
+ 90deg,
+ var(--color-accent),
+ var(--color-facebook-button-gradient-end)
+ );
+}
+
+.primary {
+ background: var(--color-primary);
+}
+
+.spinner {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ border-width: 2px;
+ border-style: solid;
+ border-color: currentColor transparent transparent transparent;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/src/components/auth/auth-primary-button.tsx b/src/components/auth/auth-primary-button.tsx
new file mode 100644
index 00000000..1914ab94
--- /dev/null
+++ b/src/components/auth/auth-primary-button.tsx
@@ -0,0 +1,45 @@
+"use client";
+/**
+ * 认证主按钮(粉/蓝渐变)
+ *
+ * 原始 Dart: lib/ui/auth/widgets/auth_primary_button.dart
+ */
+import { type ButtonHTMLAttributes, type ReactNode } from "react";
+
+import styles from "./auth-primary-button.module.css";
+
+export interface AuthPrimaryButtonProps
+ extends Omit, "children"> {
+ isLoading?: boolean;
+ children: ReactNode;
+ variant?: "accent" | "primary";
+}
+
+export function AuthPrimaryButton({
+ isLoading,
+ children,
+ variant = "accent",
+ disabled,
+ className,
+ ...rest
+}: AuthPrimaryButtonProps) {
+ return (
+
+ );
+}
diff --git a/src/components/auth/auth-screen.tsx b/src/components/auth/auth-screen.tsx
new file mode 100644
index 00000000..427a33d9
--- /dev/null
+++ b/src/components/auth/auth-screen.tsx
@@ -0,0 +1,74 @@
+"use client";
+/**
+ * 认证全屏页面
+ *
+ * 原始 Dart: lib/ui/auth/auth_screen.dart
+ *
+ * 行为对齐:
+ * - 500px 移动端宽度(MobileShell)
+ * - 已登录用户:自动跳 /chat
+ * - 未登录:渲染 AuthPanel(含 Facebook/Email 两种面板)
+ * - 登录成功:通知 ChatBloc + UserBloc,然后 router.replace(/chat)
+ */
+import { useEffect, useRef } from "react";
+import { useRouter } from "next/navigation";
+
+import { useAuthState, useAuthDispatch } from "@/contexts/auth/auth-context";
+import { useChatDispatch } from "@/contexts/chat/chat-context";
+import { useUserDispatch } from "@/contexts/user/user-context";
+import { useAuthGate } from "@/lib/auth/use-auth-gate";
+import { ROUTES } from "@/lib/routes";
+import { MobileShell } from "@/components/core/mobile-shell";
+import { AuthPanel } from "@/components/auth/auth-panel";
+
+export interface AuthScreenProps {
+ /** 自定义背景(默认紫粉渐变)。 */
+ background?: string;
+}
+
+export function AuthScreen({
+ background = "var(--color-sidebar-background)",
+}: AuthScreenProps) {
+ const router = useRouter();
+ const { isAuthed } = useAuthGate();
+ const state = useAuthState();
+ const authDispatch = useAuthDispatch();
+ const chatDispatch = useChatDispatch();
+ const userDispatch = useUserDispatch();
+ const wasSuccess = useRef(false);
+
+ // 已登录 → 自动跳 /chat
+ useEffect(() => {
+ if (isAuthed) router.replace(ROUTES.chat);
+ }, [isAuthed, router]);
+
+ // 登录成功 → 通知 Chat + User + 跳转
+ useEffect(() => {
+ if (state.isSuccess && !wasSuccess.current) {
+ wasSuccess.current = true;
+ chatDispatch({ type: "ChatAuthStatusChanged" });
+ userDispatch({ type: "UserInit" });
+ router.replace(ROUTES.chat);
+ }
+ }, [state.isSuccess, chatDispatch, userDispatch, router]);
+
+ return (
+
+
+
{
+ authDispatch({ type: "AuthWebGoogleLoginSuccess", idToken, email });
+ }}
+ />
+
+
+ );
+}
diff --git a/src/components/auth/auth-social-button.module.css b/src/components/auth/auth-social-button.module.css
new file mode 100644
index 00000000..fd3049c7
--- /dev/null
+++ b/src/components/auth/auth-social-button.module.css
@@ -0,0 +1,55 @@
+.button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing-sm);
+ width: 100%;
+ height: var(--button-height);
+ padding: 0 var(--spacing-lg);
+ border: var(--border-light) solid var(--color-chat-input-border);
+ border-radius: var(--radius-full);
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--color-text-primary);
+ font-size: var(--font-size-md);
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.15s ease, border-color 0.15s ease;
+}
+
+.button:hover:not(:disabled) {
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+}
+
+.facebook {
+ background: var(--color-facebook-blue);
+ border-color: transparent;
+}
+
+.google {
+ background: #ffffff;
+ color: #1f1f1f;
+ border-color: transparent;
+}
+
+.apple {
+ background: #000000;
+ border-color: #ffffff;
+}
+
+.label {
+ flex: 1 1 auto;
+ text-align: center;
+}
diff --git a/src/components/auth/auth-social-button.tsx b/src/components/auth/auth-social-button.tsx
new file mode 100644
index 00000000..37280150
--- /dev/null
+++ b/src/components/auth/auth-social-button.tsx
@@ -0,0 +1,39 @@
+"use client";
+/**
+ * 社交登录按钮
+ *
+ * 原始 Dart: lib/ui/auth/widgets/auth_social_button.dart
+ */
+import { type ButtonHTMLAttributes, type ReactNode } from "react";
+
+import styles from "./auth-social-button.module.css";
+
+export interface AuthSocialButtonProps
+ extends Omit, "children"> {
+ icon?: ReactNode;
+ children: ReactNode;
+ variant?: "facebook" | "google" | "apple" | "default";
+}
+
+export function AuthSocialButton({
+ icon,
+ children,
+ variant = "default",
+ disabled,
+ className,
+ ...rest
+}: AuthSocialButtonProps) {
+ return (
+
+ );
+}
diff --git a/src/components/auth/auth-social-buttons.module.css b/src/components/auth/auth-social-buttons.module.css
new file mode 100644
index 00000000..3aea1f8a
--- /dev/null
+++ b/src/components/auth/auth-social-buttons.module.css
@@ -0,0 +1,65 @@
+.list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-md);
+ width: 100%;
+}
+
+.button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing-sm);
+ width: 100%;
+ height: var(--button-height);
+ padding: 0 var(--spacing-lg);
+ border: var(--border-light) solid var(--color-chat-input-border);
+ border-radius: var(--radius-full);
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--color-text-primary);
+ font-size: var(--font-size-md);
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.15s ease;
+}
+
+.button:hover:not(:disabled) {
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+}
+
+.facebook {
+ background: var(--color-facebook-blue);
+ border-color: transparent;
+}
+
+.google {
+ background: #ffffff;
+ color: #1f1f1f;
+ border-color: transparent;
+}
+
+.apple {
+ background: #000000;
+ border-color: rgba(255, 255, 255, 0.2);
+ color: #ffffff;
+}
+
+.googleSlot {
+ display: flex;
+ justify-content: center;
+ width: 100%;
+ min-height: var(--button-height);
+}
diff --git a/src/components/auth/auth-social-buttons.tsx b/src/components/auth/auth-social-buttons.tsx
new file mode 100644
index 00000000..8c262872
--- /dev/null
+++ b/src/components/auth/auth-social-buttons.tsx
@@ -0,0 +1,99 @@
+"use client";
+/**
+ * 社交登录按钮组合
+ *
+ * 原始 Dart: lib/ui/auth/widgets/auth_social_buttons.dart
+ *
+ * 包含 Facebook / Google / Apple 三个按钮的竖排列表。
+ */
+import { type ReactNode } from "react";
+
+import styles from "./auth-social-buttons.module.css";
+
+export interface AuthSocialButtonsProps {
+ onFacebook: () => void;
+ onGoogle: () => void;
+ onApple?: () => void;
+ loadingProvider?: "facebook" | "google" | "apple" | null;
+ showApple?: boolean;
+ /** 自定义子节点(如嵌入 GIS 按钮) */
+ googleSlot?: ReactNode;
+}
+
+export function AuthSocialButtons({
+ onFacebook,
+ onGoogle,
+ onApple,
+ loadingProvider,
+ showApple = false,
+ googleSlot,
+}: AuthSocialButtonsProps) {
+ return (
+
+
+
+ {googleSlot ? (
+
{googleSlot}
+ ) : (
+
+ )}
+
+ {showApple && onApple ? (
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/auth/auth-text-field.module.css b/src/components/auth/auth-text-field.module.css
new file mode 100644
index 00000000..bec6910e
--- /dev/null
+++ b/src/components/auth/auth-text-field.module.css
@@ -0,0 +1,46 @@
+.wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-xs);
+ width: 100%;
+}
+
+.label {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+}
+
+.input {
+ width: 100%;
+ height: var(--button-height);
+ padding: 0 var(--spacing-lg);
+ border: var(--border-light) solid var(--color-chat-input-border);
+ border-radius: var(--radius-lg);
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--color-text-primary);
+ font-size: var(--font-size-md);
+ outline: none;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+}
+
+.input::placeholder {
+ color: var(--color-input-placeholder);
+}
+
+.input:focus {
+ border-color: var(--color-accent);
+ box-shadow: 0 0 0 3px rgba(248, 77, 150, 0.18);
+}
+
+.invalid {
+ border-color: var(--color-error);
+}
+
+.invalid:focus {
+ box-shadow: 0 0 0 3px rgba(255, 77, 79, 0.18);
+}
+
+.error {
+ font-size: var(--font-size-sm);
+ color: var(--color-error);
+}
diff --git a/src/components/auth/auth-text-field.tsx b/src/components/auth/auth-text-field.tsx
new file mode 100644
index 00000000..f1a31597
--- /dev/null
+++ b/src/components/auth/auth-text-field.tsx
@@ -0,0 +1,78 @@
+"use client";
+/**
+ * 通用文本输入框
+ *
+ * 原始 Dart: lib/ui/auth/widgets/auth_text_field.dart
+ */
+import {
+ type ChangeEvent,
+ type FocusEvent,
+ type KeyboardEvent,
+ forwardRef,
+} from "react";
+
+import styles from "./auth-text-field.module.css";
+
+export interface AuthTextFieldProps {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ type?: "text" | "email" | "password" | "tel";
+ label?: string;
+ errorMessage?: string | null;
+ autoComplete?: string;
+ maxLength?: number;
+ disabled?: boolean;
+ onSubmit?: () => void;
+ onBlur?: (e: FocusEvent) => void;
+}
+
+export const AuthTextField = forwardRef(
+ function AuthTextField(
+ {
+ value,
+ onChange,
+ placeholder,
+ type = "text",
+ label,
+ errorMessage,
+ autoComplete,
+ maxLength,
+ disabled,
+ onSubmit,
+ onBlur,
+ },
+ ref,
+ ) {
+ return (
+
+ );
+ },
+);
diff --git a/src/components/auth/auth-validators.ts b/src/components/auth/auth-validators.ts
new file mode 100644
index 00000000..4153ed23
--- /dev/null
+++ b/src/components/auth/auth-validators.ts
@@ -0,0 +1,43 @@
+/**
+ * 认证表单校验
+ *
+ * 原始 Dart: lib/ui/auth/widgets/auth_validators.dart
+ *
+ * 业务行为:
+ * - validateEmail:使用 Zod (项目已有依赖) 校验邮箱格式
+ * - validatePassword:至少 8 位
+ * - validateUsername:3-20 位字母数字下划线
+ * - validateConfirmPassword:与 password 一致
+ */
+import { z } from "zod";
+
+export const emailSchema = z.string().trim().email("Invalid email address");
+export const passwordSchema = z
+ .string()
+ .min(8, "Password must be at least 8 characters");
+export const usernameSchema = z
+ .string()
+ .trim()
+ .min(3, "Username must be at least 3 characters")
+ .max(20, "Username must be at most 20 characters")
+ .regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores");
+
+export const validateEmail = (email: string): string | null => {
+ const r = emailSchema.safeParse(email);
+ return r.success ? null : r.error.issues[0]?.message ?? "Invalid email";
+};
+
+export const validatePassword = (password: string): string | null => {
+ const r = passwordSchema.safeParse(password);
+ return r.success ? null : r.error.issues[0]?.message ?? "Invalid password";
+};
+
+export const validateUsername = (username: string): string | null => {
+ const r = usernameSchema.safeParse(username);
+ return r.success ? null : r.error.issues[0]?.message ?? "Invalid username";
+};
+
+export const validateConfirmPassword = (
+ password: string,
+ confirmPassword: string,
+): string | null => (password === confirmPassword ? null : "Passwords do not match");
diff --git a/src/components/auth/email-form.module.css b/src/components/auth/email-form.module.css
new file mode 100644
index 00000000..44e06848
--- /dev/null
+++ b/src/components/auth/email-form.module.css
@@ -0,0 +1,26 @@
+.form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-md);
+ width: 100%;
+}
+
+.linkButton {
+ background: none;
+ border: none;
+ padding: 0;
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+ text-align: center;
+ cursor: pointer;
+ transition: color 0.15s ease;
+}
+
+.linkButton:hover {
+ color: var(--color-text-primary);
+}
+
+.link {
+ color: var(--color-accent);
+ font-weight: 600;
+}
diff --git a/src/components/auth/email-login-form.tsx b/src/components/auth/email-login-form.tsx
new file mode 100644
index 00000000..00c72724
--- /dev/null
+++ b/src/components/auth/email-login-form.tsx
@@ -0,0 +1,100 @@
+"use client";
+/**
+ * 邮箱登录表单
+ *
+ * 原始 Dart: lib/ui/auth/widgets/email_login_form.dart
+ */
+import { useState } from "react";
+
+import { useAuthDispatch } from "@/contexts/auth/auth-context";
+import {
+ validateEmail,
+ validatePassword,
+} from "@/components/auth/auth-validators";
+
+import { AuthTextField } from "./auth-text-field";
+import { AuthPrimaryButton } from "./auth-primary-button";
+import { AuthErrorMessage } from "./auth-error-message";
+import styles from "./email-form.module.css";
+
+export interface EmailLoginFormProps {
+ onSwitchToRegister: () => void;
+ onForgotPassword?: () => void;
+ globalError?: string | null;
+ isLoading?: boolean;
+}
+
+export function EmailLoginForm({
+ onSwitchToRegister,
+ onForgotPassword,
+ globalError,
+ isLoading,
+}: EmailLoginFormProps) {
+ const dispatch = useAuthDispatch();
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [errors, setErrors] = useState<{ email?: string; password?: string }>(
+ {},
+ );
+
+ const submit = () => {
+ const emailErr = validateEmail(email);
+ const passwordErr = validatePassword(password);
+ if (emailErr || passwordErr) {
+ setErrors({ email: emailErr ?? undefined, password: passwordErr ?? undefined });
+ return;
+ }
+ setErrors({});
+ dispatch({ type: "AuthEmailLoginSubmitted", email, password });
+ };
+
+ return (
+
+ );
+}
diff --git a/src/components/auth/email-register-form.tsx b/src/components/auth/email-register-form.tsx
new file mode 100644
index 00000000..bf35c215
--- /dev/null
+++ b/src/components/auth/email-register-form.tsx
@@ -0,0 +1,123 @@
+"use client";
+/**
+ * 邮箱注册表单
+ *
+ * 原始 Dart: lib/ui/auth/widgets/email_register_form.dart
+ */
+import { useState } from "react";
+
+import { useAuthDispatch } from "@/contexts/auth/auth-context";
+import {
+ validateConfirmPassword,
+ validateEmail,
+ validatePassword,
+ validateUsername,
+} from "@/components/auth/auth-validators";
+
+import { AuthTextField } from "./auth-text-field";
+import { AuthPrimaryButton } from "./auth-primary-button";
+import { AuthErrorMessage } from "./auth-error-message";
+import styles from "./email-form.module.css";
+
+export interface EmailRegisterFormProps {
+ onSwitchToLogin: () => void;
+ globalError?: string | null;
+ isLoading?: boolean;
+}
+
+export function EmailRegisterForm({
+ onSwitchToLogin,
+ globalError,
+ isLoading,
+}: EmailRegisterFormProps) {
+ const dispatch = useAuthDispatch();
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [username, setUsername] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [errors, setErrors] = useState<
+ Partial>
+ >({});
+
+ const submit = () => {
+ const emailErr = validateEmail(email);
+ const passwordErr = validatePassword(password);
+ const usernameErr = validateUsername(username);
+ const confirmErr = validateConfirmPassword(password, confirmPassword);
+ if (emailErr || passwordErr || usernameErr || confirmErr) {
+ setErrors({
+ email: emailErr ?? undefined,
+ password: passwordErr ?? undefined,
+ username: usernameErr ?? undefined,
+ confirmPassword: confirmErr ?? undefined,
+ });
+ return;
+ }
+ setErrors({});
+ dispatch({
+ type: "AuthEmailRegisterSubmitted",
+ email,
+ password,
+ username,
+ confirmPassword,
+ });
+ };
+
+ return (
+
+ );
+}
diff --git a/src/components/auth/google-sign-in-button.module.css b/src/components/auth/google-sign-in-button.module.css
new file mode 100644
index 00000000..bf971629
--- /dev/null
+++ b/src/components/auth/google-sign-in-button.module.css
@@ -0,0 +1,7 @@
+.slot {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ min-height: var(--button-height);
+}
diff --git a/src/components/auth/google-sign-in-button.tsx b/src/components/auth/google-sign-in-button.tsx
new file mode 100644
index 00000000..b0598b6c
--- /dev/null
+++ b/src/components/auth/google-sign-in-button.tsx
@@ -0,0 +1,50 @@
+"use client";
+/**
+ * Google 登录按钮(GIS 渲染)
+ *
+ * 原始 Dart: lib/ui/auth/widgets/google_sign_in_web.dart
+ *
+ * 通过 `google.accounts.id.renderButton` 把 GIS 官方按钮渲染到指定 div。
+ * 登录成功由 `setOnGoogleSignInSuccess` 设置的回调处理。
+ */
+import { useEffect, useRef } from "react";
+
+import {
+ renderGoogleButton,
+ setOnGoogleSignInSuccess,
+ type GoogleSignInSuccess,
+} from "@/integrations/google-identity";
+import styles from "./google-sign-in-button.module.css";
+
+export interface GoogleSignInButtonProps {
+ onSuccess: (success: GoogleSignInSuccess) => void;
+}
+
+export function GoogleSignInButton({ onSuccess }: GoogleSignInButtonProps) {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ setOnGoogleSignInSuccess(onSuccess);
+ return () => setOnGoogleSignInSuccess(null);
+ }, [onSuccess]);
+
+ useEffect(() => {
+ if (!ref.current) return;
+ let cancelled = false;
+ // GIS 异步加载,轮询直到可用
+ const tryRender = () => {
+ if (cancelled) return;
+ if (window.google?.accounts?.id) {
+ renderGoogleButton(ref.current!, { theme: "outline", size: "large" });
+ } else {
+ setTimeout(tryRender, 100);
+ }
+ };
+ tryRender();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ return ;
+}
diff --git a/src/components/chat/chat-screen.module.css b/src/components/chat/chat-screen.module.css
new file mode 100644
index 00000000..e89e7395
--- /dev/null
+++ b/src/components/chat/chat-screen.module.css
@@ -0,0 +1,208 @@
+.shell {
+ display: flex;
+ flex-direction: column;
+ height: 100dvh;
+ background: var(--color-dark-background, #111);
+ color: var(--color-text-primary, #fff);
+ position: relative;
+ overflow: hidden;
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--spacing-4, 16px) var(--spacing-5, 20px);
+ border-bottom: 1px solid var(--color-border-subtle, rgba(255, 255, 255, 0.08));
+ background: var(--color-header-background, rgba(0, 0, 0, 0.3));
+}
+
+.title {
+ font-size: var(--font-size-lg, 18px);
+ font-weight: 600;
+ margin: 0;
+}
+
+.subtitle {
+ font-size: var(--font-size-sm, 12px);
+ opacity: 0.7;
+}
+
+.area {
+ flex: 1 1 auto;
+ overflow-y: auto;
+ padding: var(--spacing-4, 16px);
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-3, 12px);
+}
+
+.aiDisclosure {
+ align-self: center;
+ background: var(--color-surface-muted, rgba(255, 255, 255, 0.06));
+ border-radius: var(--radius-md, 8px);
+ padding: var(--spacing-2, 8px) var(--spacing-4, 16px);
+ font-size: var(--font-size-sm, 12px);
+ opacity: 0.75;
+ text-align: center;
+}
+
+.bubbleRowAi {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--spacing-1, 4px);
+}
+
+.bubbleRowUser {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: var(--spacing-1, 4px);
+}
+
+.bubbleAi {
+ max-width: 75%;
+ background: var(--color-bubble-ai, rgba(255, 255, 255, 0.08));
+ padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
+ border-radius: var(--radius-lg, 12px);
+ font-size: var(--font-size-base, 14px);
+ line-height: 1.45;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.bubbleUser {
+ max-width: 75%;
+ background: var(--color-bubble-user, #4f46e5);
+ padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
+ border-radius: var(--radius-lg, 12px);
+ font-size: var(--font-size-base, 14px);
+ line-height: 1.45;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: #fff;
+}
+
+.bubbleImage {
+ max-width: 220px;
+ max-height: 220px;
+ border-radius: var(--radius-md, 8px);
+ object-fit: cover;
+}
+
+.bubbleTime {
+ font-size: var(--font-size-xs, 11px);
+ opacity: 0.5;
+}
+
+.typingDot {
+ display: inline-block;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+ margin: 0 2px;
+ animation: typingBlink 1.2s infinite;
+}
+.typingDot:nth-child(2) { animation-delay: 0.15s; }
+.typingDot:nth-child(3) { animation-delay: 0.3s; }
+
+@keyframes typingBlink {
+ 0%, 60%, 100% { opacity: 0.2; }
+ 30% { opacity: 1; }
+}
+
+.inputBar {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-2, 8px);
+ padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
+ border-top: 1px solid var(--color-chat-input-border, rgba(255, 255, 255, 0.12));
+ background: var(--color-input-bar-background, rgba(0, 0, 0, 0.2));
+}
+
+.input {
+ flex: 1 1 auto;
+ height: 40px;
+ padding: 0 var(--spacing-3, 12px);
+ border: 1px solid var(--color-chat-input-border, rgba(255, 255, 255, 0.2));
+ background: transparent;
+ color: inherit;
+ border-radius: var(--radius-md, 8px);
+ font-size: var(--font-size-base, 14px);
+ outline: none;
+}
+
+.input:focus {
+ border-color: var(--color-accent, #4f46e5);
+}
+
+.sendButton {
+ height: 40px;
+ padding: 0 var(--spacing-4, 16px);
+ background: var(--color-accent, #4f46e5);
+ color: #fff;
+ border: 0;
+ border-radius: var(--radius-md, 8px);
+ font-size: var(--font-size-base, 14px);
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.sendButton:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.dialogActions {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--spacing-2, 8px);
+ margin-top: var(--spacing-4, 16px);
+}
+
+.dialogTitle {
+ margin: 0 0 var(--spacing-2, 8px);
+ font-size: var(--font-size-lg, 18px);
+ font-weight: 600;
+}
+
+.dialogPrimary,
+.dialogSecondary {
+ height: 36px;
+ padding: 0 var(--spacing-4, 16px);
+ border-radius: var(--radius-md, 8px);
+ font-size: var(--font-size-base, 14px);
+ font-weight: 600;
+ border: 0;
+ cursor: pointer;
+}
+
+.dialogPrimary {
+ background: var(--color-accent, #4f46e5);
+ color: #fff;
+}
+
+.dialogSecondary {
+ background: transparent;
+ color: inherit;
+ border: 1px solid var(--color-border-subtle, rgba(255, 255, 255, 0.2));
+}
+
+.overlay {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 10;
+}
+
+.overlayCard {
+ background: var(--color-surface, #1f1f1f);
+ border-radius: var(--radius-lg, 12px);
+ padding: var(--spacing-5, 20px);
+ max-width: 320px;
+}
diff --git a/src/components/chat/chat-screen.tsx b/src/components/chat/chat-screen.tsx
new file mode 100644
index 00000000..28e200a7
--- /dev/null
+++ b/src/components/chat/chat-screen.tsx
@@ -0,0 +1,247 @@
+"use client";
+
+import { type FormEvent, useEffect, useRef, useState } from "react";
+
+import { MobileShell } from "@/components/core/mobile-shell";
+import { Dialog } from "@/components/core/dialog";
+import { useChatDispatch, useChatState } from "@/contexts/chat/chat-context";
+import { GuestChatQuota } from "@/contexts/chat/chat-types";
+import type { UiMessage } from "@/models/chat/ui-message";
+
+import styles from "./chat-screen.module.css";
+
+/**
+ * 聊天屏幕
+ *
+ * 原始 Dart: lib/ui/chat/chat_screen.dart
+ *
+ * 组成(自上而下):
+ * - ChatHeader(标题 + 菜单)
+ * - ChatArea(消息列表 + AI 披露 + 日期分隔 + 加载动画)
+ * - ChatInputBar(输入框 + 发送 / 语音 / 菜单)
+ * - QuotaDialog(游客配额告警弹窗)
+ * - PwaInstallOverlay / BrowserHintOverlay(占位)
+ */
+export function ChatScreen() {
+ const state = useChatState();
+ const dispatch = useChatDispatch();
+ const scrollRef = useRef(null);
+
+ const [quotaDialogOpen, setQuotaDialogOpen] = useState(false);
+ const [quotaExhausted, setQuotaExhausted] = useState(false);
+ const [input, setInput] = useState("");
+ const [showPwaOverlay, setShowPwaOverlay] = useState(false);
+
+ // 初始化 + 切屏事件
+ useEffect(() => {
+ dispatch({ type: "ChatInit" });
+ dispatch({ type: "ChatScreenVisible" });
+ return () => {
+ dispatch({ type: "ChatScreenInvisible" });
+ };
+ }, [dispatch]);
+
+ // 配额触发监听
+ const prevTriggerRef = useRef(state.quotaExceededTrigger);
+ useEffect(() => {
+ if (
+ state.quotaExceededTrigger !== prevTriggerRef.current &&
+ state.quotaExceededTrigger > 0
+ ) {
+ setQuotaExhausted(true);
+ setQuotaDialogOpen(true);
+ }
+ prevTriggerRef.current = state.quotaExceededTrigger;
+ }, [state.quotaExceededTrigger]);
+
+ // 配额告警监听(剩余 = 5)
+ const prevRemainingRef = useRef(state.guestRemainingQuota);
+ useEffect(() => {
+ if (
+ state.isGuest &&
+ state.guestRemainingQuota === GuestChatQuota.warningThreshold &&
+ prevRemainingRef.current !== state.guestRemainingQuota
+ ) {
+ setQuotaExhausted(false);
+ setQuotaDialogOpen(true);
+ }
+ prevRemainingRef.current = state.guestRemainingQuota;
+ }, [state.guestRemainingQuota, state.isGuest]);
+
+ // 滚动到底部
+ useEffect(() => {
+ scrollRef.current?.scrollTo({
+ top: scrollRef.current.scrollHeight,
+ behavior: "smooth",
+ });
+ }, [state.messages.length, state.isReplyingAI]);
+
+ const onSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ if (!input.trim()) return;
+ dispatch({ type: "ChatSendMessage", content: input });
+ setInput("");
+ };
+
+ return (
+
+
+
+
+
+
+ {state.messages.map((m, i) => (
+
+ ))}
+ {state.isReplyingAI && }
+
+
+
+
+
+
+ {showPwaOverlay && (
+
setShowPwaOverlay(false)} />
+ )}
+
+
+ );
+}
+
+function ChatHeader({ isGuest }: { isGuest: boolean }) {
+ return (
+
+ cozsweet
+
+ {isGuest ? "Guest mode" : "Signed in"}
+
+
+ );
+}
+
+function AiDisclosure() {
+ return (
+
+ You're chatting with an AI companion.
+
+ );
+}
+
+function MessageBubble({ message }: { message: UiMessage }) {
+ const isAI = message.isFromAI;
+ return (
+
+ {message.imageUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+ {message.content}
+
+ )}
+
+
+ );
+}
+
+function LoadingBubble() {
+ return (
+
+ );
+}
+
+function ChatInputBar({
+ value,
+ onChange,
+ onSubmit,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ onSubmit: (e: FormEvent) => void;
+}) {
+ return (
+
+ );
+}
+
+function PwaInstallOverlay({ onClose }: { onClose: () => void }) {
+ return (
+
+
+
Install cozsweet
+
Add to home screen for the best experience.
+
+
+
+ );
+}
diff --git a/src/components/core/auth-back-button.module.css b/src/components/core/auth-back-button.module.css
new file mode 100644
index 00000000..6fa6c265
--- /dev/null
+++ b/src/components/core/auth-back-button.module.css
@@ -0,0 +1,22 @@
+.button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ border: none;
+ border-radius: var(--radius-full);
+ background: transparent;
+ color: var(--color-text-primary);
+ cursor: pointer;
+ transition: background 0.15s ease;
+}
+
+.button:hover {
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.button:focus-visible {
+ outline: var(--border-medium) solid var(--color-accent);
+ outline-offset: 2px;
+}
diff --git a/src/components/core/auth-back-button.tsx b/src/components/core/auth-back-button.tsx
new file mode 100644
index 00000000..e2d8d1de
--- /dev/null
+++ b/src/components/core/auth-back-button.tsx
@@ -0,0 +1,59 @@
+"use client";
+/**
+ * 通用返回按钮
+ *
+ * 原始 Dart: lib/ui/auth/widgets/back_button.dart(被 auth/chat/sidebar 共用)。
+ */
+import { useRouter } from "next/navigation";
+import { type MouseEvent } from "react";
+
+import styles from "./auth-back-button.module.css";
+
+export interface AuthBackButtonProps {
+ /** 自定义跳转目标(默认 history.back)。 */
+ href?: string;
+ /** 自定义点击处理(覆盖默认跳转)。 */
+ onClick?: (e: MouseEvent) => void;
+ className?: string;
+ ariaLabel?: string;
+}
+
+export function AuthBackButton({
+ href,
+ onClick,
+ className,
+ ariaLabel = "Back",
+}: AuthBackButtonProps) {
+ const router = useRouter();
+ return (
+
+ );
+}
diff --git a/src/components/core/dialog.module.css b/src/components/core/dialog.module.css
new file mode 100644
index 00000000..5a7d28fc
--- /dev/null
+++ b/src/components/core/dialog.module.css
@@ -0,0 +1,20 @@
+.scrim {
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--spacing-md);
+}
+
+.panel {
+ position: relative;
+ width: 100%;
+ background: var(--color-dialog-background);
+ border: var(--border-light) solid var(--color-dialog-border);
+ border-radius: var(--radius-xl);
+ box-shadow: 0 24px 48px rgba(0, 0, 0, 0.45);
+ color: var(--color-text-primary);
+ overflow: hidden;
+}
diff --git a/src/components/core/dialog.tsx b/src/components/core/dialog.tsx
new file mode 100644
index 00000000..b3d47111
--- /dev/null
+++ b/src/components/core/dialog.tsx
@@ -0,0 +1,79 @@
+"use client";
+/**
+ * 通用 Dialog 基础组件(headless + 简单样式)
+ *
+ * 原始 Dart: 散落在各处的 `showDialog` + `Dialog` widget(quota_dialog, pwa_install_dialog,
+ * username_dialog, pronouns_dialog, subscription_dialog, environment_dialog, auth_other_options_dialog,
+ * facebook_login_dialog, external_browser_dialog)。
+ *
+ * 设计目标:~70 行覆盖所有 9 种 dialog 的通用逻辑(portal, ESC, scroll lock, click-outside),
+ * 调用方只关心内容布局。
+ *
+ * 注意:项目不引入 Radix/shadcn,纯依赖 React + CSS Modules。
+ */
+import { type ReactNode, useEffect } from "react";
+import { createPortal } from "react-dom";
+
+import styles from "./dialog.module.css";
+
+export interface DialogProps {
+ open: boolean;
+ onClose: () => void;
+ children: ReactNode;
+ /** 内容最大宽度(px)。默认 360。 */
+ maxWidth?: number;
+ /** 蒙层透明度(0-1)。默认 0.5。 */
+ scrimOpacity?: number;
+ /** 禁用点击外部 / ESC 关闭。 */
+ persistent?: boolean;
+ /** ARIA 标签。 */
+ ariaLabel?: string;
+}
+
+export function Dialog({
+ open,
+ onClose,
+ children,
+ maxWidth = 360,
+ scrimOpacity = 0.5,
+ persistent = false,
+ ariaLabel,
+}: DialogProps) {
+ useEffect(() => {
+ if (!open || typeof document === "undefined") return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape" && !persistent) onClose();
+ };
+ document.addEventListener("keydown", onKey);
+ const prevOverflow = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.removeEventListener("keydown", onKey);
+ document.body.style.overflow = prevOverflow;
+ };
+ }, [open, onClose, persistent]);
+
+ if (!open || typeof document === "undefined") return null;
+
+ return createPortal(
+ {
+ if (!persistent && e.target === e.currentTarget) onClose();
+ }}
+ >
+
e.stopPropagation()}
+ >
+ {children}
+
+
,
+ document.body,
+ );
+}
diff --git a/src/components/core/loading-indicator.module.css b/src/components/core/loading-indicator.module.css
new file mode 100644
index 00000000..e9ed37a5
--- /dev/null
+++ b/src/components/core/loading-indicator.module.css
@@ -0,0 +1,26 @@
+.wrapper {
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--spacing-sm);
+}
+
+.spinner {
+ display: inline-block;
+ border-style: solid;
+ border-color: var(--color-text-secondary) transparent transparent
+ transparent;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.label {
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-sm);
+}
diff --git a/src/components/core/loading-indicator.tsx b/src/components/core/loading-indicator.tsx
new file mode 100644
index 00000000..9a9bb9c3
--- /dev/null
+++ b/src/components/core/loading-indicator.tsx
@@ -0,0 +1,40 @@
+"use client";
+/**
+ * 通用加载指示器
+ *
+ * 原始 Dart: lib/ui/core/loading_indicator.dart
+ *
+ * 行为对齐 Flutter `CircularProgressIndicator`:
+ * - `size`:圆圈直径(默认 32)
+ * - `strokeWidth`:环宽(默认 3)
+ * - `color`:环颜色(默认 `--color-accent`)
+ * - `label`:可选的可视标签(环形下方文本)
+ */
+import styles from "./loading-indicator.module.css";
+
+export interface LoadingIndicatorProps {
+ size?: number;
+ strokeWidth?: number;
+ color?: string;
+ label?: string;
+}
+
+export function LoadingIndicator({
+ size = 32,
+ strokeWidth = 3,
+ color,
+ label,
+}: LoadingIndicatorProps) {
+ const style: React.CSSProperties = {
+ width: size,
+ height: size,
+ borderWidth: strokeWidth,
+ ...(color ? { borderTopColor: color } : {}),
+ };
+ return (
+
+
+ {label ?
{label} : null}
+
+ );
+}
diff --git a/src/components/core/mobile-shell.module.css b/src/components/core/mobile-shell.module.css
new file mode 100644
index 00000000..197af616
--- /dev/null
+++ b/src/components/core/mobile-shell.module.css
@@ -0,0 +1,19 @@
+.shell {
+ display: flex;
+ flex: 1 1 auto;
+ align-items: stretch;
+ justify-content: center;
+ width: 100%;
+ min-height: 100dvh;
+ background: inherit;
+}
+
+.content {
+ position: relative;
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ width: 100%;
+ max-width: 500px;
+ min-height: 100dvh;
+}
diff --git a/src/components/core/mobile-shell.tsx b/src/components/core/mobile-shell.tsx
new file mode 100644
index 00000000..cab15323
--- /dev/null
+++ b/src/components/core/mobile-shell.tsx
@@ -0,0 +1,37 @@
+"use client";
+/**
+ * 500px 移动端 Shell
+ *
+ * 原始 Dart: 每个屏幕的 `Scaffold → SafeArea → Center → ConstrainedBox(maxWidth: 500)` 模式
+ * (`Breakpoints.mobileMaxWidth = 500.0`)。
+ *
+ * 集中封装,调用方仅需 `{...}` 即可获得统一的移动端宽度约束。
+ */
+import type { ReactNode } from "react";
+
+import styles from "./mobile-shell.module.css";
+
+export interface MobileShellProps {
+ children: ReactNode;
+ /** 自定义背景色(默认透明,继承 body 背景)。 */
+ background?: string;
+ /** 自定义 className(注入到内容容器上)。 */
+ className?: string;
+}
+
+export function MobileShell({
+ children,
+ background,
+ className,
+}: MobileShellProps) {
+ return (
+
+ );
+}
diff --git a/src/components/core/settings-section.module.css b/src/components/core/settings-section.module.css
new file mode 100644
index 00000000..66fbde38
--- /dev/null
+++ b/src/components/core/settings-section.module.css
@@ -0,0 +1,101 @@
+.section {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+}
+
+.title {
+ margin: 0;
+ padding: 0 var(--spacing-md);
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ color: var(--color-section-title);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ background: var(--color-settings-card-background);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+}
+
+.row {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md);
+ width: 100%;
+ padding: var(--spacing-md) var(--spacing-lg);
+ border: none;
+ background: transparent;
+ color: var(--color-settings-text-primary);
+ font-size: var(--font-size-md);
+ text-align: left;
+ cursor: pointer;
+ transition: background 0.15s ease;
+}
+
+.row:hover {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.row:focus-visible {
+ outline: var(--border-medium) solid var(--color-accent);
+ outline-offset: -2px;
+}
+
+.icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ color: var(--color-chevron-icon);
+ flex-shrink: 0;
+}
+
+.body {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.rowTitle {
+ font-size: var(--font-size-md);
+ font-weight: 500;
+ color: var(--color-settings-text-primary);
+}
+
+.subtitle {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+}
+
+.trailing {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+ flex-shrink: 0;
+}
+
+.chevron {
+ color: var(--color-chevron-icon);
+ flex-shrink: 0;
+}
+
+.divider {
+ height: var(--border-light);
+ background: var(--color-settings-divider);
+ margin: 0 var(--spacing-lg);
+}
+
+.destructive {
+ color: var(--color-destructive);
+}
+.destructive .rowTitle {
+ color: var(--color-destructive);
+}
diff --git a/src/components/core/settings-section.tsx b/src/components/core/settings-section.tsx
new file mode 100644
index 00000000..affafa70
--- /dev/null
+++ b/src/components/core/settings-section.tsx
@@ -0,0 +1,82 @@
+"use client";
+/**
+ * 设置项列表 Section
+ *
+ * 原始 Dart: lib/ui/core/settings_section.dart
+ *
+ * 渲染一组 SettingItem:图标 + 标题 + 副标题/值 + 右侧 chevron + 可选 divider。
+ */
+import { type ReactNode } from "react";
+
+import styles from "./settings-section.module.css";
+
+export interface SettingsItemModel {
+ id: string;
+ title: string;
+ subtitle?: string;
+ trailing?: string;
+ icon?: ReactNode;
+ onClick?: () => void;
+ destructive?: boolean;
+}
+
+export interface SettingsSectionProps {
+ title?: string;
+ items: SettingsItemModel[];
+}
+
+export function SettingsSection({ title, items }: SettingsSectionProps) {
+ return (
+
+ {title ? {title}
: null}
+
+ {items.map((item, idx) => (
+ -
+
+ {idx < items.length - 1 ? (
+
+ ) : null}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/sidebar/sidebar-screen.module.css b/src/components/sidebar/sidebar-screen.module.css
new file mode 100644
index 00000000..6ce75a92
--- /dev/null
+++ b/src/components/sidebar/sidebar-screen.module.css
@@ -0,0 +1,159 @@
+.shell {
+ display: flex;
+ flex-direction: column;
+ min-height: 100dvh;
+ background: var(--color-sidebar-background, #fafafa);
+ color: var(--color-text-primary, #111);
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-3, 12px);
+ padding: var(--spacing-4, 16px);
+ border-bottom: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.08));
+ background: var(--color-surface, #fff);
+}
+
+.backButton {
+ font-size: 20px;
+ text-decoration: none;
+ color: inherit;
+ padding: 4px 8px;
+ border-radius: var(--radius-sm, 4px);
+}
+
+.backButton:hover {
+ background: rgba(0, 0, 0, 0.05);
+}
+
+.title {
+ font-size: var(--font-size-lg, 18px);
+ font-weight: 600;
+ margin: 0;
+}
+
+.main {
+ flex: 1 1 auto;
+ padding: var(--spacing-4, 16px);
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-5, 20px);
+}
+
+.profileCard {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-3, 12px);
+ background: var(--color-surface, #fff);
+ border-radius: var(--radius-lg, 12px);
+ padding: var(--spacing-4, 16px);
+}
+
+.avatar {
+ width: 56px;
+ height: 56px;
+ border-radius: 50%;
+ background: var(--color-accent, #4f46e5);
+ color: #fff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 24px;
+ font-weight: 600;
+ overflow: hidden;
+ flex-shrink: 0;
+}
+
+.avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.avatarInitial {
+ text-transform: uppercase;
+}
+
+.userInfo {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.userInfo h2 {
+ margin: 0;
+ font-size: var(--font-size-base, 16px);
+ font-weight: 600;
+}
+
+.userInfo p {
+ margin: 4px 0 0;
+ font-size: var(--font-size-sm, 13px);
+ color: var(--color-text-secondary, #666);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.signInButton {
+ flex-shrink: 0;
+ background: var(--color-accent, #4f46e5);
+ color: #fff;
+ padding: 8px 16px;
+ border-radius: var(--radius-md, 8px);
+ text-decoration: none;
+ font-size: var(--font-size-sm, 13px);
+ font-weight: 600;
+}
+
+.settings {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-4, 16px);
+}
+
+.section {
+ background: var(--color-surface, #fff);
+ border-radius: var(--radius-lg, 12px);
+ overflow: hidden;
+}
+
+.sectionTitle {
+ margin: 0;
+ padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
+ font-size: var(--font-size-xs, 11px);
+ text-transform: uppercase;
+ color: var(--color-text-secondary, #666);
+ background: rgba(0, 0, 0, 0.02);
+}
+
+.sectionList {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.sectionList li + li {
+ border-top: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.06));
+}
+
+.settingsItem,
+.dangerItem {
+ width: 100%;
+ text-align: left;
+ background: transparent;
+ border: 0;
+ padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
+ font-size: var(--font-size-base, 14px);
+ color: inherit;
+ cursor: pointer;
+}
+
+.settingsItem:hover,
+.dangerItem:hover {
+ background: rgba(0, 0, 0, 0.03);
+}
+
+.dangerItem {
+ color: var(--color-danger, #dc2626);
+}
diff --git a/src/components/sidebar/sidebar-screen.tsx b/src/components/sidebar/sidebar-screen.tsx
new file mode 100644
index 00000000..c42ad89b
--- /dev/null
+++ b/src/components/sidebar/sidebar-screen.tsx
@@ -0,0 +1,137 @@
+"use client";
+
+import { useEffect } from "react";
+import Link from "next/link";
+
+import { MobileShell } from "@/components/core/mobile-shell";
+import { useUserDispatch, useUserState } from "@/contexts/user/user-context";
+
+import styles from "./sidebar-screen.module.css";
+
+/**
+ * 侧边栏主屏幕
+ *
+ * 原始 Dart: lib/ui/sidebar/sidebar_screen.dart + profile_view.dart
+ */
+export function SidebarScreen() {
+ const user = useUserState();
+ const dispatch = useUserDispatch();
+
+ useEffect(() => {
+ dispatch({ type: "UserInit" });
+ }, [dispatch]);
+
+ return (
+
+
+
+
+
+
+
+ {user.avatarUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+ {(user.currentUser?.username ?? "?").charAt(0).toUpperCase()}
+
+ )}
+
+
+
{user.currentUser?.username ?? "Guest"}
+
{user.currentUser?.email ?? "Sign in to get started"}
+
+ {user.currentUser ? null : (
+
+ Sign in
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+function SettingsList({
+ user,
+ dispatch,
+}: {
+ user: ReturnType;
+ dispatch: ReturnType;
+}) {
+ const sections: Array<{
+ title: string;
+ items: Array<{ label: string; onClick: () => void; danger?: boolean }>;
+ }> = [
+ {
+ title: "Account",
+ items: [
+ { label: "Edit username", onClick: () => {} },
+ { label: "Edit pronouns", onClick: () => {} },
+ { label: "Subscription", onClick: () => {} },
+ ],
+ },
+ {
+ title: "Data",
+ items: [
+ {
+ label: "Delete chat history",
+ onClick: () => dispatch({ type: "UserDeleteChatHistory" }),
+ },
+ {
+ label: "Delete account",
+ onClick: () => dispatch({ type: "UserDeleteAccount" }),
+ danger: true,
+ },
+ ],
+ },
+ {
+ title: "Session",
+ items: user.currentUser
+ ? [
+ {
+ label: "Log out",
+ onClick: () => dispatch({ type: "UserLogout" }),
+ danger: true,
+ },
+ ]
+ : [],
+ },
+ ];
+
+ return (
+
+ {sections
+ .filter((s) => s.items.length > 0)
+ .map((s) => (
+
+
{s.title}
+
+ {s.items.map((it) => (
+ -
+
+
+ ))}
+
+
+ ))}
+
+ );
+}
diff --git a/src/components/splash/splash-background.module.css b/src/components/splash/splash-background.module.css
new file mode 100644
index 00000000..585271e6
--- /dev/null
+++ b/src/components/splash/splash-background.module.css
@@ -0,0 +1,8 @@
+.bg {
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(circle at 80% 20%, rgba(248, 77, 150, 0.25), transparent 60%),
+ linear-gradient(180deg, #1a0f2a 0%, #0d0b14 100%);
+ z-index: 0;
+}
diff --git a/src/components/splash/splash-background.tsx b/src/components/splash/splash-background.tsx
new file mode 100644
index 00000000..9920d1d3
--- /dev/null
+++ b/src/components/splash/splash-background.tsx
@@ -0,0 +1,14 @@
+"use client";
+/**
+ * Splash 背景
+ *
+ * 原始 Dart: lib/ui/splash/widgets/splash_background.dart
+ * 使用 `Assets.images.picBgHome.image(fit: BoxFit.cover)` 渲染全屏背景图。
+ *
+ * 当前实现:纯色 + CSS 渐变占位(待 `public/splash/bg.png` 资源到位后可切换为 ``)。
+ */
+import styles from "./splash-background.module.css";
+
+export function SplashBackground() {
+ return ;
+}
diff --git a/src/components/splash/splash-button.module.css b/src/components/splash/splash-button.module.css
new file mode 100644
index 00000000..6af579e6
--- /dev/null
+++ b/src/components/splash/splash-button.module.css
@@ -0,0 +1,7 @@
+.wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-md);
+ width: 100%;
+ z-index: 2;
+}
diff --git a/src/components/splash/splash-button.tsx b/src/components/splash/splash-button.tsx
new file mode 100644
index 00000000..9fcf4647
--- /dev/null
+++ b/src/components/splash/splash-button.tsx
@@ -0,0 +1,35 @@
+"use client";
+/**
+ * Splash 底部按钮组(Skip + Facebook 登录)
+ *
+ * 原始 Dart: lib/ui/splash/widgets/splash_button.dart
+ */
+import { useAuthState } from "@/contexts/auth/auth-context";
+
+import { AuthSocialButtons } from "@/components/auth/auth-social-buttons";
+import { AuthPrimaryButton } from "@/components/auth/auth-primary-button";
+import { AuthDivider } from "@/components/auth/auth-divider";
+import { useAuthDispatch } from "@/contexts/auth/auth-context";
+import styles from "./splash-button.module.css";
+
+export interface SplashButtonProps {
+ onSkip: () => void;
+}
+
+export function SplashButton({ onSkip }: SplashButtonProps) {
+ const state = useAuthState();
+ const dispatch = useAuthDispatch();
+ return (
+
+
dispatch({ type: "AuthFacebookLoginSubmitted" })}
+ onGoogle={() => dispatch({ type: "AuthGoogleLoginSubmitted" })}
+ />
+
+
+ Skip — Continue as Guest
+
+
+ );
+}
diff --git a/src/components/splash/splash-content.module.css b/src/components/splash/splash-content.module.css
new file mode 100644
index 00000000..70d7bbcd
--- /dev/null
+++ b/src/components/splash/splash-content.module.css
@@ -0,0 +1,22 @@
+.content {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-md);
+ color: var(--color-text-primary);
+ z-index: 2;
+}
+
+.title {
+ margin: 0;
+ font-family: var(--font-athelas);
+ font-size: var(--font-size-26);
+ font-weight: 600;
+ line-height: 1.3;
+}
+
+.subtitle {
+ margin: 0;
+ font-size: var(--font-size-md);
+ line-height: 1.5;
+ color: rgba(255, 255, 255, 0.8);
+}
diff --git a/src/components/splash/splash-content.tsx b/src/components/splash/splash-content.tsx
new file mode 100644
index 00000000..1fe9338c
--- /dev/null
+++ b/src/components/splash/splash-content.tsx
@@ -0,0 +1,18 @@
+"use client";
+/**
+ * Splash 内容文案
+ *
+ * 原始 Dart: lib/ui/splash/widgets/splash_content.dart
+ */
+import styles from "./splash-content.module.css";
+
+export function SplashContent() {
+ return (
+
+
Chat with Elio, anytime, anywhere
+
+ A safe, private space to talk, share, and unwind — your AI companion who's always there.
+
+
+ );
+}
diff --git a/src/components/splash/splash-loading.tsx b/src/components/splash/splash-loading.tsx
new file mode 100644
index 00000000..d74a353b
--- /dev/null
+++ b/src/components/splash/splash-loading.tsx
@@ -0,0 +1,13 @@
+"use client";
+/**
+ * Splash 加载状态
+ *
+ * 原始 Dart: lib/ui/splash/widgets/splash_loading.dart
+ *
+ * 包装 ``,splash 阶段可能用于等待 auth 检查。
+ */
+import { LoadingIndicator } from "@/components/core/loading-indicator";
+
+export function SplashLoading() {
+ return ;
+}
diff --git a/src/components/splash/splash-logo.module.css b/src/components/splash/splash-logo.module.css
new file mode 100644
index 00000000..39cbe2bf
--- /dev/null
+++ b/src/components/splash/splash-logo.module.css
@@ -0,0 +1,20 @@
+.logo {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-xs);
+ color: var(--color-text-primary);
+ z-index: 2;
+}
+
+.brand {
+ font-family: var(--font-athelas);
+ font-size: var(--font-size-title-x-large);
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ line-height: 1;
+}
+
+.tagline {
+ font-size: var(--font-size-md);
+ color: var(--color-text-secondary);
+}
diff --git a/src/components/splash/splash-logo.tsx b/src/components/splash/splash-logo.tsx
new file mode 100644
index 00000000..5f856fcb
--- /dev/null
+++ b/src/components/splash/splash-logo.tsx
@@ -0,0 +1,19 @@
+"use client";
+/**
+ * Splash Logo
+ *
+ * 原始 Dart: lib/ui/splash/widgets/splash_logo.dart
+ *
+ * 当前实现:纯文字 logo("cozsweet" + tagline)。
+ * 资源:`Assets.images.icLogoHome`(待替换为 ``)。
+ */
+import styles from "./splash-logo.module.css";
+
+export function SplashLogo() {
+ return (
+
+ cozsweet
+ Your exclusive AI boyfriend
+
+ );
+}
diff --git a/src/components/splash/splash-progress.module.css b/src/components/splash/splash-progress.module.css
new file mode 100644
index 00000000..6c7ec43a
--- /dev/null
+++ b/src/components/splash/splash-progress.module.css
@@ -0,0 +1,28 @@
+.wrapper {
+ position: relative;
+ width: 100%;
+ height: 4px;
+ border-radius: var(--radius-full);
+ background: rgba(255, 255, 255, 0.1);
+ overflow: hidden;
+}
+
+.bar {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ width: 30%;
+ border-radius: var(--radius-full);
+ background: linear-gradient(90deg, var(--color-accent), var(--color-facebook-button-gradient-end));
+ animation: progress 1.5s ease-in-out infinite;
+}
+
+@keyframes progress {
+ 0% {
+ left: -30%;
+ }
+ 100% {
+ left: 100%;
+ }
+}
diff --git a/src/components/splash/splash-progress.tsx b/src/components/splash/splash-progress.tsx
new file mode 100644
index 00000000..0faf6e6a
--- /dev/null
+++ b/src/components/splash/splash-progress.tsx
@@ -0,0 +1,17 @@
+"use client";
+/**
+ * Splash 进度条
+ *
+ * 原始 Dart: lib/ui/splash/widgets/splash_progress.dart
+ *
+ * 当前实现:简单 CSS 动画进度条,0 → 100% 1.5s。
+ */
+import styles from "./splash-progress.module.css";
+
+export function SplashProgress() {
+ return (
+
+ );
+}
diff --git a/src/components/splash/splash-screen.module.css b/src/components/splash/splash-screen.module.css
new file mode 100644
index 00000000..838d3b8c
--- /dev/null
+++ b/src/components/splash/splash-screen.module.css
@@ -0,0 +1,47 @@
+.wrapper {
+ position: relative;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ min-height: 100dvh;
+ overflow: hidden;
+}
+
+.gradientOverlay {
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(
+ to top right,
+ var(--color-accent),
+ transparent 60%
+ );
+ opacity: 0.4;
+ z-index: 1;
+ pointer-events: none;
+}
+
+.content {
+ position: relative;
+ z-index: 2;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ padding: var(--spacing-26) var(--spacing-26) var(--spacing-md);
+}
+
+.spacer {
+ flex: 1 1 auto;
+}
+
+.buttonArea {
+ margin-top: var(--spacing-26);
+}
+
+.bottom {
+ margin: var(--spacing-xxxl) 0 0 0;
+ font-size: var(--font-size-md);
+ line-height: 1.5;
+ color: rgba(255, 255, 255, 0.9);
+ text-align: center;
+}
diff --git a/src/components/splash/splash-screen.tsx b/src/components/splash/splash-screen.tsx
new file mode 100644
index 00000000..5a44d742
--- /dev/null
+++ b/src/components/splash/splash-screen.tsx
@@ -0,0 +1,70 @@
+"use client";
+/**
+ * Splash 屏幕
+ *
+ * 原始 Dart: lib/ui/splash/splash_screen.dart
+ *
+ * 行为:
+ * - 渐变背景 + 品牌区 + 内容文案 + Skip/Facebook 按钮 + 底部辅助信息
+ * - 已登录用户:自动跳 /chat(由父级处理)
+ */
+import { useRouter } from "next/navigation";
+import { useEffect, useRef } from "react";
+
+import { useAuthState } from "@/contexts/auth/auth-context";
+import { useChatDispatch } from "@/contexts/chat/chat-context";
+import { useUserDispatch } from "@/contexts/user/user-context";
+import { useAuthGate } from "@/lib/auth/use-auth-gate";
+import { ROUTES } from "@/lib/routes";
+import { MobileShell } from "@/components/core/mobile-shell";
+
+import { SplashBackground } from "./splash-background";
+import { SplashLogo } from "./splash-logo";
+import { SplashContent } from "./splash-content";
+import { SplashButton } from "./splash-button";
+import styles from "./splash-screen.module.css";
+
+export function SplashScreen() {
+ const router = useRouter();
+ const { isAuthed } = useAuthGate();
+ const state = useAuthState();
+ const wasSuccess = useRef(false);
+ const chatDispatch = useChatDispatch();
+ const userDispatch = useUserDispatch();
+
+ useEffect(() => {
+ if (isAuthed) router.replace(ROUTES.chat);
+ }, [isAuthed, router]);
+
+ // 登录成功跳 /chat
+ useEffect(() => {
+ if (state.isSuccess && !wasSuccess.current) {
+ wasSuccess.current = true;
+ chatDispatch({ type: "ChatAuthStatusChanged" });
+ userDispatch({ type: "UserInit" });
+ router.replace(ROUTES.chat);
+ }
+ }, [state.isSuccess, chatDispatch, userDispatch, router]);
+
+ return (
+
+
+
+
+
+
+
+
+
+ router.push(ROUTES.chat)} />
+
+
+ Elio Silvestri, Your exclusive AI boyfriend
+
+ 24/7 online | Chat | Companion | Heal | Sweet moments
+
+
+
+
+ );
+}
diff --git a/src/contexts/auth/auth-context.tsx b/src/contexts/auth/auth-context.tsx
new file mode 100644
index 00000000..fe6ec5ff
--- /dev/null
+++ b/src/contexts/auth/auth-context.tsx
@@ -0,0 +1,72 @@
+"use client";
+/**
+ * AuthContext:提供 State + Dispatch 的 React Context Provider。
+ *
+ * 业务事件(`Auth*Submitted`)通过 `sideEffects.run` 拦截 → 异步完成后 dispatch 内部 `_Set*` 事件。
+ * UI 侧仅消费 `useAuthState()`,调用 `useAuthDispatch()` 派发业务事件。
+ */
+import {
+ type Dispatch,
+ type ReactNode,
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useReducer,
+} from "react";
+
+import { authReducer } from "./auth-reducer";
+import {
+ type AuthEvent,
+ type AuthState,
+ initialAuthState,
+} from "./auth-types";
+import {
+ type AuthSideEffects,
+ authSideEffects as defaultSideEffects,
+} from "./auth-side-effects";
+
+const AuthStateCtx = createContext(null);
+const AuthDispatchCtx = createContext | null>(null);
+
+export interface AuthProviderProps {
+ children: ReactNode;
+ /** 可注入 side-effects(用于测试 / Storybook)。默认使用全局单例。 */
+ sideEffects?: AuthSideEffects;
+}
+
+export function AuthProvider({
+ children,
+ sideEffects = defaultSideEffects,
+}: AuthProviderProps) {
+ const [state, rawDispatch] = useReducer(authReducer, initialAuthState);
+ const dispatch = useCallback>(
+ (event) => {
+ rawDispatch(event);
+ void sideEffects.run(event, rawDispatch);
+ },
+ [sideEffects],
+ );
+
+ const ctxValue = useMemo(() => state, [state]);
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export function useAuthState(): AuthState {
+ const ctx = useContext(AuthStateCtx);
+ if (!ctx) throw new Error("useAuthState must be used inside ");
+ return ctx;
+}
+
+export function useAuthDispatch(): Dispatch {
+ const ctx = useContext(AuthDispatchCtx);
+ if (!ctx)
+ throw new Error("useAuthDispatch must be used inside ");
+ return ctx;
+}
diff --git a/src/contexts/auth/auth-reducer.ts b/src/contexts/auth/auth-reducer.ts
new file mode 100644
index 00000000..14491016
--- /dev/null
+++ b/src/contexts/auth/auth-reducer.ts
@@ -0,0 +1,77 @@
+import { type AuthEvent, type AuthState } from "./auth-types";
+
+/**
+ * 纯 reducer:`(state, event) => state`
+ *
+ * 业务事件(`Auth*Submitted` / `AuthFormCleared` 等)由 side-effects 拦截执行异步,
+ * 内部事件(`_SetLoading` / `_SetError` / `_SetSuccess`)由 side-effects 完成后 dispatch。
+ */
+export function authReducer(state: AuthState, event: AuthEvent): AuthState {
+ switch (event.type) {
+ case "AuthPanelModeChanged":
+ return { ...state, authPanelMode: event.mode, errorMessage: null };
+
+ case "AuthModeChanged":
+ return { ...state, authMode: event.mode, errorMessage: null };
+
+ case "_SetLoading":
+ return {
+ ...state,
+ isLoading: event.isLoading,
+ errorMessage: event.isLoading ? null : state.errorMessage,
+ };
+
+ case "_SetError":
+ return { ...state, isLoading: false, errorMessage: event.errorMessage };
+
+ case "_SetSuccess":
+ return {
+ ...state,
+ isLoading: false,
+ isSuccess: true,
+ loginType: event.loginType ?? state.loginType,
+ };
+
+ case "_SetForm":
+ return {
+ ...state,
+ email: event.email ?? state.email,
+ password: event.password ?? state.password,
+ username: event.username ?? state.username,
+ confirmPassword: event.confirmPassword ?? state.confirmPassword,
+ };
+
+ case "AuthFormCleared":
+ return {
+ ...state,
+ email: "",
+ password: "",
+ username: "",
+ confirmPassword: "",
+ errorMessage: null,
+ };
+
+ case "AuthReset":
+ return {
+ authPanelMode: "facebook",
+ authMode: "login",
+ email: "",
+ password: "",
+ username: "",
+ confirmPassword: "",
+ isLoading: false,
+ errorMessage: null,
+ isSuccess: false,
+ loginType: "none",
+ };
+
+ // 业务事件:reducer 不直接处理(由 side-effects 拦截 + 内部 _Set* 事件驱动)
+ case "AuthEmailLoginSubmitted":
+ case "AuthEmailRegisterSubmitted":
+ case "AuthGoogleLoginSubmitted":
+ case "AuthFacebookLoginSubmitted":
+ case "AuthAppleLoginSubmitted":
+ case "AuthWebGoogleLoginSuccess":
+ return state;
+ }
+}
diff --git a/src/contexts/auth/auth-side-effects.ts b/src/contexts/auth/auth-side-effects.ts
new file mode 100644
index 00000000..294fe5a3
--- /dev/null
+++ b/src/contexts/auth/auth-side-effects.ts
@@ -0,0 +1,146 @@
+import type { Dispatch } from "react";
+
+import { authRepository } from "@/data/repositories/auth_repository";
+import { authStorage } from "@/data/storage/auth_storage";
+import {
+ loginWithFacebook,
+ getFacebookUserData,
+} from "@/integrations/facebook-sdk";
+import {
+ handleGoogleCredentialResponse,
+} from "@/integrations/google-identity";
+import { Result } from "@/utils/result";
+
+import { type AuthEvent } from "./auth-types";
+
+/**
+ * 认证 side-effects:拦截业务事件执行异步,dispatch 内部 _Set* 事件。
+ */
+export type AuthSideEffects = {
+ run: (
+ event: AuthEvent,
+ dispatch: Dispatch,
+ ) => void | Promise;
+};
+
+export const authSideEffects: AuthSideEffects = {
+ run: async (event, dispatch) => {
+ switch (event.type) {
+ case "AuthEmailLoginSubmitted": {
+ dispatch({ type: "_SetLoading", isLoading: true });
+ const guestId = (authStorage.getDeviceId?.() ?? undefined) || undefined;
+ const result = await authRepository.emailLogin({
+ email: event.email,
+ password: event.password,
+ guestId,
+ });
+ if (Result.isOk(result)) {
+ dispatch({ type: "_SetSuccess", loginType: "email" });
+ } else {
+ dispatch({ type: "_SetError", errorMessage: result.error.message });
+ }
+ break;
+ }
+
+ case "AuthEmailRegisterSubmitted": {
+ dispatch({ type: "_SetLoading", isLoading: true });
+ const guestId = (authStorage.getDeviceId?.() ?? undefined) || undefined;
+ const result = await authRepository.register({
+ username: event.username,
+ email: event.email,
+ password: event.password,
+ guestId,
+ });
+ if (Result.isOk(result)) {
+ // 注册成功需自行调 emailLogin(对齐 Dart 行为)
+ const loginResult = await authRepository.emailLogin({
+ email: event.email,
+ password: event.password,
+ guestId,
+ });
+ if (Result.isOk(loginResult)) {
+ dispatch({ type: "_SetSuccess", loginType: "email" });
+ } else {
+ dispatch({
+ type: "_SetError",
+ errorMessage: loginResult.error.message,
+ });
+ }
+ } else {
+ dispatch({ type: "_SetError", errorMessage: result.error.message });
+ }
+ break;
+ }
+
+ case "AuthGoogleLoginSubmitted": {
+ // Web 端:实际由 GIS 按钮回调触发 `AuthWebGoogleLoginSuccess`;
+ // 这里仅在 mobile 平台下尝试 `signIn` 流程。
+ dispatch({ type: "_SetLoading", isLoading: true });
+ // 占位:Web 端请使用 GIS 按钮(见 google-sign-in-button)
+ dispatch({
+ type: "_SetError",
+ errorMessage: "Use the Google button to sign in on web",
+ });
+ break;
+ }
+
+ case "AuthFacebookLoginSubmitted": {
+ dispatch({ type: "_SetLoading", isLoading: true });
+ const loginResult = await loginWithFacebook();
+ if (Result.isErr(loginResult)) {
+ dispatch({ type: "_SetError", errorMessage: loginResult.error.message });
+ return;
+ }
+ const guestId =
+ (authStorage.getDeviceId?.() ?? undefined) || undefined;
+ const authResult = await authRepository.facebookLogin({
+ accessToken: loginResult.data.accessToken,
+ guestId,
+ });
+ if (Result.isOk(authResult)) {
+ // 异步上传 Facebook 头像(best-effort)
+ void (async () => {
+ const userData = await getFacebookUserData();
+ if (Result.isOk(userData) && userData.data.pictureUrl) {
+ // TODO: 调用 userStorage.setAvatarUrl;AuthRepository 没有暴露
+ }
+ })();
+ dispatch({ type: "_SetSuccess", loginType: "facebook" });
+ } else {
+ dispatch({ type: "_SetError", errorMessage: authResult.error.message });
+ }
+ break;
+ }
+
+ case "AuthAppleLoginSubmitted": {
+ dispatch({
+ type: "_SetError",
+ errorMessage: "Apple login not implemented",
+ });
+ break;
+ }
+
+ case "AuthWebGoogleLoginSuccess": {
+ dispatch({ type: "_SetLoading", isLoading: true });
+ const guestId =
+ (authStorage.getDeviceId?.() ?? undefined) || undefined;
+ const result = await authRepository.googleLogin({
+ idToken: event.idToken,
+ guestId,
+ });
+ if (Result.isOk(result)) {
+ dispatch({ type: "_SetSuccess", loginType: "google" });
+ } else {
+ dispatch({ type: "_SetError", errorMessage: result.error.message });
+ }
+ // 消费 result 避免未使用警告
+ void handleGoogleCredentialResponse;
+ break;
+ }
+
+ default:
+ // 内部事件 / 非业务事件不处理
+ break;
+ }
+ },
+};
diff --git a/src/contexts/auth/auth-types.ts b/src/contexts/auth/auth-types.ts
new file mode 100644
index 00000000..1fecffb1
--- /dev/null
+++ b/src/contexts/auth/auth-types.ts
@@ -0,0 +1,77 @@
+import type { AuthMode } from "@/models/auth/auth-mode";
+import type { AuthPanelMode } from "@/models/auth/auth-panel-mode";
+import type { LoginType } from "@/models/auth/login-type";
+
+/**
+ * 认证 State(对齐 Dart `lib/ui/auth/bloc/auth_state.dart` 的字段)
+ */
+export interface AuthState {
+ /** 当前面板模式(Facebook / Email) */
+ authPanelMode: AuthPanelMode;
+ /** 邮箱 */
+ email: string;
+ /** 密码 */
+ password: string;
+ /** 用户名 */
+ username: string;
+ /** 确认密码 */
+ confirmPassword: string;
+ /** 是否正在加载 */
+ isLoading: boolean;
+ /** 错误消息 */
+ errorMessage: string | null;
+ /** 是否成功 */
+ isSuccess: boolean;
+ /** 登录方式 */
+ loginType: LoginType;
+ /** 内部登录模式(login / register),UI 内部用 */
+ authMode: AuthMode;
+}
+
+/**
+ * 认证 Event(对齐 Dart `lib/ui/auth/bloc/auth_event.dart` 的事件类型)
+ *
+ * 私有内部事件以 `_` 前缀命名(reducer / side-effects 内部消费)。
+ */
+export type AuthEvent =
+ | { type: "AuthPanelModeChanged"; mode: AuthPanelMode }
+ | { type: "AuthModeChanged"; mode: AuthMode }
+ | { type: "AuthEmailLoginSubmitted"; email: string; password: string }
+ | {
+ type: "AuthEmailRegisterSubmitted";
+ email: string;
+ password: string;
+ username: string;
+ confirmPassword: string;
+ }
+ | { type: "AuthGoogleLoginSubmitted" }
+ | { type: "AuthFacebookLoginSubmitted" }
+ | { type: "AuthAppleLoginSubmitted" }
+ | { type: "AuthWebGoogleLoginSuccess"; idToken: string; email: string }
+ | { type: "AuthFormCleared" }
+ | { type: "AuthReset" }
+ // ===== 内部事件 =====
+ | { type: "_SetLoading"; isLoading: boolean }
+ | { type: "_SetError"; errorMessage: string | null }
+ | { type: "_SetSuccess"; loginType?: LoginType }
+ | {
+ type: "_SetForm";
+ email?: string;
+ password?: string;
+ username?: string;
+ confirmPassword?: string;
+ };
+
+/** 初始状态 */
+export const initialAuthState: AuthState = {
+ authPanelMode: "facebook",
+ authMode: "login",
+ email: "",
+ password: "",
+ username: "",
+ confirmPassword: "",
+ isLoading: false,
+ errorMessage: null,
+ isSuccess: false,
+ loginType: "none",
+};
diff --git a/src/contexts/chat/__tests__/chat-reducer.test.ts b/src/contexts/chat/__tests__/chat-reducer.test.ts
new file mode 100644
index 00000000..3044a3fd
--- /dev/null
+++ b/src/contexts/chat/__tests__/chat-reducer.test.ts
@@ -0,0 +1,81 @@
+import { describe, it, expect } from "vitest";
+
+import { chatReducer } from "@/contexts/chat/chat-reducer";
+import {
+ initialChatState,
+ GuestChatQuota,
+} from "@/contexts/chat/chat-types";
+
+describe("chatReducer", () => {
+ it("appends a new AI message on first sentence", () => {
+ const next = chatReducer(initialChatState, {
+ type: "ChatAISentenceReceived",
+ index: 0,
+ text: "Hello",
+ total: 1,
+ done: true,
+ });
+ expect(next.messages).toHaveLength(1);
+ expect(next.messages[0]?.isFromAI).toBe(true);
+ expect(next.messages[0]?.content).toBe("Hello");
+ expect(next.isReplyingAI).toBe(false);
+ });
+
+ it("concatenates subsequent sentences to the last AI message", () => {
+ const s1 = chatReducer(initialChatState, {
+ type: "ChatAISentenceReceived",
+ index: 0,
+ text: "First",
+ total: 2,
+ done: false,
+ });
+ const s2 = chatReducer(s1, {
+ type: "ChatAISentenceReceived",
+ index: 1,
+ text: "Second",
+ total: 2,
+ done: true,
+ });
+ expect(s2.messages).toHaveLength(1);
+ expect(s2.messages[0]?.content).toBe("First Second");
+ });
+
+ it("appends user message and sets isReplyingAI on send", () => {
+ const next = chatReducer(initialChatState, {
+ type: "ChatSendMessage",
+ content: "hi",
+ });
+ expect(next.messages).toHaveLength(1);
+ expect(next.messages[0]?.isFromAI).toBe(false);
+ expect(next.isReplyingAI).toBe(true);
+ });
+
+ it("ignores empty user messages", () => {
+ const next = chatReducer(initialChatState, {
+ type: "ChatSendMessage",
+ content: " ",
+ });
+ expect(next.messages).toHaveLength(0);
+ });
+
+ it("increments quotaExceededTrigger on ChatQuotaExceeded", () => {
+ const s1 = chatReducer(initialChatState, { type: "ChatQuotaExceeded" });
+ const s2 = chatReducer(s1, { type: "ChatQuotaExceeded" });
+ expect(s2.quotaExceededTrigger).toBe(2);
+ });
+
+ it("applies _StateUpdate for quota fields", () => {
+ const next = chatReducer(initialChatState, {
+ type: "_StateUpdate",
+ guestRemainingQuota: 0,
+ isGuest: true,
+ });
+ expect(next.guestRemainingQuota).toBe(0);
+ expect(next.isGuest).toBe(true);
+ });
+
+ it("exposes the warning threshold constant matching the spec", () => {
+ expect(GuestChatQuota.warningThreshold).toBe(5);
+ expect(GuestChatQuota.quotaExhausted).toBe(0);
+ });
+});
diff --git a/src/contexts/chat/chat-context.tsx b/src/contexts/chat/chat-context.tsx
new file mode 100644
index 00000000..a41aedf1
--- /dev/null
+++ b/src/contexts/chat/chat-context.tsx
@@ -0,0 +1,95 @@
+"use client";
+/**
+ * ChatContext:提供 State + Dispatch 的 React Context Provider。
+ *
+ * 业务事件(`ChatSendMessage`、`ChatInit` 等)通过 `sideEffects.run` 拦截
+ * → 异步完成后 dispatch 内部 `_StateUpdate` 等事件。
+ * UI 侧仅消费 `useChatState()`,调用 `useChatDispatch()` 派发业务事件。
+ */
+import {
+ type Dispatch,
+ type ReactNode,
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useReducer,
+ useRef,
+} from "react";
+
+import { chatReducer } from "./chat-reducer";
+import {
+ type ChatEvent,
+ type ChatState,
+ initialChatState,
+} from "./chat-types";
+import {
+ type ChatSideEffects,
+ createChatSideEffects as createDefaultSideEffects,
+} from "./chat-side-effects";
+
+const ChatStateCtx = createContext(null);
+const ChatDispatchCtx = createContext | null>(null);
+
+export interface ChatProviderProps {
+ children: ReactNode;
+ /** 可注入 side-effects(用于测试)。默认创建一个新的实例。 */
+ sideEffects?: ChatSideEffects;
+}
+
+export function ChatProvider({
+ children,
+ sideEffects,
+}: ChatProviderProps) {
+ // 始终在第一次渲染时创建 side-effects(保证 dispose 与组件生命周期一致)
+ const effectsRef = useRef(null);
+ if (effectsRef.current == null) {
+ effectsRef.current = sideEffects ?? createDefaultSideEffects();
+ }
+
+ const [state, rawDispatch] = useReducer(chatReducer, initialChatState);
+
+ // 同步 state 到 ref(在 effect 中更新,避免在 render 阶段修改 ref)
+ const stateRef = useRef(state);
+ useEffect(() => {
+ stateRef.current = state;
+ }, [state]);
+
+ const dispatch = useCallback>(
+ (event) => {
+ rawDispatch(event);
+ void effectsRef.current?.run(event, rawDispatch, () => stateRef.current);
+ },
+ [],
+ );
+
+ useEffect(() => {
+ const effects = effectsRef.current;
+ return () => {
+ effects?.dispose?.();
+ };
+ }, []);
+
+ const ctxValue = useMemo(() => state, [state]);
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export function useChatState(): ChatState {
+ const ctx = useContext(ChatStateCtx);
+ if (!ctx) throw new Error("useChatState must be used inside ");
+ return ctx;
+}
+
+export function useChatDispatch(): Dispatch {
+ const ctx = useContext(ChatDispatchCtx);
+ if (!ctx)
+ throw new Error("useChatDispatch must be used inside ");
+ return ctx;
+}
diff --git a/src/contexts/chat/chat-reducer.ts b/src/contexts/chat/chat-reducer.ts
new file mode 100644
index 00000000..ffc5a700
--- /dev/null
+++ b/src/contexts/chat/chat-reducer.ts
@@ -0,0 +1,111 @@
+import { formatDate } from "@/utils/date";
+
+import { type ChatEvent, type ChatState } from "./chat-types";
+
+/**
+ * 纯 reducer
+ */
+export function chatReducer(state: ChatState, event: ChatEvent): ChatState {
+ switch (event.type) {
+ case "_StateUpdate":
+ return {
+ ...state,
+ ...(event.messages !== undefined ? { messages: event.messages } : {}),
+ ...(event.isReplyingAI !== undefined
+ ? { isReplyingAI: event.isReplyingAI }
+ : {}),
+ ...(event.isGuest !== undefined ? { isGuest: event.isGuest } : {}),
+ ...(event.guestRemainingQuota !== undefined
+ ? { guestRemainingQuota: event.guestRemainingQuota }
+ : {}),
+ ...(event.guestTotalQuota !== undefined
+ ? { guestTotalQuota: event.guestTotalQuota }
+ : {}),
+ ...(event.isLoadingMore !== undefined
+ ? { isLoadingMore: event.isLoadingMore }
+ : {}),
+ ...(event.hasMore !== undefined ? { hasMore: event.hasMore } : {}),
+ ...(event.historyOffset !== undefined
+ ? { historyOffset: event.historyOffset }
+ : {}),
+ };
+
+ case "_SetQuotaExceededTrigger":
+ return { ...state, quotaExceededTrigger: event.trigger };
+
+ case "ChatQuotaExceeded":
+ return {
+ ...state,
+ quotaExceededTrigger: state.quotaExceededTrigger + 1,
+ };
+
+ case "ChatAISentenceReceived": {
+ const messages = [...state.messages];
+ if (event.index === 0) {
+ // 第一句:新增 AI 消息
+ messages.push({
+ content: event.text,
+ isFromAI: true,
+ date: formatDate(),
+ });
+ } else {
+ // 后续句:追加到最后一条 AI 消息
+ const last = messages[messages.length - 1];
+ if (last && last.isFromAI) {
+ messages[messages.length - 1] = {
+ ...last,
+ content: `${last.content} ${event.text}`,
+ };
+ }
+ }
+ return { ...state, messages, isReplyingAI: !event.done };
+ }
+
+ case "ChatWebSocketError": {
+ const messages = [
+ ...state.messages,
+ {
+ content: "Something went wrong. Try sending again?",
+ isFromAI: true,
+ date: formatDate(),
+ },
+ ];
+ return { ...state, messages, isReplyingAI: false };
+ }
+
+ case "ChatSendMessage": {
+ if (!event.content.trim()) return state;
+ const messages = [
+ ...state.messages,
+ {
+ content: event.content,
+ isFromAI: false,
+ date: formatDate(),
+ },
+ ];
+ return { ...state, messages, isReplyingAI: true };
+ }
+
+ case "ChatSendImage": {
+ const messages = [
+ ...state.messages,
+ {
+ content: "[Image]",
+ isFromAI: false,
+ date: formatDate(),
+ imageUrl: event.imageBase64,
+ },
+ ];
+ return { ...state, messages, isReplyingAI: true };
+ }
+
+ // 其它业务事件不直接处理(由 side-effects 拦截后通过 _StateUpdate 等内部事件驱动)
+ case "ChatInit":
+ case "ChatScreenVisible":
+ case "ChatScreenInvisible":
+ case "ChatLoadMoreHistory":
+ case "ChatWebSocketConnected":
+ case "ChatAuthStatusChanged":
+ return state;
+ }
+}
diff --git a/src/contexts/chat/chat-side-effects.ts b/src/contexts/chat/chat-side-effects.ts
new file mode 100644
index 00000000..192fd59a
--- /dev/null
+++ b/src/contexts/chat/chat-side-effects.ts
@@ -0,0 +1,245 @@
+import type { Dispatch } from "react";
+
+import { chatRepository } from "@/data/repositories/chat_repository";
+import { authStorage } from "@/data/storage/auth_storage";
+import { ChatStorage } from "@/data/storage/chat/chat_storage";
+import {
+ ChatWebSocket,
+ createChatWebSocket,
+} from "@/integrations/chat-websocket";
+import { MessageQueue } from "@/integrations/message-queue";
+import { Result } from "@/utils/result";
+
+import { GuestChatQuota, type ChatEvent, type ChatState } from "./chat-types";
+import type { UiMessage } from "@/models/chat/ui-message";
+
+/**
+ * 聊天 side-effects:拦截业务事件执行异步,dispatch 内部 `_StateUpdate` 事件。
+ */
+export type ChatSideEffects = {
+ run: (
+ event: ChatEvent,
+ dispatch: Dispatch,
+ getState: () => ChatState,
+ ) => void | Promise;
+ /** 组件卸载前清理 WebSocket / 队列。 */
+ dispose?: () => void;
+};
+
+const PAGE_SIZE = 20;
+
+/**
+ * 默认 side-effects 单例:负责 ChatInit / 发送 / 加载历史 / WS 生命周期 / 配额扣减。
+ */
+export function createChatSideEffects(): ChatSideEffects {
+ const chatStorage = ChatStorage.getInstance();
+ let ws: ChatWebSocket | null = null;
+ let queue: MessageQueue | null = null;
+ let alive = true;
+
+ function attachWebSocket(dispatch: Dispatch) {
+ if (ws) return;
+ const token = authStorage.getAuthToken();
+ if (!token) return;
+ ws = createChatWebSocket(token);
+ queue = new MessageQueue();
+ ws.onConnected = (userId) => {
+ dispatch({ type: "ChatWebSocketConnected", userId });
+ };
+ ws.onSentence = (payload) => {
+ dispatch({
+ type: "ChatAISentenceReceived",
+ index: payload.index,
+ text: payload.text,
+ total: payload.total,
+ done: payload.done,
+ });
+ };
+ ws.onError = (errorMessage) => {
+ dispatch({ type: "ChatWebSocketError", errorMessage });
+ };
+ ws.connect();
+ // 注册队列消费器:仅当 WS 处于连接状态时才出队
+ queue.setConsumer((msg) => {
+ if (ws?.isConnected) {
+ ws.sendMessage(msg);
+ return true;
+ }
+ return false;
+ });
+ }
+
+ function detachWebSocket() {
+ ws?.disconnect();
+ ws = null;
+ queue?.clear();
+ queue = null;
+ }
+
+ return {
+ run: async (event, dispatch, getState) => {
+ switch (event.type) {
+ case "ChatInit": {
+ // 读取本地历史
+ const localResult = await chatRepository.getLocalMessages();
+ if (alive && Result.isOk(localResult) && localResult.data.length > 0) {
+ const uiMessages: UiMessage[] = localResult.data.map((m) => ({
+ content: m.content,
+ isFromAI: m.role === "assistant",
+ date: m.createdAt,
+ }));
+ dispatch({
+ type: "_StateUpdate",
+ messages: uiMessages,
+ });
+ }
+
+ // 加载游客配额
+ const dailyResult = await chatStorage.getGuestDailyChatQuota();
+ const totalResult = await chatStorage.getGuestTotalQuota();
+ const isGuest = !authStorage.hasLoginToken();
+ dispatch({
+ type: "_StateUpdate",
+ isGuest,
+ guestRemainingQuota:
+ Result.isOk(dailyResult) && dailyResult.data
+ ? dailyResult.data.remaining
+ : 40,
+ guestTotalQuota:
+ Result.isOk(totalResult) && totalResult.data !== null
+ ? totalResult.data
+ : 50,
+ });
+
+ // 已登录则建立 WS
+ if (!isGuest) {
+ attachWebSocket(dispatch);
+ }
+ break;
+ }
+
+ case "ChatScreenVisible": {
+ // 切回前台时重连 WS(若已登录)
+ if (!ws && authStorage.hasLoginToken()) {
+ attachWebSocket(dispatch);
+ }
+ break;
+ }
+
+ case "ChatScreenInvisible": {
+ // 切到后台:暂不断开(与 Dart 行为一致);保持 WS 用于接收推送
+ break;
+ }
+
+ case "ChatSendMessage": {
+ if (!event.content.trim()) return;
+ const state = getState();
+
+ // 游客配额扣减
+ if (state.isGuest) {
+ if (state.guestRemainingQuota <= GuestChatQuota.quotaExhausted) {
+ dispatch({ type: "ChatQuotaExceeded" });
+ return;
+ }
+ const newRemaining = state.guestRemainingQuota - 1;
+ dispatch({
+ type: "_StateUpdate",
+ guestRemainingQuota: newRemaining,
+ });
+ // 异步持久化(best-effort)
+ void chatStorage.setGuestDailyChatQuota(
+ newRemaining,
+ new Date().toISOString().slice(0, 10),
+ );
+ }
+
+ // 游客走 HTTP;登录用户走 WS
+ if (state.isGuest) {
+ const result = await chatRepository.sendMessage(event.content);
+ if (!alive) return;
+ if (Result.isOk(result)) {
+ // 拉取最新本地历史
+ const local = await chatRepository.getLocalMessages();
+ if (Result.isOk(local) && alive) {
+ const uiMessages: UiMessage[] = local.data.map((m) => ({
+ content: m.content,
+ isFromAI: m.role === "assistant",
+ date: m.createdAt,
+ }));
+ dispatch({
+ type: "_StateUpdate",
+ messages: uiMessages,
+ isReplyingAI: false,
+ });
+ }
+ } else {
+ dispatch({
+ type: "ChatWebSocketError",
+ errorMessage: result.error.message,
+ });
+ }
+ return;
+ }
+
+ // 登录用户:直接发到 WS(用 queue 节流)
+ queue?.enqueue(event.content);
+ break;
+ }
+
+ case "ChatLoadMoreHistory": {
+ const state = getState();
+ if (state.isLoadingMore || !state.hasMore) return;
+ dispatch({ type: "_StateUpdate", isLoadingMore: true });
+ const offset = state.historyOffset;
+ const result = await chatRepository.getHistory(PAGE_SIZE, offset);
+ if (!alive) return;
+ if (Result.isOk(result)) {
+ const page = result.data.messages.map((m) => ({
+ content: m.content,
+ isFromAI: m.role === "assistant",
+ date: m.createdAt,
+ }));
+ dispatch({
+ type: "_StateUpdate",
+ isLoadingMore: false,
+ hasMore: page.length >= PAGE_SIZE,
+ historyOffset: offset + page.length,
+ messages: [...page, ...state.messages],
+ });
+ } else {
+ dispatch({ type: "_StateUpdate", isLoadingMore: false });
+ }
+ break;
+ }
+
+ case "ChatAuthStatusChanged": {
+ // 登录/登出切换:登出清理 WS;登录则建立
+ const isGuest = !authStorage.hasLoginToken();
+ dispatch({ type: "_StateUpdate", isGuest });
+ if (isGuest) {
+ detachWebSocket();
+ } else {
+ attachWebSocket(dispatch);
+ }
+ break;
+ }
+
+ case "ChatAISentenceReceived":
+ case "ChatWebSocketConnected":
+ case "ChatWebSocketError":
+ case "ChatQuotaExceeded":
+ case "_StateUpdate":
+ case "_SetQuotaExceededTrigger":
+ default:
+ // 内部事件 / reducer 直处理;这里不做事
+ break;
+ }
+ },
+ dispose: () => {
+ alive = false;
+ detachWebSocket();
+ },
+ };
+}
+
+export const chatSideEffects: ChatSideEffects = createChatSideEffects();
diff --git a/src/contexts/chat/chat-types.ts b/src/contexts/chat/chat-types.ts
new file mode 100644
index 00000000..d5f94bf3
--- /dev/null
+++ b/src/contexts/chat/chat-types.ts
@@ -0,0 +1,81 @@
+import type { UiMessage } from "@/models/chat/ui-message";
+
+/**
+ * 聊天 State(对齐 Dart `lib/ui/chat/bloc/chat_state.dart`)
+ */
+export interface ChatState {
+ /** 消息列表 */
+ messages: UiMessage[];
+ /** AI 是否正在回复 */
+ isReplyingAI: boolean;
+ /** 是否为游客模式 */
+ isGuest: boolean;
+ /** 游客每日剩余配额 */
+ guestRemainingQuota: number;
+ /** 游客总配额剩余 */
+ guestTotalQuota: number;
+ /** 配额耗尽触发器(每次触发递增,UI 据此显示弹窗) */
+ quotaExceededTrigger: number;
+ /** 是否正在加载更多历史消息 */
+ isLoadingMore: boolean;
+ /** 是否还有更多历史消息可加载 */
+ hasMore: boolean;
+ /** 下次加载更多时使用的 offset */
+ historyOffset: number;
+}
+
+/**
+ * 聊天 Event(对齐 Dart `lib/ui/chat/bloc/chat_event.dart`)
+ *
+ * 私有内部事件以 `_` 前缀命名。
+ */
+export type ChatEvent =
+ | { type: "ChatInit" }
+ | { type: "ChatScreenVisible" }
+ | { type: "ChatScreenInvisible" }
+ | { type: "ChatSendMessage"; content: string }
+ | { type: "ChatSendImage"; imageBase64: string }
+ | { type: "ChatLoadMoreHistory" }
+ | {
+ type: "ChatAISentenceReceived";
+ index: number;
+ text: string;
+ total: number;
+ done: boolean;
+ }
+ | { type: "ChatWebSocketError"; errorMessage: string }
+ | { type: "ChatWebSocketConnected"; userId: string }
+ | { type: "ChatQuotaExceeded" }
+ | { type: "ChatAuthStatusChanged" }
+ // ===== 内部事件 =====
+ | {
+ type: "_StateUpdate";
+ messages?: UiMessage[];
+ isReplyingAI?: boolean;
+ isGuest?: boolean;
+ guestRemainingQuota?: number;
+ guestTotalQuota?: number;
+ isLoadingMore?: boolean;
+ hasMore?: boolean;
+ historyOffset?: number;
+ }
+ | { type: "_SetQuotaExceededTrigger"; trigger: number };
+
+/** 初始状态 */
+export const initialChatState: ChatState = {
+ messages: [],
+ isReplyingAI: false,
+ isGuest: true,
+ guestRemainingQuota: 40,
+ guestTotalQuota: 50,
+ quotaExceededTrigger: 0,
+ isLoadingMore: false,
+ hasMore: true,
+ historyOffset: 0,
+};
+
+/** 游客配额阈值(与 Dart `GuestChatQuota` 保持一致) */
+export const GuestChatQuota = {
+ warningThreshold: 5,
+ quotaExhausted: 0,
+} as const;
diff --git a/src/contexts/sidebar/sidebar-context.tsx b/src/contexts/sidebar/sidebar-context.tsx
new file mode 100644
index 00000000..461cf09b
--- /dev/null
+++ b/src/contexts/sidebar/sidebar-context.tsx
@@ -0,0 +1,50 @@
+"use client";
+/**
+ * SidebarContext:提供 State + Dispatch。
+ */
+import {
+ type Dispatch,
+ type ReactNode,
+ createContext,
+ useContext,
+ useMemo,
+ useReducer,
+} from "react";
+
+import { sidebarReducer } from "./sidebar-reducer";
+import {
+ type SidebarEvent,
+ type SidebarState,
+ initialSidebarState,
+} from "./sidebar-types";
+
+const SidebarStateCtx = createContext(null);
+const SidebarDispatchCtx = createContext | null>(null);
+
+export function SidebarProvider({ children }: { children: ReactNode }) {
+ const [state, dispatch] = useReducer(sidebarReducer, initialSidebarState);
+ const ctxValue = useMemo(() => state, [state]);
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export function useSidebarState(): SidebarState {
+ const ctx = useContext(SidebarStateCtx);
+ if (!ctx)
+ throw new Error("useSidebarState must be used inside ");
+ return ctx;
+}
+
+export function useSidebarDispatch(): Dispatch {
+ const ctx = useContext(SidebarDispatchCtx);
+ if (!ctx)
+ throw new Error(
+ "useSidebarDispatch must be used inside ",
+ );
+ return ctx;
+}
diff --git a/src/contexts/sidebar/sidebar-reducer.ts b/src/contexts/sidebar/sidebar-reducer.ts
new file mode 100644
index 00000000..7fcc5269
--- /dev/null
+++ b/src/contexts/sidebar/sidebar-reducer.ts
@@ -0,0 +1,35 @@
+/**
+ * 纯 sidebar reducer
+ */
+import {
+ type SidebarEvent,
+ type SidebarState,
+ BottomNavItem,
+} from "./sidebar-types";
+
+export function sidebarReducer(
+ state: SidebarState,
+ event: SidebarEvent,
+): SidebarState {
+ switch (event.type) {
+ case "SidebarShowPage":
+ return {
+ ...state,
+ currentPage: event.page,
+ selectedBottomNavItem: event.bottomNavItem ?? BottomNavItem.None,
+ };
+
+ case "SidebarClearBottomNav":
+ return { ...state, selectedBottomNavItem: BottomNavItem.None };
+
+ case "SidebarUpdateCharacter":
+ return {
+ ...state,
+ characterName: event.name,
+ characterProgress: event.progress,
+ };
+
+ default:
+ return state;
+ }
+}
diff --git a/src/contexts/sidebar/sidebar-types.ts b/src/contexts/sidebar/sidebar-types.ts
new file mode 100644
index 00000000..76e828f8
--- /dev/null
+++ b/src/contexts/sidebar/sidebar-types.ts
@@ -0,0 +1,52 @@
+/**
+ * Sidebar 页面/底部导航枚举
+ */
+export const SidebarPage = {
+ DefaultView: "defaultView",
+ Profile: "profile",
+ Topics: "topics",
+ Gifts: "gifts",
+ Activities: "activities",
+} as const;
+
+export type SidebarPage =
+ (typeof SidebarPage)[keyof typeof SidebarPage];
+
+export const BottomNavItem = {
+ None: "none",
+ Topics: "topics",
+ Gifts: "gifts",
+ Activities: "activities",
+} as const;
+
+export type BottomNavItem =
+ (typeof BottomNavItem)[keyof typeof BottomNavItem];
+
+/**
+ * Sidebar State(对齐 Dart `lib/ui/sidebar/bloc/sidebar_state.dart`)
+ */
+export interface SidebarState {
+ currentPage: SidebarPage;
+ selectedBottomNavItem: BottomNavItem;
+ characterName: string;
+ characterProgress: number;
+}
+
+export const initialSidebarState: SidebarState = {
+ currentPage: SidebarPage.DefaultView,
+ selectedBottomNavItem: BottomNavItem.None,
+ characterName: "Luna",
+ characterProgress: 0,
+};
+
+/**
+ * Sidebar Event
+ */
+export type SidebarEvent =
+ | {
+ type: "SidebarShowPage";
+ page: SidebarPage;
+ bottomNavItem?: BottomNavItem;
+ }
+ | { type: "SidebarClearBottomNav" }
+ | { type: "SidebarUpdateCharacter"; name: string; progress: number };
diff --git a/src/contexts/user/user-context.tsx b/src/contexts/user/user-context.tsx
new file mode 100644
index 00000000..8e57e5ca
--- /dev/null
+++ b/src/contexts/user/user-context.tsx
@@ -0,0 +1,64 @@
+"use client";
+/**
+ * UserContext:提供 State + Dispatch 的 React Context Provider。
+ */
+import {
+ type Dispatch,
+ type ReactNode,
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useReducer,
+} from "react";
+
+import { userReducer } from "./user-reducer";
+import { type UserEvent, type UserState, initialUserState } from "./user-types";
+import {
+ type UserSideEffects,
+ userSideEffects as defaultSideEffects,
+} from "./user-side-effects";
+
+const UserStateCtx = createContext(null);
+const UserDispatchCtx = createContext | null>(null);
+
+export interface UserProviderProps {
+ children: ReactNode;
+ sideEffects?: UserSideEffects;
+}
+
+export function UserProvider({
+ children,
+ sideEffects = defaultSideEffects,
+}: UserProviderProps) {
+ const [state, rawDispatch] = useReducer(userReducer, initialUserState);
+ const dispatch = useCallback>(
+ (event) => {
+ rawDispatch(event);
+ void sideEffects.run(event, rawDispatch);
+ },
+ [sideEffects],
+ );
+
+ const ctxValue = useMemo(() => state, [state]);
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export function useUserState(): UserState {
+ const ctx = useContext(UserStateCtx);
+ if (!ctx) throw new Error("useUserState must be used inside ");
+ return ctx;
+}
+
+export function useUserDispatch(): Dispatch {
+ const ctx = useContext(UserDispatchCtx);
+ if (!ctx)
+ throw new Error("useUserDispatch must be used inside ");
+ return ctx;
+}
diff --git a/src/contexts/user/user-reducer.ts b/src/contexts/user/user-reducer.ts
new file mode 100644
index 00000000..9203e07c
--- /dev/null
+++ b/src/contexts/user/user-reducer.ts
@@ -0,0 +1,50 @@
+/**
+ * 纯 user reducer
+ */
+import { type UserEvent, type UserState } from "./user-types";
+
+export function userReducer(state: UserState, event: UserEvent): UserState {
+ switch (event.type) {
+ case "_SetLoading":
+ return { ...state, isLoading: event.isLoading };
+ case "_SetUser":
+ return { ...state, currentUser: event.user };
+ case "_SetAvatarUrl":
+ return { ...state, avatarUrl: event.url };
+ case "_SetPronouns":
+ return { ...state, pronouns: event.pronouns };
+ case "_SetCoinBalance":
+ return { ...state, coinBalance: event.coinBalance };
+
+ case "UserUpdate":
+ return { ...state, currentUser: event.user };
+
+ case "UserUpdateUsername": {
+ if (!state.currentUser) return state;
+ return {
+ ...state,
+ currentUser: { ...state.currentUser, username: event.username },
+ };
+ }
+
+ case "UserUpdatePronouns":
+ return { ...state, pronouns: event.pronouns };
+
+ case "UserLogout":
+ return {
+ ...state,
+ currentUser: null,
+ avatarUrl: null,
+ };
+
+ case "UserInit":
+ case "UserFetch":
+ case "UserDeleteChatHistory":
+ case "UserDeleteAccount":
+ // 由 side-effects 处理
+ return state;
+
+ default:
+ return state;
+ }
+}
diff --git a/src/contexts/user/user-side-effects.ts b/src/contexts/user/user-side-effects.ts
new file mode 100644
index 00000000..67ad79fc
--- /dev/null
+++ b/src/contexts/user/user-side-effects.ts
@@ -0,0 +1,127 @@
+import type { Dispatch } from "react";
+
+import { authRepository } from "@/data/repositories/auth_repository";
+import { userRepository } from "@/data/repositories/user_repository";
+import { authStorage } from "@/data/storage/auth_storage";
+import { UserStorage } from "@/data/storage/user/user_storage";
+import { Result } from "@/utils/result";
+import type { UserView } from "@/models/user/user";
+
+import { type UserEvent } from "./user-types";
+
+/**
+ * 用户 side-effects:拦截业务事件执行异步,dispatch 内部 `_Set*` 事件。
+ */
+export type UserSideEffects = {
+ run: (event: UserEvent, dispatch: Dispatch) => void | Promise;
+};
+
+const userStorage = UserStorage.getInstance();
+
+function toView(user: {
+ id: string;
+ username: string;
+ email?: string;
+ avatarUrl?: string;
+ intimacy?: number;
+ dolBalance?: number;
+ relationshipStage?: string;
+ currentMood?: string;
+ isGuest?: boolean;
+}): UserView {
+ return {
+ id: user.id,
+ username: user.username,
+ email: user.email ?? "",
+ avatarUrl: user.avatarUrl ?? "",
+ intimacy: user.intimacy ?? 0,
+ dolBalance: user.dolBalance ?? 0,
+ relationshipStage: user.relationshipStage ?? "",
+ currentMood: user.currentMood ?? "",
+ isGuest: user.isGuest ?? false,
+ };
+}
+
+export const userSideEffects: UserSideEffects = {
+ run: async (event, dispatch) => {
+ switch (event.type) {
+ case "UserInit": {
+ const userResult = await userStorage.getUser();
+ if (Result.isOk(userResult) && userResult.data) {
+ dispatch({ type: "_SetUser", user: toView(userResult.data) });
+ }
+ const avatarResult = await userStorage.getAvatarUrl();
+ if (
+ Result.isOk(avatarResult) &&
+ avatarResult.data &&
+ avatarResult.data.length > 0
+ ) {
+ dispatch({ type: "_SetAvatarUrl", url: avatarResult.data });
+ }
+ break;
+ }
+
+ case "UserFetch": {
+ if (!authStorage.hasLoginToken()) return;
+ dispatch({ type: "_SetLoading", isLoading: true });
+ const result = await userRepository.getCurrentUser();
+ if (Result.isOk(result)) {
+ await userStorage.setUser(result.data.toJson());
+ dispatch({ type: "_SetUser", user: toView(result.data.toJson()) });
+ }
+ dispatch({ type: "_SetLoading", isLoading: false });
+ break;
+ }
+
+ case "UserUpdate": {
+ await userStorage.setUser({
+ id: event.user.id,
+ username: event.user.username,
+ email: event.user.email,
+ avatarUrl: event.user.avatarUrl,
+ intimacy: event.user.intimacy,
+ dolBalance: event.user.dolBalance,
+ relationshipStage: event.user.relationshipStage,
+ currentMood: event.user.currentMood,
+ isGuest: event.user.isGuest,
+ // 其余字段用 UserStorage.setUser 接受 UserData 即可
+ platform: "web",
+ personalityTraits: {} as never,
+ preferredLanguage: "",
+ createdAt: "",
+ lastMessageAt: "",
+ loginProvider: "email",
+ } as never);
+ break;
+ }
+
+ case "UserLogout": {
+ await authRepository.logout();
+ authStorage.clearAuthData();
+ await userStorage.clearUserData();
+ dispatch({ type: "_SetUser", user: null });
+ dispatch({ type: "_SetAvatarUrl", url: null });
+ break;
+ }
+
+ case "UserDeleteChatHistory":
+ case "UserDeleteAccount":
+ // TODO: 接入真实 API
+ break;
+
+ case "UserUpdateUsername":
+ case "UserUpdatePronouns":
+ // 已由 reducer 处理
+ break;
+
+ case "_SetLoading":
+ case "_SetUser":
+ case "_SetAvatarUrl":
+ case "_SetPronouns":
+ case "_SetCoinBalance":
+ default:
+ // 内部事件
+ break;
+ }
+ },
+};
diff --git a/src/contexts/user/user-types.ts b/src/contexts/user/user-types.ts
new file mode 100644
index 00000000..30f01790
--- /dev/null
+++ b/src/contexts/user/user-types.ts
@@ -0,0 +1,44 @@
+/**
+ * 用户 State(对齐 Dart `lib/ui/user/bloc/user_state.dart`)
+ */
+import type { UserView } from "@/models/user/user";
+
+export interface UserState {
+ /** 当前用户信息(null = 未登录) */
+ currentUser: UserView | null;
+ /** 用户代词 */
+ pronouns: string;
+ /** 金币余额 */
+ coinBalance: number;
+ /** 是否正在加载 */
+ isLoading: boolean;
+ /** 用户头像 URL(独立字段;可能与 currentUser.avatarUrl 不同源) */
+ avatarUrl: string | null;
+}
+
+export const initialUserState: UserState = {
+ currentUser: null,
+ pronouns: "He",
+ coinBalance: 45,
+ isLoading: false,
+ avatarUrl: null,
+};
+
+/**
+ * 用户 Event
+ */
+export type UserEvent =
+ | { type: "UserInit" }
+ | { type: "UserFetch" }
+ | { type: "UserUpdate"; user: UserView }
+ | { type: "UserUpdateUsername"; username: string }
+ | { type: "UserUpdatePronouns"; pronouns: string }
+ | { type: "UserLogout" }
+ | { type: "UserDeleteChatHistory" }
+ | { type: "UserDeleteAccount" }
+ // ===== 内部事件 =====
+ | { type: "_SetLoading"; isLoading: boolean }
+ | { type: "_SetUser"; user: UserView | null }
+ | { type: "_SetAvatarUrl"; url: string | null }
+ | { type: "_SetPronouns"; pronouns: string }
+ | { type: "_SetCoinBalance"; coinBalance: number };
diff --git a/src/data/storage/index.ts b/src/data/storage/index.ts
index f15fa816..1a333dc5 100644
--- a/src/data/storage/index.ts
+++ b/src/data/storage/index.ts
@@ -2,11 +2,13 @@
* @file Automatically generated by barrelsby.
*/
-export * from "./auth_storage";
export * from "./local_storage";
export * from "@/utils/result";
export * from "./storage_keys";
export * from "./app/app_storage";
+// NOTE: top-level `auth_storage.ts` is intentionally not re-exported here —
+// it conflicts with `auth/auth_storage.ts` (both define `AuthStorage`).
+// Import directly via `@/data/storage/auth/auth_storage` instead.
export * from "./auth/auth_storage";
export * from "./auth/iauth_storage";
export * from "./chat/chat_storage";
diff --git a/src/design/breakpoints.css b/src/design/breakpoints.css
new file mode 100644
index 00000000..2f87ad05
--- /dev/null
+++ b/src/design/breakpoints.css
@@ -0,0 +1,12 @@
+/* ============================================================
+ * 设计系统 - 响应式断点
+ * 原始 Dart: lib/ui/core/responsive.dart
+ * ============================================================ */
+
+:root {
+ --bp-mobile-max: 599.98px;
+ --bp-tablet-min: 600px;
+ --bp-tablet-max: 1023.98px;
+ --bp-desktop-min: 1024px;
+ --shell-max-width: 500px;
+}
diff --git a/src/design/colors.css b/src/design/colors.css
index f1620333..34a71adc 100644
--- a/src/design/colors.css
+++ b/src/design/colors.css
@@ -55,4 +55,28 @@
/* ==================== 页面背景(body 用) ==================== */
--color-page-background: #ffffff;
+
+ /* ==================== 扩展令牌 (迁移自 Flutter UI) ==================== */
+ /* 侧边栏/Profile 深色背景 */
+ --color-sidebar-background: #0d0b14;
+ --color-dark-background: #0d0b14;
+ --color-dark-gray: #2a2a2a;
+ --color-bubble-background: #1a1625;
+ --color-bubble-transparent: rgba(255, 255, 255, 0);
+
+ /* 聊天输入框 */
+ --color-chat-input-border: rgba(255, 255, 255, 0.1);
+ --color-input-box-shadow: rgba(0, 0, 0, 0.1);
+ --color-input-placeholder: #757575;
+ --color-pwa-button-text: #ffffff;
+
+ /* Facebook 蓝 */
+ --color-facebook-blue: #1877f2;
+
+ /* Dialog 通用 */
+ --color-dialog-background: #1f1a2e;
+ --color-dialog-border: rgba(255, 255, 255, 0.1);
+
+ /* 半透明 / 模糊 */
+ --color-blur-background: rgba(13, 11, 20, 0.85);
}
diff --git a/src/design/dimensions.css b/src/design/dimensions.css
new file mode 100644
index 00000000..90590fce
--- /dev/null
+++ b/src/design/dimensions.css
@@ -0,0 +1,35 @@
+/* ============================================================
+ * 设计系统 - 固定尺寸变量
+ * 原始 Dart: lib/design/app_dimensions.dart
+ * 集中管理按钮/对话框/气泡等固定尺寸
+ * ============================================================ */
+
+:root {
+ /* 按钮 */
+ --button-height: 48px;
+ --button-height-lg: 56px;
+
+ /* Splash Facebook 登录按钮 */
+ --splash-facebook-button-height: 56px;
+ --splash-facebook-button-radius: 28px;
+
+ /* PWA 按钮 */
+ --pwa-button-height: 44px;
+
+ /* 对话框 */
+ --dialog-max-width: 360px;
+ --pwa-install-dialog-max-width: 360px;
+ --pwa-install-container-size: 64px;
+
+ /* 列表项 */
+ --item-height: 56px;
+
+ /* 消息气泡 */
+ --bubble-min-width: 60px;
+ --bubble-max-width: 280px;
+ --bubble-min-height: 36px;
+
+ /* 聊天媒体 */
+ --chat-media-max-width: 240px;
+ --chat-media-max-height: 240px;
+}
diff --git a/src/design/spacing.css b/src/design/spacing.css
index a80baa59..1d216f41 100644
--- a/src/design/spacing.css
+++ b/src/design/spacing.css
@@ -12,4 +12,10 @@
--spacing-xl: 20px;
--spacing-xxl: 24px;
--spacing-xxxl: 32px;
+
+ /* 扩展间距(迁移自 Flutter widgets) */
+ --spacing-26: 26px;
+ --spacing-30: 30px;
+ --spacing-40: 40px;
+ --spacing-46: 46px;
}
diff --git a/src/design/typography.css b/src/design/typography.css
index 77242e89..ca57768f 100644
--- a/src/design/typography.css
+++ b/src/design/typography.css
@@ -22,4 +22,8 @@
--font-size-title: 24px;
--font-size-title-large: 28px;
--font-size-title-x-large: 32px;
+
+ /* 扩展字号(迁移自 Flutter widgets) */
+ --font-size-22: 22px;
+ --font-size-26: 26px;
}
diff --git a/src/hooks/use-breakpoint.ts b/src/hooks/use-breakpoint.ts
new file mode 100644
index 00000000..ef7b7387
--- /dev/null
+++ b/src/hooks/use-breakpoint.ts
@@ -0,0 +1,45 @@
+"use client";
+/**
+ * 响应式断点 hook
+ *
+ * 原始 Dart: lib/ui/core/responsive.dart 的 `Responsive` widget + `isDesktop/isTablet/isMobile` 静态方法。
+ *
+ * 实现要点(与 `src/lib/auth/use-auth-gate.tsx` 同款 `useSyncExternalStore` 模式):
+ * - `subscribe` 监听 3 个 media query 的 change 事件,调用方重读 snapshot。
+ * - `getServerSnapshot` 返回 `"mobile"`(最保守的默认)以避免 SSR/水合首帧不一致。
+ * - `getSnapshot` 在客户端按 desktop → tablet → mobile 优先级匹配。
+ *
+ * 用法:
+ * const size = useBreakpoint();
+ * if (size === "mobile") return ;
+ */
+import { useSyncExternalStore } from "react";
+
+import type { DeviceSize } from "@/lib/breakpoints";
+
+const QUERIES = {
+ mobile: "(max-width: 599.98px)",
+ tablet: "(min-width: 600px) and (max-width: 1023.98px)",
+ desktop: "(min-width: 1024px)",
+} as const satisfies Record;
+
+const subscribe = (cb: () => void): (() => void) => {
+ if (typeof window === "undefined") return () => {};
+ const mqls = Object.values(QUERIES).map((q) => window.matchMedia(q));
+ const handler = () => cb();
+ mqls.forEach((mql) => mql.addEventListener("change", handler));
+ return () => mqls.forEach((mql) => mql.removeEventListener("change", handler));
+};
+
+const getSnapshot = (): DeviceSize => {
+ if (typeof window === "undefined") return "mobile";
+ if (window.matchMedia(QUERIES.desktop).matches) return "desktop";
+ if (window.matchMedia(QUERIES.tablet).matches) return "tablet";
+ return "mobile";
+};
+
+const getServerSnapshot = (): DeviceSize => "mobile";
+
+export function useBreakpoint(): DeviceSize {
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+}
diff --git a/src/hooks/use-pull-to-refresh.ts b/src/hooks/use-pull-to-refresh.ts
new file mode 100644
index 00000000..e85521ae
--- /dev/null
+++ b/src/hooks/use-pull-to-refresh.ts
@@ -0,0 +1,69 @@
+"use client";
+/**
+ * 下拉刷新 hook(占位实现)
+ *
+ * 原始 Dart: `easy_refresh` 包的 `EasyRefresh.builder`(chat_area.dart 用来加载更多历史消息)。
+ *
+ * 当前实现:最小可用版本 —— 监听顶部 overscroll 触发 onRefresh。
+ * 后续可替换为 react-use / react-easy-refresh。
+ */
+import { useEffect, useRef, useState } from "react";
+
+export function usePullToRefresh(
+ onRefresh: () => Promise | void,
+ options: { threshold?: number; disabled?: boolean } = {},
+): {
+ isRefreshing: boolean;
+ pullDistance: number;
+ containerRef: React.RefObject;
+} {
+ const { threshold = 80, disabled = false } = options;
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [pullDistance, setPullDistance] = useState(0);
+ const startY = useRef(null);
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ if (disabled || typeof window === "undefined") return;
+ const el = containerRef.current;
+ if (!el) return;
+
+ const onTouchStart = (e: TouchEvent) => {
+ if (el.scrollTop > 0) return;
+ startY.current = e.touches[0]?.clientY ?? null;
+ };
+ const onTouchMove = (e: TouchEvent) => {
+ if (startY.current == null) return;
+ const y = e.touches[0]?.clientY ?? startY.current;
+ const delta = y - startY.current;
+ if (delta > 0 && el.scrollTop <= 0) {
+ setPullDistance(Math.min(delta, threshold * 1.5));
+ }
+ };
+ const onTouchEnd = async () => {
+ if (startY.current == null) return;
+ if (pullDistance >= threshold && !isRefreshing) {
+ setIsRefreshing(true);
+ try {
+ await onRefresh();
+ } finally {
+ setIsRefreshing(false);
+ setPullDistance(0);
+ }
+ } else {
+ setPullDistance(0);
+ }
+ startY.current = null;
+ };
+ el.addEventListener("touchstart", onTouchStart, { passive: true });
+ el.addEventListener("touchmove", onTouchMove, { passive: true });
+ el.addEventListener("touchend", onTouchEnd);
+ return () => {
+ el.removeEventListener("touchstart", onTouchStart);
+ el.removeEventListener("touchmove", onTouchMove);
+ el.removeEventListener("touchend", onTouchEnd);
+ };
+ }, [threshold, disabled, isRefreshing, onRefresh, pullDistance]);
+
+ return { isRefreshing, pullDistance, containerRef };
+}
diff --git a/src/hooks/use-pwa-install.ts b/src/hooks/use-pwa-install.ts
new file mode 100644
index 00000000..e2a7f032
--- /dev/null
+++ b/src/hooks/use-pwa-install.ts
@@ -0,0 +1,47 @@
+"use client";
+/**
+ * PWA Install 提示 hook
+ *
+ * 原始 Dart: `pwa_install` 包的 BeforeInstallPrompt 事件封装
+ * (`lib/ui/chat/widgets/pwa_install_overlay.dart` + `pwa_install_dialog.dart`)。
+ *
+ * 浏览器 API:`beforeinstallprompt` 事件 → 用户触发 → 浏览器展示原生安装提示。
+ *
+ * 用法:
+ * const { canInstall, promptInstall } = usePwaInstall();
+ * if (canInstall) ;
+ */
+import { useEffect, useState } from "react";
+
+interface BeforeInstallPromptEvent extends Event {
+ prompt(): Promise;
+ userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
+}
+
+export function usePwaInstall(): {
+ canInstall: boolean;
+ promptInstall: () => Promise<"accepted" | "dismissed" | "unavailable">;
+} {
+ const [deferred, setDeferred] = useState(null);
+
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+ const handler = (e: Event) => {
+ e.preventDefault();
+ setDeferred(e as BeforeInstallPromptEvent);
+ };
+ window.addEventListener("beforeinstallprompt", handler);
+ return () => window.removeEventListener("beforeinstallprompt", handler);
+ }, []);
+
+ return {
+ canInstall: deferred !== null,
+ promptInstall: async () => {
+ if (!deferred) return "unavailable";
+ await deferred.prompt();
+ const choice = await deferred.userChoice;
+ setDeferred(null);
+ return choice.outcome;
+ },
+ };
+}
diff --git a/src/hooks/use-visibility.ts b/src/hooks/use-visibility.ts
new file mode 100644
index 00000000..29d2b3cb
--- /dev/null
+++ b/src/hooks/use-visibility.ts
@@ -0,0 +1,42 @@
+"use client";
+/**
+ * 元素全可见性 hook
+ *
+ * 原始 Dart: `visibility_detector` 包的 `VisibilityDetector`(chat_screen 用法:
+ * onVisibilityChanged: (info) { if (info.visibleFraction == 1.0) ... })。
+ *
+ * 用 IntersectionObserver,threshold=1.0 触发 `isIntersecting` 即视为「完全可见」。
+ * 返回 `[isVisible, ref]`,与 Flutter `VisibilityDetector` 的语义对齐。
+ *
+ * 用法:
+ * const [isVisible, ref] = useFullVisibility();
+ * useEffect(() => { dispatch(isVisible ? Visible() : Invisible()); }, [isVisible]);
+ */
+import { useEffect, useRef, useState } from "react";
+
+export function useFullVisibility(
+ threshold = 1.0,
+): readonly [boolean, React.RefObject] {
+ const ref = useRef(null);
+ const [visible, setVisible] = useState(false);
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el || typeof IntersectionObserver === "undefined") return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const entry = entries[0];
+ if (!entry) return;
+ setVisible(
+ entry.isIntersecting && entry.intersectionRatio >= threshold,
+ );
+ },
+ { threshold: [threshold] },
+ );
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, [threshold]);
+
+ return [visible, ref] as const;
+}
diff --git a/src/integrations/browser-detect.ts b/src/integrations/browser-detect.ts
new file mode 100644
index 00000000..fbcd1d5f
--- /dev/null
+++ b/src/integrations/browser-detect.ts
@@ -0,0 +1,41 @@
+/**
+ * 浏览器 / WebView 检测
+ *
+ * 原始 Dart: `lib/utils/browser_detector.dart`
+ *
+ * 主要用例:
+ * - 嵌入 Facebook / 微信 / 微博内嵌浏览器时,给用户提示「在外部浏览器打开」。
+ * - 判断 PWA 模式(standalone)。
+ */
+export const BrowserDetector = {
+ /** 当前是否在 Facebook 内嵌浏览器 */
+ isFacebookInAppBrowser(ua: string | null = null): boolean {
+ if (typeof navigator === "undefined" && !ua) return false;
+ const userAgent = ua ?? (typeof navigator !== "undefined" ? navigator.userAgent : "");
+ return /FBAN|FBAV|Instagram/i.test(userAgent);
+ },
+
+ /** 当前是否在微信内嵌浏览器 */
+ isWeChatInAppBrowser(ua: string | null = null): boolean {
+ if (typeof navigator === "undefined" && !ua) return false;
+ const userAgent = ua ?? (typeof navigator !== "undefined" ? navigator.userAgent : "");
+ return /MicroMessenger/i.test(userAgent);
+ },
+
+ /** 当前是否在 WebView(不通用;仅作 best-effort 判断) */
+ isInAppBrowser(ua: string | null = null): boolean {
+ if (typeof navigator === "undefined" && !ua) return false;
+ const userAgent = ua ?? (typeof navigator !== "undefined" ? navigator.userAgent : "");
+ return /WebView|; wv\)|FBAN|FBAV|MicroMessenger|Instagram|Line\//i.test(userAgent);
+ },
+
+ /** 当前是否以 PWA standalone 模式运行 */
+ isPWAStandalone(): boolean {
+ if (typeof window === "undefined") return false;
+ return (
+ window.matchMedia?.("(display-mode: standalone)").matches === true ||
+ // iOS Safari 旧版
+ (navigator as Navigator & { standalone?: boolean }).standalone === true
+ );
+ },
+};
diff --git a/src/integrations/chat-websocket.ts b/src/integrations/chat-websocket.ts
new file mode 100644
index 00000000..1a91732f
--- /dev/null
+++ b/src/integrations/chat-websocket.ts
@@ -0,0 +1,152 @@
+/**
+ * 聊天 WebSocket 处理器
+ *
+ * 原始 Dart: lib/core/net/websocket_handler.dart
+ *
+ * 浏览器方案:使用原生 `WebSocket` API,封装连接管理 + 重连 + 事件回调。
+ *
+ * 事件:
+ * - onConnected(userId)
+ * - onTyping(isTyping)
+ * - onSentence(index, text, total, done) ← AI 逐句推送
+ * - onVoiceReady(index, audioUrl)
+ * - onIntimacyUpdate(intimacyChange, newIntimacy, relationshipStage)
+ * - onMoodUpdate(mood)
+ * - onError(errorMessage)
+ */
+import { getApiConfig } from "@/data/api/config/api_config";
+
+export interface SentencePayload {
+ index: number;
+ text: string;
+ total: number;
+ done: boolean;
+}
+
+export interface IntimacyPayload {
+ intimacyChange?: number;
+ newIntimacy?: number;
+ relationshipStage?: string;
+}
+
+export class ChatWebSocket {
+ private ws: WebSocket | null = null;
+ private reconnectTimer: ReturnType | null = null;
+ private disposed = false;
+
+ onConnected: ((userId: string) => void) | null = null;
+ onTyping: ((isTyping: boolean) => void) | null = null;
+ onSentence: ((payload: SentencePayload) => void) | null = null;
+ onVoiceReady: ((index: number, audioUrl: string) => void) | null = null;
+ onIntimacyUpdate: ((payload: IntimacyPayload) => void) | null = null;
+ onMoodUpdate: ((mood: string) => void) | null = null;
+ onError: ((errorMessage: string) => void) | null = null;
+
+ constructor(
+ private readonly serverUrl: string,
+ private readonly token: string,
+ ) {}
+
+ get isConnected(): boolean {
+ return this.ws?.readyState === WebSocket.OPEN;
+ }
+
+ connect(): void {
+ if (this.disposed) return;
+ if (this.ws && this.ws.readyState !== WebSocket.CLOSED) return;
+ try {
+ const url = `${this.serverUrl}?token=${encodeURIComponent(this.token)}`;
+ this.ws = new WebSocket(url);
+ this.ws.onopen = () => {
+ // 连接成功;userId 由后端在第一条消息中下发
+ };
+ this.ws.onmessage = (e) => this.handleMessage(e.data);
+ this.ws.onerror = () => this.onError?.("WebSocket error");
+ this.ws.onclose = () => {
+ if (!this.disposed) {
+ this.reconnectTimer = setTimeout(() => this.connect(), 3000);
+ }
+ };
+ } catch (e) {
+ this.onError?.(e instanceof Error ? e.message : String(e));
+ }
+ }
+
+ sendMessage(content: string): boolean {
+ if (!this.isConnected) return false;
+ this.ws?.send(JSON.stringify({ type: "message", content }));
+ return true;
+ }
+
+ disconnect(): void {
+ this.disposed = true;
+ if (this.reconnectTimer) {
+ clearTimeout(this.reconnectTimer);
+ this.reconnectTimer = null;
+ }
+ this.ws?.close();
+ this.ws = null;
+ }
+
+ private handleMessage(data: unknown): void {
+ if (typeof data !== "string") return;
+ let payload: {
+ type?: string;
+ userId?: string;
+ isTyping?: boolean;
+ index?: number;
+ text?: string;
+ total?: number;
+ done?: boolean;
+ audioUrl?: string;
+ intimacyChange?: number;
+ newIntimacy?: number;
+ relationshipStage?: string;
+ mood?: string;
+ error?: string;
+ };
+ try {
+ payload = JSON.parse(data);
+ } catch {
+ return;
+ }
+ switch (payload.type) {
+ case "connected":
+ if (payload.userId) this.onConnected?.(payload.userId);
+ break;
+ case "typing":
+ this.onTyping?.(payload.isTyping ?? false);
+ break;
+ case "sentence":
+ this.onSentence?.({
+ index: payload.index ?? 0,
+ text: payload.text ?? "",
+ total: payload.total ?? 0,
+ done: payload.done ?? false,
+ });
+ break;
+ case "voice_ready":
+ this.onVoiceReady?.(payload.index ?? 0, payload.audioUrl ?? "");
+ break;
+ case "intimacy_update":
+ this.onIntimacyUpdate?.({
+ intimacyChange: payload.intimacyChange,
+ newIntimacy: payload.newIntimacy,
+ relationshipStage: payload.relationshipStage,
+ });
+ break;
+ case "mood_update":
+ if (payload.mood) this.onMoodUpdate?.(payload.mood);
+ break;
+ case "error":
+ this.onError?.(payload.error ?? "Unknown error");
+ break;
+ }
+ }
+}
+
+/** 默认创建实例:从 env 读取 WS URL。 */
+export function createChatWebSocket(token: string): ChatWebSocket {
+ const base = getApiConfig().wsUrl;
+ return new ChatWebSocket(base, token);
+}
diff --git a/src/integrations/facebook-sdk.d.ts b/src/integrations/facebook-sdk.d.ts
new file mode 100644
index 00000000..77ca1e7e
--- /dev/null
+++ b/src/integrations/facebook-sdk.d.ts
@@ -0,0 +1,39 @@
+/**
+ * Facebook SDK 全局类型声明
+ */
+export {};
+
+declare global {
+ interface Window {
+ FB?: {
+ init(params: {
+ appId: string;
+ cookie?: boolean;
+ xfbml?: boolean;
+ version: string;
+ }): void;
+ login(
+ callback: (response: {
+ authResponse?: {
+ accessToken: string;
+ userID: string;
+ expiresIn: number;
+ };
+ status?: "connected" | "not_authorized" | "unknown";
+ }) => void,
+ options?: { scope: string },
+ ): void;
+ logout(callback?: () => void): void;
+ api(
+ path: string,
+ callback: (response: {
+ id?: string;
+ name?: string;
+ email?: string;
+ picture?: { data?: { url?: string } };
+ }) => void,
+ ): void;
+ };
+ fbAsyncInit?: () => void;
+ }
+}
diff --git a/src/integrations/facebook-sdk.ts b/src/integrations/facebook-sdk.ts
new file mode 100644
index 00000000..6c29042f
--- /dev/null
+++ b/src/integrations/facebook-sdk.ts
@@ -0,0 +1,147 @@
+/**
+ * Facebook SDK JS 封装
+ *
+ * 原始 Dart: `flutter_facebook_auth` 包
+ * (`lib/data/services/auth/auth_platform.dart` + `auth_screen.dart` 中 Facebook 登录入口)。
+ *
+ * 浏览器方案:
+ * 1. 在 `app/layout.tsx` 通过 `