Compare commits
338 Commits
40143afc39
...
terry-wall
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae84e73736 | ||
|
|
bd48fded30 | ||
|
|
fd1a3f4b3e | ||
|
|
37e6e4901f | ||
|
|
e80330b13c | ||
|
|
b38b28f175 | ||
|
|
ec8ef5b774 | ||
|
|
4c684d75a3 | ||
|
|
ee3f2c43eb | ||
|
|
75ccfd78ed | ||
|
|
69bef7ee6e | ||
|
|
3275aff121 | ||
|
|
356d8a0207 | ||
|
|
7a33a62c8f | ||
|
|
abfd92b16a | ||
|
|
9f5367ae12 | ||
|
|
2d003c6fef | ||
|
|
908f89ac24 | ||
|
|
aae7faf9dd | ||
|
|
4e459aa4be | ||
|
|
5d550e0342 | ||
|
|
a9ec46e008 | ||
|
|
486c09dd39 | ||
|
|
292c745549 | ||
|
|
e73e25077e | ||
|
|
36ab5be3c2 | ||
|
|
062f630798 | ||
|
|
1fcf2ea46d | ||
|
|
ec98ff5a03 | ||
|
|
4f6cbbc314 | ||
|
|
6471559b3b | ||
|
|
efaf92c4e4 | ||
|
|
01eab88c0f | ||
|
|
fd19ed438e | ||
|
|
53dc35e7dc | ||
|
|
863a448ec9 | ||
|
|
1b52a6d93d | ||
|
|
4059ec3f20 | ||
|
|
90f27b050c | ||
|
|
0a86619b6c | ||
|
|
ae64f96bbe | ||
|
|
65dee3a37e | ||
|
|
fb904d3a55 | ||
|
|
355c920c80 | ||
|
|
f8369b6361 | ||
|
|
53eab4a066 | ||
|
|
93790cb885 | ||
|
|
a1b318016f | ||
|
|
469e53a860 | ||
|
|
8140828c11 | ||
|
|
526facb261 | ||
|
|
57dc25e5eb | ||
|
|
173c283fb8 | ||
|
|
39f9cba8c7 | ||
|
|
2ef26390be | ||
|
|
6800a8e9b6 | ||
|
|
b4ef5ddb61 | ||
|
|
53614189ce | ||
|
|
f2f2572cd2 | ||
|
|
f7c0c0387e | ||
|
|
fc19b92158 | ||
|
|
724bfb8f24 | ||
|
|
be638e32c9 | ||
|
|
4f0d8925a4 | ||
|
|
7e4be0a590 | ||
|
|
a2f6c4fc35 | ||
|
|
49380dc5ed | ||
|
|
42b25b9e09 | ||
|
|
966663f3d7 | ||
|
|
985463b7da | ||
|
|
a68dd8f616 | ||
|
|
6552b92c50 | ||
|
|
b19486e908 | ||
|
|
0326cb2998 | ||
|
|
2955ba1039 | ||
|
|
cd85a4bcfa | ||
|
|
a8863c5478 | ||
|
|
3c72917bf9 | ||
|
|
8a948e41e0 | ||
|
|
850daf3a2a | ||
|
|
4d38c4513d | ||
|
|
803d3d57c1 | ||
|
|
243e98b829 | ||
|
|
f0209eb894 | ||
|
|
0898744deb | ||
|
|
edba16bbd2 | ||
|
|
562843e4b2 | ||
|
|
387b25f1e3 | ||
|
|
7ed9f8c8bf | ||
|
|
fbb9d21f24 | ||
|
|
e752de67e1 | ||
|
|
8b0ee18cd8 | ||
|
|
d3e562663d | ||
|
|
92210cf0a2 | ||
|
|
6c0c3b89a9 | ||
|
|
8acb3a281b | ||
|
|
4e33c7deef | ||
|
|
e1b24aa0f9 | ||
|
|
11599e54ea | ||
|
|
8821058c0a | ||
|
|
ed04e1fb7e | ||
|
|
7abe4a868c | ||
|
|
f935f122f9 | ||
|
|
0edcc80513 | ||
|
|
b9fe7ff168 | ||
|
|
fb6cb5bc11 | ||
|
|
184193e655 | ||
|
|
fc2ca62957 | ||
|
|
05c2252b49 | ||
|
|
4900256423 | ||
|
|
de93e883c9 | ||
|
|
337e8f7e67 | ||
|
|
43700d9fdc | ||
|
|
71dac8373e | ||
|
|
df20005357 | ||
|
|
b265a57541 | ||
|
|
5a5acfcbc2 | ||
|
|
097c12bab5 | ||
|
|
e096d59fa6 | ||
|
|
7b48f9780c | ||
|
|
b4eb44f824 | ||
|
|
56d6bd033d | ||
|
|
da4c13f304 | ||
|
|
a968f47640 | ||
|
|
c53032155b | ||
|
|
337d19e626 | ||
|
|
c490524575 | ||
|
|
c32ae539f6 | ||
|
|
4dcf68bc71 | ||
|
|
fa78568c94 | ||
|
|
6c4936fea3 | ||
|
|
9b08379d50 | ||
|
|
04badc26d1 | ||
|
|
186ba362f3 | ||
| d0302218b2 | |||
|
|
06fe117ebc | ||
|
|
34ef6cba15 | ||
|
|
c7e0562d9a | ||
|
|
5faa18d343 | ||
|
|
c71ebba807 | ||
|
|
345ccb0a25 | ||
|
|
92a8a83585 | ||
|
|
6b3211f26f | ||
|
|
39c593c454 | ||
|
|
cf6bd7339e | ||
|
|
00913a26a7 | ||
|
|
46b7ee861e | ||
|
|
5b93e8dc77 | ||
|
|
a564fddfcb | ||
|
|
e35573083a | ||
|
|
320e09cc87 | ||
|
|
9ac072e8d8 | ||
|
|
14aca7bc8d | ||
|
|
5179a8c068 | ||
|
|
4c01e4fa52 | ||
|
|
cff857362d | ||
|
|
ada117f4f4 | ||
|
|
1ab8319465 | ||
|
|
3f251710d0 | ||
|
|
7ed4cbbeba | ||
| 92de7a57f4 | |||
| ad4eeeb87a | |||
|
|
cc58ee8aac | ||
|
|
d531ba40f3 | ||
|
|
5277943196 | ||
| 08c474e86b | |||
|
|
5ce52943e9 | ||
|
|
0e877d4959 | ||
|
|
942db88f58 | ||
|
|
5f7c4eea62 | ||
|
|
b2e4a4e710 | ||
|
|
41299b5b65 | ||
|
|
0733ea8b18 | ||
|
|
07f040a549 | ||
|
|
eb0eabe21f | ||
|
|
40d64f1293 | ||
|
|
9bef178bc8 | ||
|
|
cc9f0a5730 | ||
|
|
1d74f29c2a | ||
| 21e078fd89 | |||
|
|
1557d29af7 | ||
|
|
a8fd540ef5 | ||
|
|
6798e90708 | ||
|
|
2b9ab9eb2c | ||
|
|
4c441244c8 | ||
|
|
f1e5e17fce | ||
|
|
ea38503f37 | ||
|
|
609c119277 | ||
|
|
78d055bb99 | ||
|
|
ce5505ea23 | ||
|
|
2e50b301a3 | ||
|
|
4a20d80f68 | ||
|
|
ed6e0023b8 | ||
|
|
b848ce5db3 | ||
|
|
27f9dbbc45 | ||
|
|
8646b51b6c | ||
|
|
3d5681e7de | ||
|
|
ae14b33f83 | ||
|
|
6d62aad8c4 | ||
|
|
a4884a689d | ||
|
|
29dc71d2dd | ||
|
|
09d887dd52 | ||
|
|
8425809f98 | ||
|
|
8610ac521e | ||
|
|
61f3c41567 | ||
|
|
e9245875a3 | ||
|
|
fd46359fd9 | ||
|
|
4e5093cae0 | ||
|
|
92e6ce9dd8 | ||
|
|
6b42981419 | ||
|
|
f7230de12b | ||
|
|
ac208dfe25 | ||
|
|
cb14cb166a | ||
|
|
d19f2f9efa | ||
|
|
15bcb6bdf0 | ||
|
|
d7e2e56cde | ||
|
|
c0068e957e | ||
| 6249b14096 | |||
|
|
ad885f578c | ||
|
|
b370bf756c | ||
|
|
f3c51725fb | ||
|
|
64a41359b4 | ||
|
|
b283ba74da | ||
|
|
471d29bec9 | ||
|
|
789920d2b9 | ||
|
|
0035457c6d | ||
|
|
a7792c117d | ||
|
|
b66c35be11 | ||
|
|
33a466841c | ||
| b4f9b5b304 | |||
| 3e68bb4334 | |||
|
|
042635528a | ||
|
|
e04dd7dc2a | ||
|
|
4a097bad9d | ||
|
|
1f8772f645 | ||
|
|
b22ecc22ad | ||
|
|
db5da8a841 | ||
|
|
54bdbbc0e9 | ||
| 3fa0a3ccc2 | |||
|
|
7eb2aa8b5b | ||
|
|
512fa53c2b | ||
|
|
cfae09a7d3 | ||
|
|
4f6b4a498f | ||
|
|
559c4f19c8 | ||
|
|
4464e6fdc5 | ||
|
|
b252fa113d | ||
| 87683293da | |||
|
|
f73131dc03 | ||
| bcd7395e77 | |||
|
|
14c3defd23 | ||
|
|
88a25b6ad4 | ||
|
|
8e36894851 | ||
|
|
35e25fa023 | ||
|
|
9b21b7e301 | ||
|
|
9afb4de859 | ||
|
|
026b037c5b | ||
|
|
bca69fe3bd | ||
|
|
f7828d8776 | ||
|
|
320739f91b | ||
|
|
5ae9647465 | ||
|
|
b59fd82006 | ||
|
|
b24529afc4 | ||
|
|
f183a401fc | ||
|
|
f1a0e9ab40 | ||
|
|
4e44636d68 | ||
|
|
15d873be63 | ||
|
|
6f901f48e1 | ||
|
|
5ca38a0eca | ||
|
|
ef1f3163eb | ||
|
|
c03a3c6d89 | ||
|
|
c480eea7b7 | ||
|
|
b6ba4d53e7 | ||
|
|
5036c930bb | ||
| 459c051fc8 | |||
|
|
5fec82dbba | ||
|
|
4c15e01460 | ||
|
|
ca6dfe0fe1 | ||
|
|
6a998c0186 | ||
|
|
fea6e1c93b | ||
|
|
16b047ba04 | ||
|
|
e0240f6217 | ||
|
|
9d977be2d2 | ||
|
|
28b0ef3f9a | ||
|
|
3ed3d00655 | ||
|
|
4b497380ee | ||
|
|
0e98428f64 | ||
|
|
e65c473369 | ||
|
|
16f3f06431 | ||
|
|
49f61b89f1 | ||
|
|
3825c4ec2f | ||
|
|
f7d62bff6e | ||
|
|
47cff67b87 | ||
|
|
02c9d454c1 | ||
|
|
4a718926da | ||
|
|
7e70798d68 | ||
|
|
f5e858659f | ||
|
|
23a7807bef | ||
|
|
1ad599c3ac | ||
|
|
a3b989303a | ||
|
|
d3d054ad32 | ||
| 910cebb03c | |||
|
|
a6fda3cd03 | ||
|
|
565784b4bb | ||
|
|
1f3acca211 | ||
|
|
902300933e | ||
|
|
8120f6b05c | ||
|
|
54841a4ed9 | ||
|
|
9453777dba | ||
|
|
68cbce9cf1 | ||
|
|
7cd48f767e | ||
|
|
3f0a395f40 | ||
|
|
f169144378 | ||
| 80f79a3ace | |||
|
|
2b1874ab01 | ||
|
|
7546faf15e | ||
| 3ff3ce1468 | |||
| 1f89363b6d | |||
|
|
292383f122 | ||
|
|
54f71c6ab3 | ||
|
|
532f0112fd | ||
|
|
e0629c9df7 | ||
| 5a3568820e | |||
| 625d9fbb42 | |||
|
|
31b7d53b69 | ||
| efc41fbd2f | |||
|
|
f6c0f30921 | ||
|
|
78bdf73143 | ||
|
|
d3c30795dc | ||
|
|
f482a2ec38 | ||
|
|
f2e97c329e | ||
|
|
e7a5952d58 | ||
|
|
453abfcec7 | ||
|
|
a784f159fe | ||
|
|
aaebd7ccd1 | ||
| 3f0a9f72d9 | |||
| 769087ba4a | |||
| 2c710e2e24 | |||
|
|
e6bc212c4e |
11
.env.example
@@ -1,9 +1,6 @@
|
||||
# API origin. Leave empty for same-origin/local Vite proxy.
|
||||
VITE_API_URL=
|
||||
|
||||
# Reown / WalletConnect project id. Required for WalletConnect QR/mobile login.
|
||||
VITE_WALLETCONNECT_PROJECT_ID=
|
||||
|
||||
# Public production deploy disables admin routes.
|
||||
VITE_DISABLE_ADMIN=false
|
||||
|
||||
@@ -12,3 +9,11 @@ VITE_ADMIN_ONLY=false
|
||||
|
||||
# Optional admin UI base path. Leave empty to use default app behavior.
|
||||
VITE_ADMIN_UI_PREFIX=
|
||||
|
||||
# Use mock Post data (Telegram-style resource stream) only when explicitly enabled.
|
||||
# Default production/staging behavior should hit the real /api/posts API.
|
||||
VITE_USE_MOCK_POSTS=false
|
||||
|
||||
# Reown/WalletConnect project ID used by RainbowKit fallback QR login
|
||||
# for MetaMask/imToken. TokenPocket QR does not depend on this.
|
||||
VITE_WALLETCONNECT_PROJECT_ID=
|
||||
|
||||
125
.gitea/workflows/deploy-staging.yml
Normal file
@@ -0,0 +1,125 @@
|
||||
name: Deploy Staging (terry-wallet-login)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- terry-wallet-login
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Free disk space
|
||||
run: |
|
||||
set +e
|
||||
echo "=== Disk before cleanup ==="
|
||||
df -h
|
||||
du -sh "$HOME/.cache/act" "$HOME/.npm" "$HOME/actions-runner/_work" 2>/dev/null
|
||||
|
||||
# DO NOT touch ~/.cache/act for the current job — only sweep dirs older than 60 min.
|
||||
if [ -d "$HOME/.cache/act" ]; then
|
||||
find "$HOME/.cache/act" -mindepth 1 -maxdepth 1 -type d -mmin +60 -exec rm -rf {} + 2>/dev/null
|
||||
fi
|
||||
|
||||
# Wipe npm and setup-node caches (cache: npm will repopulate from registry).
|
||||
rm -rf "$HOME/.npm/_cacache" "$HOME/.npm/_logs" 2>/dev/null
|
||||
rm -rf "$HOME/.cache/setup-node" 2>/dev/null
|
||||
|
||||
# Old actions-runner workspaces (>6h)
|
||||
if [ -d "$HOME/actions-runner/_work" ]; then
|
||||
find "$HOME/actions-runner/_work" -mindepth 1 -maxdepth 2 -mmin +360 -exec rm -rf {} + 2>/dev/null
|
||||
fi
|
||||
|
||||
# Docker aggressive prune (all dangling + unused, including volumes)
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
docker system prune -af --volumes 2>/dev/null
|
||||
fi
|
||||
|
||||
# apt/yum cache
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
sudo apt-get clean 2>/dev/null
|
||||
fi
|
||||
if command -v yum >/dev/null 2>&1; then
|
||||
sudo yum clean all 2>/dev/null
|
||||
fi
|
||||
|
||||
# /tmp leftovers (>30 min) other than active runner state
|
||||
find /tmp -mindepth 1 -maxdepth 1 -mmin +30 \
|
||||
-not -name 'runner*' -not -name 'act*' -not -name 'tmp.*' \
|
||||
-exec rm -rf {} + 2>/dev/null
|
||||
|
||||
# journald logs vacuum to 100M
|
||||
if command -v journalctl >/dev/null 2>&1; then
|
||||
sudo journalctl --vacuum-size=100M 2>/dev/null
|
||||
fi
|
||||
|
||||
echo "=== Disk after cleanup ==="
|
||||
df -h
|
||||
exit 0
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Format check
|
||||
run: npm run format:check
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
env:
|
||||
VITE_API_URL: ""
|
||||
VITE_API_PREFIX: "/apnew"
|
||||
VITE_DISABLE_ADMIN: "true"
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.STAGING_SSH_KEY }}" > ~/.ssh/staging_key
|
||||
chmod 600 ~/.ssh/staging_key
|
||||
ssh-keyscan -H ${{ secrets.STAGING_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Deploy to staging server
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HOST="${{ secrets.STAGING_HOST }}"
|
||||
USER="${{ secrets.STAGING_USER }}"
|
||||
echo ">>> 部署到 staging $USER@$HOST"
|
||||
rsync -avz --delete \
|
||||
-e "ssh -i ~/.ssh/staging_key -o StrictHostKeyChecking=no" \
|
||||
dist/ \
|
||||
"${USER}@${HOST}:/var/www/ark-library-staging/"
|
||||
echo ">>> staging 部署完成"
|
||||
|
||||
- name: Verify staging server matches local build
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LOCAL=$(sha256sum dist/index.html | awk '{print $1}')
|
||||
REMOTE=$(ssh -i ~/.ssh/staging_key -o StrictHostKeyChecking=no \
|
||||
${{ secrets.STAGING_USER }}@${{ secrets.STAGING_HOST }} \
|
||||
"sha256sum /var/www/ark-library-staging/index.html | awk '{print \$1}'")
|
||||
echo "local: $LOCAL"
|
||||
echo "staging: $REMOTE"
|
||||
if [ "$REMOTE" != "$LOCAL" ]; then
|
||||
echo "ERROR: staging 不是本次构建的版本"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ staging 已经更新到本次构建的版本。"
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/staging_key
|
||||
@@ -10,6 +10,36 @@ jobs:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Free disk space
|
||||
run: |
|
||||
set +e
|
||||
echo "=== Disk before cleanup ==="
|
||||
df -h
|
||||
# Stale act runner workspaces from previous jobs (older than 60 min).
|
||||
if [ -d "$HOME/.cache/act" ]; then
|
||||
du -sh "$HOME/.cache/act" 2>/dev/null
|
||||
find "$HOME/.cache/act" -mindepth 1 -maxdepth 1 -type d -mmin +60 -exec rm -rf {} + 2>/dev/null
|
||||
fi
|
||||
# Stale runner workspaces under common locations.
|
||||
for dir in "$HOME/actions-runner/_work" "$HOME/.cache/setup-node" "$HOME/.npm/_cacache"; do
|
||||
if [ -d "$dir" ]; then
|
||||
find "$dir" -mindepth 1 -maxdepth 2 -mmin +1440 -exec rm -rf {} + 2>/dev/null
|
||||
fi
|
||||
done
|
||||
# Docker leftovers if docker is available.
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
docker image prune -af --filter "until=24h" 2>/dev/null
|
||||
docker container prune -f --filter "until=24h" 2>/dev/null
|
||||
docker builder prune -af --filter "until=24h" 2>/dev/null
|
||||
fi
|
||||
# Stale /tmp files older than 2h, keep currently-running runner files.
|
||||
find /tmp -mindepth 1 -maxdepth 1 -mmin +120 \
|
||||
-not -name 'runner*' -not -name 'act*' \
|
||||
-exec rm -rf {} + 2>/dev/null
|
||||
echo "=== Disk after cleanup ==="
|
||||
df -h
|
||||
exit 0
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -34,7 +64,8 @@ jobs:
|
||||
- name: Build
|
||||
run: npm run build
|
||||
env:
|
||||
VITE_API_URL: https://api.ark-library.com
|
||||
VITE_API_URL: ""
|
||||
VITE_API_PREFIX: "/apnew"
|
||||
VITE_DISABLE_ADMIN: "true"
|
||||
|
||||
- name: Setup SSH key
|
||||
@@ -47,6 +78,7 @@ jobs:
|
||||
|
||||
- name: Deploy to both servers
|
||||
run: |
|
||||
set -euo pipefail
|
||||
deploy_to() {
|
||||
local HOST=$1
|
||||
echo ">>> 部署到 $HOST"
|
||||
@@ -57,25 +89,37 @@ jobs:
|
||||
echo ">>> $HOST 部署完成"
|
||||
}
|
||||
deploy_to "${{ secrets.FRONTEND_1_HOST }}" &
|
||||
PID1=$!
|
||||
deploy_to "${{ secrets.FRONTEND_2_HOST }}" &
|
||||
wait
|
||||
PID2=$!
|
||||
FAIL=0
|
||||
wait $PID1 || { echo "ERROR: frontend-1 部署失败"; FAIL=1; }
|
||||
wait $PID2 || { echo "ERROR: frontend-2 部署失败"; FAIL=1; }
|
||||
[ $FAIL -eq 0 ] || exit 1
|
||||
echo "=== 两台都部署完成 ==="
|
||||
|
||||
- name: Verify both servers match
|
||||
- name: Verify both servers match local build
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LOCAL=$(sha256sum dist/index.html | awk '{print $1}')
|
||||
SUM1=$(ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
|
||||
ec2-user@${{ secrets.FRONTEND_1_HOST }} \
|
||||
"sha256sum /var/www/ark-library/index.html | awk '{print \$1}'")
|
||||
SUM2=$(ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
|
||||
ec2-user@${{ secrets.FRONTEND_2_HOST }} \
|
||||
"sha256sum /var/www/ark-library/index.html | awk '{print \$1}'")
|
||||
echo "local: $LOCAL"
|
||||
echo "frontend-1: $SUM1"
|
||||
echo "frontend-2: $SUM2"
|
||||
if [ "$SUM1" != "$SUM2" ]; then
|
||||
echo "ERROR: 两台 index.html 不一样!"
|
||||
if [ "$SUM1" != "$LOCAL" ]; then
|
||||
echo "ERROR: frontend-1 不是本次构建的版本"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ 两台 checksum 一致,部署成功。"
|
||||
if [ "$SUM2" != "$LOCAL" ]; then
|
||||
echo "ERROR: frontend-2 不是本次构建的版本"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ 两台都已经更新到本次构建的版本。"
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
|
||||
9
.gitignore
vendored
@@ -30,3 +30,12 @@ pnpm-debug.log*
|
||||
coverage/
|
||||
.cache/
|
||||
.vite/
|
||||
|
||||
# Agent local state / workflow noise
|
||||
.oh-my-opencode-pi-*
|
||||
.omc/
|
||||
.unipi/ralph/
|
||||
.unipi/logs/
|
||||
|
||||
# Visual brainstorming companion
|
||||
.superpowers/
|
||||
|
||||
103
.unipi/docs/debug/2026-06-02-metamask-wallet-login-debug.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: "MetaMask Wallet Login Does Not Surface Address — Debug Report"
|
||||
type: debug
|
||||
date: 2026-06-02
|
||||
severity: high
|
||||
status: root-caused
|
||||
---
|
||||
|
||||
# MetaMask Wallet Login Does Not Surface Address — Debug Report
|
||||
|
||||
## Summary
|
||||
MetaMask QR login and mobile deeplink login can be approved in MetaMask, but the ARK frontend does not write the approved wallet address into the local wallet session; the mobile QR waiting text is also incorrectly TokenPocket-specific for all wallets.
|
||||
|
||||
## Expected Behavior
|
||||
- Selecting MetaMask and approving the connection should result in the page showing the connected wallet address.
|
||||
- Mobile MetaMask deeplink login should return/reconnect to the page and complete local no-signature login with `local-wallet:<address>`.
|
||||
- QR login copy should be generic or absent; it should not say “waiting in TokenPocket” when the selected wallet is MetaMask or imToken.
|
||||
|
||||
## Actual Behavior
|
||||
- Terry can approve MetaMask QR/deeplink login, but the web page does not show the authorized address.
|
||||
- The QR panel uses TokenPocket-specific copy for any wallet on mobile, e.g. imToken QR login still shows a TokenPocket waiting message.
|
||||
|
||||
## Reproduction Steps
|
||||
1. Open the public frontend and open the wallet login modal.
|
||||
2. Select MetaMask.
|
||||
3. Use either:
|
||||
- QR login: scan the QR using MetaMask and approve, or
|
||||
- Mobile app login: tap “Open wallet app”, approve in MetaMask, then return to the web page.
|
||||
4. Observe that the page does not show the wallet address.
|
||||
5. Select imToken QR login on mobile and observe that the QR panel displays TokenPocket-specific waiting text.
|
||||
|
||||
## Environment
|
||||
- Project: Arkie Library Frontend (`ark-database-web`)
|
||||
- Branch context: `terry-wallet-login`
|
||||
- Stack: React 18, Vite, TypeScript, RainbowKit, Wagmi, WalletConnect/Reown
|
||||
- Wallets involved: MetaMask Mobile, TokenPocket, imToken
|
||||
- Backend auth endpoints intentionally not required for this flow; login is local no-signature wallet session.
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### Failure Chain
|
||||
1. `WalletLoginModal` calls `wc.start(kind, mode)` for all wallet app/QR flows.
|
||||
2. `useWalletConnectLogin.start()` currently chooses the first generic WalletConnect connector for non-injected flows:
|
||||
- `connectors.find((item) => item.type === "walletConnect") ?? connectors.find((item) => item.id === "walletConnect")`
|
||||
3. On MetaMask mobile, RainbowKit’s own MetaMask wallet definition intentionally uses Wagmi’s `metaMask()` / MetaMask SDK connector, not the generic WalletConnect connector.
|
||||
4. The custom hook bypasses that MetaMask-specific connector on mobile, so MetaMask SDK deeplink/reconnect handling is not used.
|
||||
5. The hook only calls `completeLogin(localWalletToken(address), address)` inside the awaited `connectAsync(...)` result path.
|
||||
6. If MetaMask approval completes while the browser is backgrounded, after a page reload, or through a restored Wagmi connection, there is no `useAccount`/reconnect bridge that converts the Wagmi connected address into the app’s local wallet session.
|
||||
7. `RainbowWalletProvider` calls `useReconnect()`, but this only restores Wagmi connection state; it does not update `WalletProvider` unless `useWalletConnectLogin` observes the restored account and calls `completeLogin`.
|
||||
8. Therefore MetaMask may be approved/connected at the wallet/Wagmi layer but the ARK UI still has no `local-wallet:<address>` token and shows logged-out/no address.
|
||||
|
||||
### Root Cause
|
||||
The MetaMask flow is treated as a generic WalletConnect flow, but RainbowKit/Wagmi have MetaMask-specific mobile behavior. Additionally, local ARK wallet login is tied only to the synchronous `connectAsync` return path instead of being derived from Wagmi account state/reconnect events. This misses MetaMask connections that resolve via mobile app backgrounding, deep link return, QR approval, or page reload.
|
||||
|
||||
### Evidence
|
||||
- File: `src/wallet/useWalletConnectLogin.ts` — connector selection prefers `item.type === "walletConnect"` for all wallets, so mobile MetaMask does not use the RainbowKit/Wagmi MetaMask connector.
|
||||
- File: `src/wallet/useWalletConnectLogin.ts` — `completeLogin(localWalletToken(...), ...)` only runs after `await connectAsync(...)`; there is no `useAccount` effect to complete local login when Wagmi is already/reconnected.
|
||||
- File: `src/wallet/RainbowWalletProvider.tsx` — `WalletReconnectOnMount` calls `useReconnect()`, but no downstream code maps the restored Wagmi account into `WalletProvider`.
|
||||
- File: `node_modules/@rainbow-me/rainbowkit/dist/wallets/walletConnectors/chunk-BQHQU37S.js` — RainbowKit MetaMask wallet uses `metaMask()` connector on mobile and comments that “MetaMask mobile deep linking [is] handled by wagmi”. The custom hook bypasses this by selecting a generic WalletConnect connector.
|
||||
- File: `src/wallet/WalletLoginModal.tsx` — QR text uses `mobileDevice ? t("walletTpWaiting") : t("walletQrUseAnotherDevice")` for every selected wallet, causing TokenPocket-specific copy for MetaMask/imToken.
|
||||
- File: `src/locales/en.ts` and `src/locales/zh-CN.ts` — `walletQrUseAnotherDevice` also explicitly mentions TokenPocket, so even desktop/generic QR copy is wallet-specific.
|
||||
|
||||
## Affected Files
|
||||
- `src/wallet/useWalletConnectLogin.ts` — connector choice, deeplink generation, QR URI generation, local-login completion.
|
||||
- `src/wallet/WalletLoginModal.tsx` — misleading QR panel copy.
|
||||
- `src/wallet/RainbowWalletProvider.tsx` — currently reconnects Wagmi but does not by itself complete local login.
|
||||
- `src/locales/*.ts` — QR copy currently contains TokenPocket-specific text.
|
||||
|
||||
## Suggested Fix
|
||||
Use the wallet-specific connector when a wallet is selected, especially MetaMask, and add a Wagmi account/reconnect bridge that completes the local wallet session whenever Wagmi has an address. Remove or generalize TokenPocket-specific QR waiting copy.
|
||||
|
||||
### Fix Strategy
|
||||
1. In `useWalletConnectLogin.ts`, prefer a connector matching `preferredWallet` before falling back to generic WalletConnect:
|
||||
- MetaMask: `id === "metaMask"` or `type === "metaMask"`
|
||||
- imToken/TokenPocket: matching wallet id when present, otherwise generic WalletConnect
|
||||
2. Add `useAccount()` inside `useWalletConnectLogin` and a `useEffect` that calls `completeLogin(localWalletToken(address), address)` when Wagmi reports `isConnected && address`.
|
||||
3. For MetaMask QR, transform the displayed QR value with the same wallet-specific URI RainbowKit uses: `https://metamask.app.link/wc?uri=<encoded_wc_uri>` instead of always rendering raw `wc:`.
|
||||
4. Remove the QR panel paragraph entirely, or replace it with generic copy such as “Please approve the connection in your wallet app.”
|
||||
5. Re-test MetaMask separately for desktop QR scan, mobile Chrome deeplink return, and MetaMask in-app browser injected login.
|
||||
|
||||
### Risk Assessment
|
||||
- Risk: Selecting MetaMask’s SDK connector may behave differently from WalletConnect for QR mode. Mitigate by falling back to WalletConnect if the MetaMask connector does not emit a `display_uri` in QR mode.
|
||||
- Risk: Auto-completing local login from any Wagmi connected account may log in a stale account after reconnect. Mitigate by only completing when modal/pending/login-in-progress context exists or by clearing stale flags.
|
||||
- Risk: Changing QR value for MetaMask may affect wallets that scan raw WalletConnect URIs. Mitigate by only applying MetaMask app-link QR transformation for MetaMask.
|
||||
|
||||
## Verification Plan
|
||||
1. Run `npx tsc --noEmit`.
|
||||
2. Run `npm run format:check`.
|
||||
3. Run `npm test`.
|
||||
4. Desktop Chrome + MetaMask Mobile QR: Select MetaMask → QR login → scan/approve → confirm address and `local-wallet:<address>`.
|
||||
5. Mobile Chrome + MetaMask app login: Select MetaMask → Open wallet app → approve → return/refresh browser → confirm address appears.
|
||||
6. Regression test TokenPocket and imToken app login.
|
||||
7. Confirm imToken/MetaMask QR login no longer displays TokenPocket-specific text.
|
||||
|
||||
## Related Issues
|
||||
- TokenPocket was previously fixed by handling mobile return/reload behavior.
|
||||
- imToken was previously fixed by adding in-app browser fallback and injected no-signature login.
|
||||
- Existing local-memory context indicates MetaMask QR approval has been observed to not update frontend state.
|
||||
|
||||
## Notes
|
||||
- This report is diagnosis only; no source fix was applied during `/unipi:debug`.
|
||||
- The local no-signature session model means the frontend does not need backend wallet nonce/verify endpoints for this fix.
|
||||
- The current debug UI showing `Wallet debug` may be useful during verification but should be removed or hidden before final production cleanup if no longer needed.
|
||||
137
.unipi/docs/debug/2026-06-04-imtoken-no-address-debug.md
Normal file
@@ -0,0 +1,137 @@
|
||||
---
|
||||
title: "imToken opens in-app browser but cannot get wallet address — Debug Report"
|
||||
type: debug
|
||||
date: 2026-06-04
|
||||
severity: high
|
||||
status: needs-investigation
|
||||
---
|
||||
|
||||
# imToken opens in-app browser but cannot get wallet address — Debug Report
|
||||
|
||||
## Summary
|
||||
|
||||
imToken can open the site in its in-app browser, but the frontend does not obtain a wallet address and the login flow stays at `connected: no`.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
1. User taps imToken from Chrome or from imToken's in-app browser.
|
||||
2. The site detects the imToken injected wallet provider.
|
||||
3. The frontend requests accounts via the injected provider.
|
||||
4. The frontend receives a wallet address and completes login.
|
||||
|
||||
## Actual Behavior
|
||||
|
||||
The page opens inside imToken, but the wallet modal remains stuck with:
|
||||
|
||||
- `state: connecting`
|
||||
- `connected: no`
|
||||
- `address: -`
|
||||
- `qr: -`
|
||||
|
||||
The supplied screenshot shows the page loaded at `192.168.1.187` and the imToken card selected.
|
||||
|
||||
## Reproduction Steps
|
||||
|
||||
1. Open the site in Chrome on mobile.
|
||||
2. Tap wallet login.
|
||||
3. Select imToken.
|
||||
4. The app opens imToken's in-app browser.
|
||||
5. Login does not complete and no wallet address appears.
|
||||
6. Open the same page inside imToken's in-app browser and tap login again.
|
||||
7. The wallet debug panel still shows `connected: no` and `address: -`.
|
||||
|
||||
## Environment
|
||||
|
||||
- Branch: `terry-wallet-login`
|
||||
- Frontend URL shown in screenshot: `192.168.1.187`
|
||||
- Wallet: imToken mobile in-app browser
|
||||
- Network path: local LAN IP, likely non-HTTPS
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### Failure Chain
|
||||
|
||||
1. `WalletLoginModal` selects imToken and exposes the mobile actions.
|
||||
2. `openWalletAppDirect(kind)` first checks `getInjectedWallet(kind)`.
|
||||
3. If no injected provider is detected, the flow deep-links to imToken or falls through to `useWalletConnectLogin.start()` depending on which action is tapped.
|
||||
4. `useWalletConnectLogin.start()` sets `state` to `connecting` before trying direct injected login.
|
||||
5. The direct injected branch only runs when `getInjectedWallet(preferredWallet)` returns a provider.
|
||||
6. The screenshot shows `state: connecting`, `connected: no`, `address: -`, and `qr: -`, which means no address was completed through either direct injected login or WalletConnect.
|
||||
7. `AutoInjectedLogin` can auto-start imToken login without `?autoLogin=imToken` only if `isImTokenBrowser()` detects the imToken user agent; it still depends on `waitForInjected("imToken")`, which depends on `getInjectedWallet("imToken")`.
|
||||
8. Therefore the app is not obtaining a usable imToken injected provider, or the provider is present but `eth_accounts` / `eth_requestAccounts` returns no valid address.
|
||||
|
||||
### Root Cause
|
||||
|
||||
The immediate root cause is failure to obtain a usable injected imToken provider/account. The most likely reason from the screenshot is that the page is running on raw LAN IP `192.168.1.187`, likely over HTTP. imToken may not inject its EIP-1193 provider into non-HTTPS/raw-IP pages, or its iOS in-app browser may not expose a user agent/provider shape that matches the current checks.
|
||||
|
||||
The code currently assumes at least one of these is true:
|
||||
|
||||
- URL contains `?autoLogin=imToken`, or
|
||||
- `navigator.userAgent` matches `/imtoken/i`, and
|
||||
- `window.ethereum` is present and accepted by `getInjectedWallet("imToken")`, and
|
||||
- `eth_accounts` or `eth_requestAccounts` returns a valid `0x...` address.
|
||||
|
||||
The observed behavior shows that this assumption chain is breaking before a valid address is produced.
|
||||
|
||||
### Evidence
|
||||
|
||||
- File: `src/wallet/WalletLoginModal.tsx` — `openWalletAppDirect()` uses `getInjectedWallet(kind)` as the gate for direct injected login; if it is false, the flow navigates/deep-links instead of reading an address.
|
||||
- File: `src/wallet/useWalletConnectLogin.ts` — `start()` sets `state` to `connecting`; the imToken direct local-session path only executes inside `if (mode === "deeplink" && preferredWallet && getInjectedWallet(preferredWallet))`.
|
||||
- File: `src/wallet/AutoInjectedLogin.tsx` — auto-login picks imToken from the query parameter or `isImTokenBrowser()`, then waits for `getInjectedWallet("imToken")` before calling `connectInjectedWallet("imToken")`.
|
||||
- File: `src/wallet/injected.ts` — `getInjectedWallet("imToken")` falls back to generic `window.ethereum` only when `isImTokenBrowser()` is true.
|
||||
- Screenshot evidence: modal shows `state: connecting`, `connected: no`, `address: -`, `qr: -`, and URL `192.168.1.187`.
|
||||
|
||||
## Affected Files
|
||||
|
||||
- `src/wallet/WalletLoginModal.tsx` — user entry point for mobile imToken login.
|
||||
- `src/wallet/AutoInjectedLogin.tsx` — auto-login effect for wallet in-app browsers.
|
||||
- `src/wallet/useWalletConnectLogin.ts` — direct injected login vs WalletConnect fallback selection.
|
||||
- `src/wallet/injected.ts` — provider/account detection and account request logic.
|
||||
- `src/wallet/deepLinks.ts` — imToken in-app browser deeplink target.
|
||||
|
||||
## Suggested Fix
|
||||
|
||||
Do not guess blindly from `connected: no`; first make the runtime state visible. Add a temporary imToken diagnostic surface or alert that reports:
|
||||
|
||||
- `navigator.userAgent`
|
||||
- `location.href`
|
||||
- whether `window.ethereum` exists
|
||||
- whether `window.ethereum.providers` exists and its length
|
||||
- provider flags: `isImToken`, `isMetaMask`, `isTokenPocket`
|
||||
- result/error of `eth_accounts`
|
||||
- result/error of `eth_requestAccounts`
|
||||
- current `isSecureContext`
|
||||
|
||||
Then apply one of these fixes based on the diagnostic result:
|
||||
|
||||
### Fix Strategy
|
||||
|
||||
1. If `window.ethereum` is missing on `192.168.1.187`, test and deploy through an HTTPS domain/tunnel because imToken is likely not injecting on the LAN IP origin.
|
||||
2. If `window.ethereum` exists but `isImTokenBrowser()` is false, broaden imToken detection or allow a user-selected imToken flow to try generic `window.ethereum` before WalletConnect.
|
||||
3. If `eth_accounts` is empty and `eth_requestAccounts` errors, surface that wallet error in the modal instead of leaving `state: connecting`.
|
||||
4. If a valid address is returned, complete imToken login with the local-session path already used by `connectInjectedWallet()` + `localWalletToken()`.
|
||||
5. Ensure the imToken mobile button never silently falls into WalletConnect when the user is already inside imToken and selected imToken; it should either get an address or show a clear injected-provider/account error.
|
||||
|
||||
### Risk Assessment
|
||||
|
||||
- Broadly accepting generic `window.ethereum` could pick the wrong provider in multi-wallet browsers. Mitigation: only do this for explicit user selection of imToken or when already inside imToken.
|
||||
- Testing on LAN IP can produce false negatives. Mitigation: verify on the actual HTTPS domain or an HTTPS tunnel before judging imToken support.
|
||||
- More diagnostics can expose wallet details on screen. Mitigation: keep diagnostics temporary or behind a debug flag.
|
||||
|
||||
## Verification Plan
|
||||
|
||||
1. Test in imToken in-app browser on the production HTTPS domain.
|
||||
2. Test in imToken in-app browser on the current LAN IP to confirm whether `window.ethereum` is missing there.
|
||||
3. Record diagnostic output for `userAgent`, `hasEthereum`, provider flags, `eth_accounts`, and `eth_requestAccounts`.
|
||||
4. From Chrome, tap imToken and confirm the in-app browser receives either `?autoLogin=imToken` or the imToken browser fallback runs.
|
||||
5. Inside imToken, tap wallet login and confirm it does not remain at `state: connecting` without an address.
|
||||
|
||||
## Related Issues
|
||||
|
||||
- `.unipi/docs/fix/2026-06-04-imtoken-injected-provider-detection-fix.md`
|
||||
- `.unipi/docs/fix/2026-06-04-imtoken-restore-local-session-login-fix.md`
|
||||
- `.unipi/docs/fix/2026-06-04-imtoken-auto-login-without-query-fix.md`
|
||||
|
||||
## Notes
|
||||
|
||||
The current screenshot strongly suggests that the problem is not backend verification or local token writing. The flow never reaches an address. The next useful step is to confirm whether imToken is injecting `window.ethereum` on the tested origin.
|
||||
35
.unipi/docs/fix/2026-06-03-category-page-layout-fix.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: "Category page stream layout mismatch — Quick Fix"
|
||||
type: quick-fix
|
||||
date: 2026-06-03
|
||||
---
|
||||
|
||||
# Category page stream layout mismatch — Quick Fix
|
||||
|
||||
## Bug
|
||||
After clicking a card on 资料分类 (`/categories`) and landing on `/category/:slug`, the resource bubbles render with narrower bubbles / different waterfall spacing than the 全部资料 page (`/browse`). Both pages use the same `MessageStream` component, but the page-level wrapper applies extra horizontal padding only to the category route.
|
||||
|
||||
## Root Cause
|
||||
`src/layouts/PublicLayout.tsx` chooses the `<main>` padding using a flag named `footerInContentFlow`, defined as:
|
||||
|
||||
```ts
|
||||
const footerInContentFlow = stripLangPrefix(pathname) === "/browse";
|
||||
```
|
||||
|
||||
That flag selects the `px-0 ... md:px-9 xl:px-0` zero-mobile-padding branch — which is the layout `MessageStream` is designed for (it manages its own inner `max-w` and centers bubbles). All other routes fall through to the default `px-4 min-[440px]:px-5 sm:px-6 md:px-9 ...`, which on mobile inset the stream by 16–24 px and shrunk each bubble's `max-w` proportionally. Because `/category/:slug` rendered the same `MessageStream`, that extra inset is exactly what made the category waterfall look "off" vs `/browse`.
|
||||
|
||||
## Fix
|
||||
Extend the same zero-padding branch to also match `/category/<slug>`, so both routes share the wrapper that `MessageStream` was designed to live in.
|
||||
|
||||
### Files Modified
|
||||
- `src/layouts/PublicLayout.tsx` — renamed the flag's derivation to also include `/category/*`. Kept the existing `BackToTop` and footer-in-content checks (`stripLangPrefix(pathname) === "/browse"`) untouched, since those are separate features the user did not ask to share with category pages.
|
||||
|
||||
## Verification
|
||||
- `npx tsc --noEmit` — clean.
|
||||
- `npm run format:check` — clean.
|
||||
- `npm test` — 49/49 passing.
|
||||
- Visual: opening `/categories` → tapping a category card now lands on a `/category/:slug` view whose `<main>` matches `/browse` (no extra mobile horizontal inset), so bubbles render with the same `max-w-[358px]` width.
|
||||
|
||||
## Notes
|
||||
- The flag is still called `footerInContentFlow` for now even though it only controls padding, matching prior code; renaming would expand the change footprint beyond this fix.
|
||||
- BackToTop and the `footerInContentFlow` footer slot remain `/browse`-only — those are independent of layout width and the user didn't ask to enable them on category pages.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: "FilterChips scroll position lost on filter click (remount) — Quick Fix"
|
||||
type: quick-fix
|
||||
date: 2026-06-03
|
||||
---
|
||||
|
||||
# FilterChips scroll position lost on filter click (remount) — Quick Fix
|
||||
|
||||
## Bug
|
||||
On mobile (and in general), scrolling the `FilterChips` horizontal bar to the right and then clicking a chip caused the bar to snap all the way back to the leftmost position. Earlier attempts to fix this with `useRef` + `useLayoutEffect` save/restore inside `FilterChips` did not work — the bar kept resetting.
|
||||
|
||||
## Root Cause
|
||||
`PublicLayout` wraps the routed page in:
|
||||
|
||||
```tsx
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<m.div key={`${pathname}${search}`} variants={pageTransition} …>
|
||||
{outlet}
|
||||
</m.div>
|
||||
</AnimatePresence>
|
||||
```
|
||||
|
||||
(`src/layouts/PublicLayout.tsx:761`)
|
||||
|
||||
When the user clicks a filter chip, `MessageStream`'s `updateParam("type", v)` mutates the `search` query (e.g. `?type=archive`). That changes the `m.div`'s `key`, so `AnimatePresence` **fully unmounts** the current page and **mounts a fresh one**. `FilterChips` is part of that page tree, so it is destroyed and re-created — every internal `useRef` resets to its initial value, which is why the previous in-component save/restore approach silently failed.
|
||||
|
||||
A secondary symptom (already present before this fix) was an iOS Safari quirk where the sibling `ScrollToTop`'s `window.scrollTo({top:0, left:0})` triggers a relayout of the sticky bar that asynchronously sets the inner `overflow-x` `scrollLeft` back to 0.
|
||||
|
||||
## Fix
|
||||
Move the saved `scrollLeft` to a **module-level** variable so it survives the unmount/remount cycle, and restore it on first paint after every fresh mount. Combine with the existing user-input-only save logic and an iOS post-mount watch window to defeat the asynchronous reset.
|
||||
|
||||
Key points in the new `FilterChips`:
|
||||
|
||||
1. `let lastScrollLeft = 0;` declared at module scope.
|
||||
2. `useLayoutEffect(() => { … }, [])` on mount: if `lastScrollLeft > 0`, restore `scrollLeft` synchronously before paint.
|
||||
3. `useEffect(() => { … }, [])` saves `lastScrollLeft` only on `touchend` / `pointerup` / `wheel` (deferred by one rAF so iOS momentum has settled). We deliberately do **not** save on raw `scroll` events because iOS Safari's quirky 0-reset fires one of those too.
|
||||
4. A second `useEffect(() => { … }, [])` runs a ~1.5 s post-mount watch: rAF tick plus a `scroll` listener that re-applies the saved value **only when `scrollLeft` snaps to 0**, and only when the user is not actively touching the bar (so a fresh scroll gesture in that window isn't yanked back).
|
||||
|
||||
### Files Modified
|
||||
- `src/components/messageStream/FilterChips.tsx`
|
||||
- Added module-level `lastScrollLeft`.
|
||||
- Added `useLayoutEffect` to restore on mount.
|
||||
- Kept the user-input-only save effect.
|
||||
- Kept the ~1.5 s iOS-quirk watch (now keyed to mount instead of `[type]` since the component remounts anyway).
|
||||
|
||||
## Verification
|
||||
- `npx tsc --noEmit` — clean.
|
||||
- `npm run format:check` — clean.
|
||||
- `npm test` — 49/49 passing.
|
||||
- Expected behavior on device: scroll the filter bar to the right, tap any chip (e.g. 压缩包) — bar should stay exactly where the user left it, with the new active chip already highlighted in gold.
|
||||
|
||||
## Notes
|
||||
- Module-level state is intentional and acceptable here: there is only ever one `FilterChips` mounted at a time, and the value semantically belongs to the user's session, not the component instance.
|
||||
- If this `AnimatePresence` `key` strategy changes (or `FilterChips` is reused outside `MessageStream` in a context with multiple instances), revisit this — the module value would then need to be scoped per surface (e.g. via a Map keyed by surface id).
|
||||
- Previous attempts using `useRef` inside `FilterChips` failed because they ignored the remount caused by `AnimatePresence`. That is now documented inline in the component.
|
||||
35
.unipi/docs/fix/2026-06-03-filter-chips-simplify-fix.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: "FilterChips simplify — strip comments and remove 1.5s watch window"
|
||||
type: quick-fix
|
||||
date: 2026-06-03
|
||||
---
|
||||
|
||||
# FilterChips simplify — strip comments and remove 1.5s watch window
|
||||
|
||||
## Bug
|
||||
The previous `FilterChips` carried too much defensive machinery (long inline comments, a 1.5s post-mount `requestAnimationFrame` + `scroll`-event watch window that re-applied `lastScrollLeft` whenever the bar snapped to 0). The watch window could interfere with a fresh slide gesture right after a filter tap, and the surrounding prose made the component hard to read.
|
||||
|
||||
## Root Cause
|
||||
Over-engineering: the iOS-quirk watch loop and its `touching` guard were added defensively but were not strictly needed once `lastScrollLeft` was lifted out to a module-level slot and restored synchronously in `useLayoutEffect`. The extra event listeners were also a source of friction when the user immediately slid the bar after tapping a chip.
|
||||
|
||||
## Fix
|
||||
Reduce `FilterChips` to just the essentials:
|
||||
|
||||
- Module-level `lastScrollLeft` (kept) — survives the `AnimatePresence` remount that happens when `?type=…` changes.
|
||||
- `useLayoutEffect` on mount (kept) — restores `scrollLeft = lastScrollLeft` before paint so the new instance starts where the user left off.
|
||||
- Desktop wheel-to-horizontal handler (kept) — necessary for mice without horizontal wheels.
|
||||
- Save effect on `touchend` / `pointerup` / `wheel` (kept) — captures the user's final scroll position, deferred by one rAF so iOS momentum settles before recording.
|
||||
- Removed: the ~1.5s rAF + `scroll` watch loop and its `touching` flag.
|
||||
- Removed: all explanatory inline comments — the code is short enough to be self-evident now.
|
||||
|
||||
### Files Modified
|
||||
- `src/components/messageStream/FilterChips.tsx` — stripped the 1.5s watch effect and every prose comment; kept the mount-restore and user-input save flow.
|
||||
|
||||
## Verification
|
||||
- `npx tsc --noEmit` — clean.
|
||||
- `npm run format:check` — clean.
|
||||
- `npm test` — 49/49 passing.
|
||||
- Behavior expected on device: tapping a filter highlights it in gold; sliding the bar after the tap is no longer pulled back to the previous position.
|
||||
|
||||
## Notes
|
||||
- If the iOS sticky/scroll-to-top quirk actually re-surfaces in production, the fallback would be to move the bar out of the `AnimatePresence`-keyed subtree (so it never unmounts), rather than re-introducing the watch loop.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: "Mobile menu drawer invisible — Quick Fix"
|
||||
type: quick-fix
|
||||
date: 2026-06-03
|
||||
---
|
||||
|
||||
# Mobile menu drawer invisible — Quick Fix
|
||||
|
||||
## Bug
|
||||
After redesigning the mobile menu to the full-screen Figma drawer (`4164-5336` → `ARK V2 - 導航菜單`), tapping the hamburger toggled the icon to `X` but the drawer overlay never appeared on screen. Page content stayed fully visible and the bottom nav stayed on top.
|
||||
|
||||
## Root Cause
|
||||
The drawer was rendered as a child of `<header className="sticky top-0 z-40 …">`. A `position: sticky` element with a `z-index` creates its own stacking context, which traps the drawer's `position: fixed; z-50` inside that context. Globally, the drawer ends up bound to the header's `z-40` layer, while the unrelated bottom navigation (`<nav className="fixed inset-x-0 bottom-0 z-40 …">`) lives in the root stacking context at `z-40`. With equal global `z`, source order wins — the bottom nav paints later and the drawer never reaches the foreground.
|
||||
|
||||
## Fix
|
||||
Move the drawer JSX out of `<header>` and render it as a sibling at the layout root, so its `fixed`/`z-50` positioning lives in the root stacking context and stacks above both the header and the bottom nav.
|
||||
|
||||
### Files Modified
|
||||
- `src/layouts/PublicLayout.tsx` — relocated the `{open ? (…) : null}` mobile drawer block from inside `<header>` to immediately after `</header>`. Logic unchanged; the `menuRef`, click-outside handler, body scroll lock, and inner nav/CTA structure all keep working because they reference the element by ref/state, not by DOM position.
|
||||
|
||||
## Verification
|
||||
- `npx tsc --noEmit` — clean.
|
||||
- `npm run format` then `npm run format:check` — clean.
|
||||
- `npm test` — 49/49 passing.
|
||||
- Expected on device: tapping the hamburger now reveals the dark full-screen drawer with the 5 nav items, active item in gold, and the bottom `链接钱包` CTA (or the connected-wallet pill).
|
||||
|
||||
## Notes
|
||||
- This is the same class of issue any future fullscreen overlay should avoid: do not nest `position: fixed` overlays inside a `position: sticky + z-index` ancestor. Either render them at the layout root or use a React Portal.
|
||||
- `position: sticky` *without* `z-index` does not create a stacking context, but adding any `z-index` to it does. The header here uses both because it needs to sit above the content while scrolled.
|
||||
30
.unipi/docs/fix/2026-06-03-wallet-icon-figma-fix.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
title: "Wallet CTA — swap lucide outline for Figma filled glyph"
|
||||
type: quick-fix
|
||||
date: 2026-06-03
|
||||
---
|
||||
|
||||
# Wallet CTA — swap lucide outline for Figma filled glyph
|
||||
|
||||
## Bug
|
||||
The 链接钱包 CTA in the mobile drawer (and in the header on desktop while logged out) was using the `Wallet` outline icon from `lucide-react`, which doesn't match the filled wallet glyph in Figma `4414:12829`.
|
||||
|
||||
## Root Cause
|
||||
`WalletButton` imported `lucide-react`'s outline `Wallet` and rendered it with `strokeWidth={2.5}`. Figma's wallet glyph is a solid filled shape with a dot, not an outline.
|
||||
|
||||
## Fix
|
||||
Created a local `WalletIcon` component from the exact Figma 24x24 path. The path uses `fill="currentColor"` so callers control the paint via Tailwind `text-…` utilities (currently `text-black` on the yellow CTA, matching Figma's `#08070C` fill).
|
||||
|
||||
### Files Modified
|
||||
- `src/components/icons/WalletIcon.tsx` (new) — Figma 4414:12829 path as a React SVG component.
|
||||
- `src/wallet/WalletButton.tsx` — drop the `Wallet` import from `lucide-react`, import `WalletIcon`, render it at `h-[18px] w-[18px]` to match the Figma 18x18 inner glyph size inside the 24x24 icon slot.
|
||||
|
||||
## Verification
|
||||
- `npx tsc --noEmit` — clean.
|
||||
- `npm run format` then `npm run format:check` — clean.
|
||||
- `npm test` — 49/49 passing.
|
||||
- Expected visual: yellow `链接钱包` CTA now shows the filled Figma wallet glyph in dark (`text-black` resolves `currentColor`), matching the design.
|
||||
|
||||
## Notes
|
||||
- `currentColor` keeps the icon themable. If a future surface needs the wallet glyph in gold or white, the caller just changes the parent `text-…` utility.
|
||||
- The lucide `Wallet` import was removed from `WalletButton.tsx`; `Heart` stays because the wallet dropdown still uses it for the favorites entry.
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: "Desktop Favorites Header Blank Page — Quick Fix"
|
||||
type: quick-fix
|
||||
date: 2026-06-04
|
||||
---
|
||||
|
||||
# Desktop Favorites Header Blank Page — Quick Fix
|
||||
|
||||
## Bug
|
||||
|
||||
Clicking the desktop header “我的收藏 / My Favorites” button could leave the page visually blank in the local browser. The provided screenshot showed DevTools Elements with an empty `<body>` and no React `#root` node after navigating to `/cn/favorites`.
|
||||
|
||||
## Root Cause
|
||||
|
||||
This was not a z-index overlay issue. The screenshot showed that React had not mounted at all because the current document had no `#root` element. In local Vite/HMR/browser state, client-side React Router navigation could land in a stale or broken document state. The favorites route itself was valid and returned the correct Vite HTML when requested directly.
|
||||
|
||||
There was also a possible same-page navigation edge case: clicking “我的收藏” while already on the favorites route would not necessarily trigger route scroll reset.
|
||||
|
||||
## Fix
|
||||
|
||||
The desktop header favorites button now uses React Router's `reloadDocument` so clicking it performs a full document navigation. This forces the browser/Vite dev server to return a fresh `index.html` with `<div id="root"></div>` instead of relying on a potentially stale client-side navigation state.
|
||||
|
||||
The route scroll reset was also made more robust by disabling browser scroll restoration and running the route scroll reset in `useLayoutEffect`, so a restored scroll position cannot leave the favorites page sitting in blank lower space before paint.
|
||||
|
||||
### Files Modified
|
||||
|
||||
- `src/layouts/PublicLayout.tsx` — added the desktop header favorites button and made it use `reloadDocument` plus top scroll reset.
|
||||
- `src/components/ScrollToTop.tsx` — switched route scroll reset to `useLayoutEffect` and set `history.scrollRestoration = "manual"` while the app is mounted.
|
||||
|
||||
## Verification
|
||||
|
||||
- Ran `npx tsc --noEmit`.
|
||||
- Ran `npm run format:check`.
|
||||
- Used browser native to open `http://192.168.1.187:5173/cn/browse`, confirm the header “我的收藏” button is present, navigate to favorites, and inspect the resulting page.
|
||||
- Verified with browser native eval that `http://192.168.1.187:5173/favorites` has `document.getElementById("root") === true`, title `My Favorites | ARK Library`, and `scrollY === 0`.
|
||||
|
||||
## Notes
|
||||
|
||||
No deploy was performed.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: "Favorite Other-Language Post Redirect — Quick Fix"
|
||||
type: quick-fix
|
||||
date: 2026-06-04
|
||||
---
|
||||
|
||||
# Favorite Other-Language Post Redirect — Quick Fix
|
||||
|
||||
## Bug
|
||||
|
||||
When the user is on a UI language (e.g. Chinese) and clicks a favorited post that does not have a translation in that language, the post page silently redirected to `/browse` and the user could not see the post.
|
||||
|
||||
## Root Cause
|
||||
|
||||
`src/pages/PostRedirect/index.tsx` requested `GET /api/posts/{id}?lang=<ui-lang>`. The backend returns `404` when the post has no translation in the requested language. The redirect's `.catch` silently sent the user to `/browse`, hiding the post entirely.
|
||||
|
||||
## Fix
|
||||
|
||||
`PostRedirect` now retries without the `lang` parameter on failure. If the post exists in any language, the user is taken to the post anyway, and a toast tells them the post is shown in its original language because the selected language is unavailable. If the retry also fails (post truly missing), behavior is unchanged: redirect to `/browse`.
|
||||
|
||||
A new i18n key `postShownInOriginalLanguage` was added in all 7 locales.
|
||||
|
||||
### Files Modified
|
||||
|
||||
- `src/pages/PostRedirect/index.tsx` — added language fallback fetch, toast notice.
|
||||
- `src/locales/zh-CN.ts` — added `postShownInOriginalLanguage`.
|
||||
- `src/locales/en.ts` — added `postShownInOriginalLanguage`.
|
||||
- `src/locales/ja.ts` — added `postShownInOriginalLanguage`.
|
||||
- `src/locales/ko.ts` — added `postShownInOriginalLanguage`.
|
||||
- `src/locales/vi.ts` — added `postShownInOriginalLanguage`.
|
||||
- `src/locales/id.ts` — added `postShownInOriginalLanguage`.
|
||||
- `src/locales/ms.ts` — added `postShownInOriginalLanguage`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `npx tsc --noEmit`
|
||||
- `npm run format:check`
|
||||
- `npm test` (13 files, 49 tests)
|
||||
- Staging curl confirmed: `GET /api/posts/{id}?lang=en` returns `404` for a Chinese-only post, while `GET /api/posts/{id}` returns `200` with the post in its source language.
|
||||
|
||||
## Notes
|
||||
|
||||
No deploy was performed.
|
||||
41
.unipi/docs/fix/2026-06-04-favorites-display-loading-fix.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: "Favorites Display Loading Blank Page — Quick Fix"
|
||||
type: quick-fix
|
||||
date: 2026-06-04
|
||||
---
|
||||
|
||||
# Favorites Display Loading Blank Page — Quick Fix
|
||||
|
||||
## Bug
|
||||
|
||||
When clicking the desktop header “我的收藏” button, the favorites page could briefly show the no-favorites empty state and then appear blank. The correct behavior is to show the user's favorited posts after loading.
|
||||
|
||||
## Root Cause
|
||||
|
||||
Two issues combined:
|
||||
|
||||
1. The favorites page initialized with `loading=false` and `items=[]`. When the wallet was already logged in, React rendered the empty state once before the `useEffect` started the favorites request.
|
||||
2. The desktop header favorites link had been changed to `reloadDocument` as a previous workaround. In the local Vite/dev-browser state this could force a full document reload and land in a broken empty document state instead of keeping the React app mounted.
|
||||
|
||||
## Fix
|
||||
|
||||
- Added an explicit `loaded` state to `src/pages/Favorites/index.tsx`.
|
||||
- The favorites page now shows loading skeletons while logged-in favorites have not completed their first load, so the empty state only appears after a completed request returns zero items.
|
||||
- Added a loading UI for `wallet.status === "loading"` so a persisted wallet token does not briefly show the logged-out prompt.
|
||||
- Removed `reloadDocument` from the desktop header favorites link and kept client-side navigation with a top scroll reset.
|
||||
|
||||
### Files Modified
|
||||
|
||||
- `src/pages/Favorites/index.tsx` — tracks loaded state and gates empty-state rendering until favorites data has loaded.
|
||||
- `src/layouts/PublicLayout.tsx` — removes hard document reload from the desktop header favorites link.
|
||||
|
||||
## Verification
|
||||
|
||||
- `npx tsc --noEmit`
|
||||
- `npm run format:check`
|
||||
- `npm test`
|
||||
- Browser native: opened `http://192.168.1.187:5173/cn/browse`, clicked the desktop header “我的收藏”, and verified the resulting page URL is `/cn/favorites`, `document.getElementById("root")` exists, and `window.scrollY === 0`.
|
||||
|
||||
## Notes
|
||||
|
||||
No deploy was performed.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
title: "imToken in-app browser opens but does not log in — Quick Fix"
|
||||
type: quick-fix
|
||||
date: 2026-06-04
|
||||
---
|
||||
|
||||
# imToken in-app browser opens but does not log in — Quick Fix
|
||||
|
||||
## Bug
|
||||
|
||||
imToken can be opened from Chrome into its in-app browser, but the site does not complete wallet login.
|
||||
|
||||
## Root Cause
|
||||
|
||||
`AutoInjectedLogin` only started when the URL contained `?autoLogin=imToken`. imToken's deeplink/in-app-browser navigation can open the page while dropping or not preserving that query string, so the auto-login effect never ran even though the page was inside imToken and an injected provider was available.
|
||||
|
||||
## Fix
|
||||
|
||||
Added an imToken browser fallback: if no explicit `autoLogin` query parameter exists, but the current user agent is imToken, `AutoInjectedLogin` treats it as an imToken direct-login session and runs the same injected login path.
|
||||
|
||||
### Files Modified
|
||||
|
||||
- `src/wallet/AutoInjectedLogin.tsx` — starts imToken direct login based on imToken in-app-browser detection when the deeplink query is missing.
|
||||
- `src/wallet/injected.ts` — exports `isImTokenBrowser()` so the auto-login flow can reuse the imToken browser detection.
|
||||
|
||||
## Verification
|
||||
|
||||
- `npx tsc --noEmit`
|
||||
- `npm run format:check`
|
||||
- `npm test`
|
||||
|
||||
## Notes
|
||||
|
||||
This preserves the explicit `?autoLogin=` flow for TokenPocket and other wallets, while making imToken robust when the deeplink opens the page without the query parameter.
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: "imToken in-app browser cannot connect after deeplink — Quick Fix"
|
||||
type: quick-fix
|
||||
date: 2026-06-04
|
||||
---
|
||||
|
||||
# imToken in-app browser cannot connect after deeplink — Quick Fix
|
||||
|
||||
## Bug
|
||||
|
||||
After Chrome opens imToken's in-app browser, wallet login cannot complete and the wallet debug panel shows `connected: no`.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The frontend looked for an injected wallet provider using the wallet-specific `isImToken` flag. Some imToken mobile versions inject a usable EIP-1193 `window.ethereum` provider but do not expose `isImToken`, so `getInjectedWallet("imToken")` returned `null`. That prevented the imToken direct-login path from using the injected provider and left the flow disconnected.
|
||||
|
||||
## Fix
|
||||
|
||||
Added a narrow imToken in-app-browser fallback: when the requested wallet is `imToken`, no provider has `isImToken`, but the user agent indicates imToken and `window.ethereum` exists, use the injected provider.
|
||||
|
||||
### Files Modified
|
||||
|
||||
- `src/wallet/injected.ts` — adds imToken user-agent fallback for injected provider detection.
|
||||
|
||||
## Verification
|
||||
|
||||
- `npx tsc --noEmit`
|
||||
- `npm run format:check`
|
||||
- `npm test`
|
||||
|
||||
## Notes
|
||||
|
||||
This fallback is limited to imToken browser user agents to avoid changing MetaMask or TokenPocket provider selection behavior.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: "Restore imToken direct injected login path — Quick Fix"
|
||||
type: quick-fix
|
||||
date: 2026-06-04
|
||||
---
|
||||
|
||||
# Restore imToken direct injected login path — Quick Fix
|
||||
|
||||
## Bug
|
||||
|
||||
imToken could log in on the 2026-06-03 22:00 Malaysia-time-era build, but the current build can no longer log in after opening imToken's in-app browser.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The working 2026-06-03 evening flow used `connectInjectedWallet()` and completed a local frontend session for TokenPocket/imToken direct in-app-browser login. Later changes switched injected direct login to `signInWithInjectedWallet()`, which requires backend nonce + `personal_sign` verification. imToken mobile appears incompatible or unstable with that newer signature-verification path in this flow.
|
||||
|
||||
## Fix
|
||||
|
||||
Restored the old local-session direct injected path for imToken only:
|
||||
|
||||
- imToken `?autoLogin=` in-app-browser flow now uses `connectInjectedWallet()` and `localWalletToken(address)`.
|
||||
- imToken direct injected login from the wallet modal uses the same local-session path.
|
||||
- TokenPocket still uses the newer backend signature verification path.
|
||||
|
||||
### Files Modified
|
||||
|
||||
- `src/wallet/AutoInjectedLogin.tsx` — restores imToken auto-login to the 2026-06-03 direct injected local-session behavior.
|
||||
- `src/wallet/useWalletConnectLogin.ts` — restores imToken injected deeplink login to the local-session behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
- `npx tsc --noEmit`
|
||||
- `npm run format:check`
|
||||
- `npm test`
|
||||
|
||||
## Notes
|
||||
|
||||
This is intentionally scoped to imToken to match the known-working Malaysia 10pm behavior without undoing the TokenPocket signature-verification work.
|
||||
38
.unipi/docs/fix/2026-06-04-language-route-short-codes-fix.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: "Language route prefixes — short ISO codes with legacy redirects"
|
||||
type: quick-fix
|
||||
date: 2026-06-04
|
||||
---
|
||||
|
||||
# Language route prefixes — short ISO codes with legacy redirects
|
||||
|
||||
## Bug
|
||||
Localized URLs used full English names (`/chinese`, `/japanese`, `/korean`, `/vietnamese`, `/indonesian`, `/malay`). Per the broadcast in WeChat (screenshot), they need to be short ISO-style codes (`/cn`, `/ja`, `/ko`, `/vi`, `/id`, `/ms`).
|
||||
|
||||
## Root Cause
|
||||
The mapping is sourced from one place — `src/languageRoutes.ts` `localizedHomeRoutes` — and that array hard-coded the long names.
|
||||
|
||||
## Fix
|
||||
- Rename every localized prefix in `localizedHomeRoutes` to its short code.
|
||||
- Add `legacyLanguageRedirects` (the old → new map) and render client redirects in `App.tsx` so links previously shared (`/chinese`, `/malay/browse?post=42`, etc.) keep landing on the right destination. The redirect preserves sub-path, query string, and hash.
|
||||
- Refresh doc-comment examples (`/malay/...`) in unrelated files so future readers don't get confused.
|
||||
- Update `languageRoutes.test.ts` to assert the new mapping.
|
||||
|
||||
### Files Modified
|
||||
- `src/languageRoutes.ts` — paths swapped to short codes; added `legacyLanguageRedirects`; refreshed doc-comment examples.
|
||||
- `src/languageRoutes.test.ts` — expectations updated to short codes; test description renamed accordingly.
|
||||
- `src/App.tsx` — added `LegacyLangRedirect` component (uses `useParams`/`useLocation`) and rendered `<Route>` pairs (`/old` and `/old/*`) for each entry in `legacyLanguageRedirects`.
|
||||
- `src/i18n.tsx`, `src/components/FigmaBanner.tsx`, `src/layouts/PublicLayout.tsx`, `src/useLocalizedPath.ts` — doc-comment example paths updated for consistency.
|
||||
|
||||
## Verification
|
||||
- `npx tsc --noEmit` — clean.
|
||||
- `npm run format` then `npm run format:check` — clean.
|
||||
- `npm test` — 49/49 passing (includes the updated `languageRoutes.test.ts`).
|
||||
- Expected runtime behavior:
|
||||
- `/cn`, `/ja`, `/ko`, `/vi`, `/id`, `/ms` (and their nested routes) resolve to localized pages.
|
||||
- `/chinese`, `/japanese`, `/korean`, `/vietnamese`, `/indonesian`, `/malay` (and their nested routes) redirect via React Router `<Navigate replace />` to the new short-code equivalents, preserving `?query` and `#hash`.
|
||||
|
||||
## Notes
|
||||
- Server-side considerations: this works for SPA navigation because BrowserRouter handles all paths client-side. Any reverse-proxy/CDN rule that hardcoded the long names should be reviewed (e.g. nginx rewrites, prerender configs). The `nginx.conf` and Gitea deploy workflow only reference `index.html`, so no server-side path rules to update here.
|
||||
- If the SEO sitemap / canonical URLs are generated elsewhere, those should also pick up the new prefixes.
|
||||
- `legacyLanguageRedirects` is intentionally kept distinct from `localizedHomeRoutes` so we can sunset it later by deleting the export and the corresponding routes block in `App.tsx`.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: "Remove wallet address verification popup — Quick Fix"
|
||||
type: quick-fix
|
||||
date: 2026-06-04
|
||||
---
|
||||
|
||||
# Remove wallet address verification popup — Quick Fix
|
||||
|
||||
## Bug
|
||||
|
||||
The in-app-browser wallet address verification popup added friction and interfered with imToken login. The requested behavior is to remove that popup and keep the deeplink login flow direct.
|
||||
|
||||
## Root Cause
|
||||
|
||||
`AutoInjectedLogin` rendered a blocking confirmation dialog for `?autoLogin=` deeplink sessions before calling the injected wallet signature flow. That UI was unnecessary for the current wallet-login flow and could block or confuse imToken users.
|
||||
|
||||
## Fix
|
||||
|
||||
Removed the verification dialog and restored direct deeplink behavior: after the wallet in-app browser injects `window.ethereum`, the app calls `signInWithInjectedWallet()` and completes backend-verified login. Existing logged-in sessions still skip auto-login after stripping the deeplink parameter.
|
||||
|
||||
### Files Modified
|
||||
|
||||
- `src/wallet/AutoInjectedLogin.tsx` — removes the verification popup UI and auto-runs signature login for logged-out deeplink sessions.
|
||||
- `src/wallet/injected.ts` — removes now-unused connected-address helper.
|
||||
- `src/locales/zh-CN.ts` — removes unused verification popup copy.
|
||||
- `src/locales/en.ts` — removes unused verification popup copy.
|
||||
|
||||
## Verification
|
||||
|
||||
- `rg -n "walletVerifyAddress|walletDetectedAddress|getConnectedInjectedAddress|wallet-verify" src || true`
|
||||
- `npx tsc --noEmit`
|
||||
- `npm run format:check`
|
||||
- `npm test`
|
||||
|
||||
## Notes
|
||||
|
||||
The imToken injected-provider fallback remains in place; only the confirmation popup and its supporting copy/helper were removed.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
title: "TokenPocket direct login requires signature verification — Quick Fix"
|
||||
type: quick-fix
|
||||
date: 2026-06-04
|
||||
---
|
||||
|
||||
# TokenPocket direct login requires signature verification — Quick Fix
|
||||
|
||||
## Bug
|
||||
|
||||
Mobile TokenPocket deeplink opened the site inside the wallet browser and completed login immediately after reading the injected wallet address. It did not trigger a password/signature verification step, so users did not get an explicit address verification prompt.
|
||||
|
||||
## Root Cause
|
||||
|
||||
`AutoInjectedLogin` used `connectInjectedWallet()` and then wrote a local frontend wallet token. The injected deeplink path in `useWalletConnectLogin` used the same address-only flow. Both paths skipped the existing backend nonce + `personal_sign` verification flow.
|
||||
|
||||
## Fix
|
||||
|
||||
Changed injected wallet direct login to use `signInWithInjectedWallet()`, which requests a backend nonce, asks the wallet to sign it, verifies the signature with the backend, and stores the verified backend JWT. If injected verification fails, the direct injected path now stops with an error instead of falling back to an unverified WalletConnect/local-token login.
|
||||
|
||||
### Files Modified
|
||||
|
||||
- `src/wallet/AutoInjectedLogin.tsx` — TP/imToken `?autoLogin=` deeplink now requires wallet signature verification before completing login.
|
||||
- `src/wallet/useWalletConnectLogin.ts` — injected deeplink path now uses verified sign-in and does not bypass verification after a signature failure.
|
||||
|
||||
## Verification
|
||||
|
||||
- `npx tsc --noEmit`
|
||||
- `npm run format:check`
|
||||
- `npm test`
|
||||
|
||||
## Notes
|
||||
|
||||
WalletConnect QR fallback still uses the existing local-session behavior; this fix targets the TokenPocket/injected direct-login flow described in the bug report.
|
||||
30
.unipi/docs/fix/2026-06-04-wallet-no-account-message-fix.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
title: "Wallet No Account Message — Quick Fix"
|
||||
type: quick-fix
|
||||
date: 2026-06-04
|
||||
---
|
||||
|
||||
# Wallet No Account Message — Quick Fix
|
||||
|
||||
## Bug
|
||||
When a wallet provider was detected but returned no account, the wallet login UI displayed the raw internal error key `walletNoAccount`.
|
||||
|
||||
## Root Cause
|
||||
`connectInjectedWallet` throws `Error("walletNoAccount")`, but the modal and toast paths rendered `error.message` directly. The locale dictionaries also did not define a friendly `walletNoAccount` message.
|
||||
|
||||
## Fix
|
||||
Translate wallet error keys before rendering them, and add user-facing English and Simplified Chinese text for `walletNoAccount`.
|
||||
|
||||
### Files Modified
|
||||
- `src/wallet/WalletLoginModal.tsx` — translate wallet error messages before showing modal errors.
|
||||
- `src/wallet/WalletProvider.tsx` — translate wallet error messages before showing toast errors.
|
||||
- `src/locales/en.ts` — added English `walletNoAccount` copy.
|
||||
- `src/locales/zh-CN.ts` — added Simplified Chinese `walletNoAccount` copy.
|
||||
|
||||
## Verification
|
||||
- `npx tsc --noEmit`
|
||||
- `npm run format:check`
|
||||
- `npm test`
|
||||
|
||||
## Notes
|
||||
The underlying login behavior is unchanged. This only replaces the raw internal key with a user-friendly explanation to unlock/select a wallet account and retry.
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: "Wallet verification popup blocks already logged-in TP browser — Quick Fix"
|
||||
type: quick-fix
|
||||
date: 2026-06-04
|
||||
---
|
||||
|
||||
# Wallet verification popup blocks already logged-in TP browser — Quick Fix
|
||||
|
||||
## Bug
|
||||
|
||||
After Chrome opens TokenPocket's in-app browser through the wallet deeplink, users who are already logged in inside the TP browser still see the new address verification popup. The popup sits on top of an already-authenticated page and blocks normal use.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The `?autoLogin=` handler in `AutoInjectedLogin` showed the verification prompt as soon as the deeplink parameter existed. It did not wait for `WalletProvider` to finish loading the existing wallet session, and it did not skip the prompt when `status === "loggedIn"`.
|
||||
|
||||
## Fix
|
||||
|
||||
The auto-login handler now waits while wallet status is `loading`. Once the status is known:
|
||||
|
||||
- `loggedIn` strips the deeplink parameter and does not show the verification popup.
|
||||
- `loggedOut` strips the deeplink parameter and shows the manual address verification prompt.
|
||||
|
||||
### Files Modified
|
||||
|
||||
- `src/wallet/AutoInjectedLogin.tsx` — only shows the verification gate for logged-out deeplink sessions; already logged-in TP sessions are not blocked.
|
||||
- `src/wallet/injected.ts` — supports reading a connected injected address without requesting wallet permission.
|
||||
- `src/locales/zh-CN.ts` — verification popup copy.
|
||||
- `src/locales/en.ts` — verification popup copy.
|
||||
|
||||
## Verification
|
||||
|
||||
- `npx tsc --noEmit`
|
||||
- `npm run format:check`
|
||||
- `npm test`
|
||||
|
||||
## Notes
|
||||
|
||||
This preserves the manual verification gate for logged-out Chrome → TokenPocket handoff, while avoiding a blocking popup for users who already have a valid wallet session in TokenPocket's in-app browser.
|
||||
@@ -0,0 +1,556 @@
|
||||
---
|
||||
title: "钱包登录与收藏功能 UI 设计需求(极简版)"
|
||||
type: design-brief
|
||||
date: 2026-06-01
|
||||
scope: 登录弹窗、钱包入口、收藏按钮、我的收藏页面
|
||||
---
|
||||
|
||||
# 钱包登录与收藏功能 UI 设计需求(极简版)
|
||||
|
||||
## 1. 设计目标
|
||||
|
||||
这份文档给 UI 设计师使用,目的是重新设计 Arkie Library 的「钱包登录」和「我的收藏」体验。
|
||||
|
||||
核心原则:**不要给用户太多选择。**
|
||||
|
||||
用户只需要理解:
|
||||
|
||||
1. 连接钱包。
|
||||
2. 签名验证地址。
|
||||
3. 收藏资源。
|
||||
4. 在「我的收藏」里管理收藏。
|
||||
|
||||
钱包登录只用于验证地址:
|
||||
|
||||
- 不会发起交易。
|
||||
- 不会产生 gas。
|
||||
- 不会读取资产。
|
||||
- 不需要切换链。
|
||||
|
||||
## 2. 最重要的设计决策
|
||||
|
||||
### 桌面端只显示「浏览器钱包」
|
||||
|
||||
桌面端登录弹窗只需要一个主要操作:
|
||||
|
||||
```text
|
||||
使用浏览器钱包登录
|
||||
```
|
||||
|
||||
适用:
|
||||
|
||||
- MetaMask 浏览器插件
|
||||
- 其他浏览器注入钱包
|
||||
|
||||
原因:
|
||||
|
||||
- 电脑端用户主要使用浏览器插件钱包。
|
||||
- 不要在桌面端同时展示 TokenPocket、MetaMask、imToken、扫码备用等多个入口。
|
||||
- 过多选择会让用户觉得重复和困惑。
|
||||
|
||||
### 手机端显示「打开钱包 App」
|
||||
|
||||
手机端可以跳转钱包 App,因此手机端可以显示:
|
||||
|
||||
```text
|
||||
打开 TokenPocket
|
||||
打开 MetaMask
|
||||
打开 imToken
|
||||
```
|
||||
|
||||
如果用户已经在钱包内置浏览器中打开网站,则显示:
|
||||
|
||||
```text
|
||||
使用当前钱包登录
|
||||
```
|
||||
|
||||
### QR / Reown 不作为主设计
|
||||
|
||||
TokenPocket QR、Reown / WalletConnect QR 可以作为技术备用方案存在,但**不要作为默认主 UI 平铺展示**。
|
||||
|
||||
如果必须保留,可以放在:
|
||||
|
||||
```text
|
||||
其他登录方式
|
||||
```
|
||||
|
||||
或折叠项中。
|
||||
|
||||
默认设计不要同时展示:
|
||||
|
||||
- 浏览器钱包
|
||||
- TokenPocket QR
|
||||
- 打开 TokenPocket
|
||||
- 打开 MetaMask
|
||||
- 打开 imToken
|
||||
- MetaMask / imToken QR 备用
|
||||
|
||||
这样会显得重复。
|
||||
|
||||
## 3. 需要设计的页面/组件
|
||||
|
||||
1. Header 钱包入口
|
||||
2. Mobile menu 钱包入口
|
||||
3. 钱包登录弹窗:桌面版
|
||||
4. 钱包登录弹窗:手机版
|
||||
5. 收藏按钮
|
||||
6. 资源卡片上的收藏按钮位置
|
||||
7. 我的收藏页面:未登录状态
|
||||
8. 我的收藏页面:已登录列表状态
|
||||
9. 我的收藏页面:空状态
|
||||
10. 我的收藏页面:加载状态
|
||||
11. 我的收藏页面:错误状态
|
||||
12. 我的收藏页面:资源不可用状态
|
||||
|
||||
## 4. Header 钱包入口
|
||||
|
||||
### 未登录
|
||||
|
||||
显示:
|
||||
|
||||
```text
|
||||
Connect Wallet
|
||||
连接钱包
|
||||
```
|
||||
|
||||
桌面端:
|
||||
|
||||
- 放在 Header 右侧。
|
||||
- 是一个清楚的主按钮。
|
||||
- 不需要在 Header 展示钱包品牌。
|
||||
|
||||
移动端:
|
||||
|
||||
- 放在 menu 中。
|
||||
- 点击后关闭 menu,再打开登录弹窗。
|
||||
|
||||
### 已登录
|
||||
|
||||
显示短地址:
|
||||
|
||||
```text
|
||||
0x12...ab34
|
||||
```
|
||||
|
||||
点击后显示 dropdown:
|
||||
|
||||
- 完整钱包地址
|
||||
- Disconnect / 断开连接
|
||||
|
||||
## 5. 钱包登录弹窗:桌面版
|
||||
|
||||
### 5.1 桌面版目标
|
||||
|
||||
桌面版只服务一个主要场景:
|
||||
|
||||
> 用户用浏览器插件钱包登录。
|
||||
|
||||
### 5.2 桌面版结构
|
||||
|
||||
建议结构:
|
||||
|
||||
1. 标题:连接钱包
|
||||
2. 简短说明:签名仅用于验证地址,不会产生交易或 gas
|
||||
3. 一个主按钮:使用浏览器钱包登录
|
||||
4. 辅助说明:请确认浏览器已安装钱包插件
|
||||
5. 关闭按钮
|
||||
|
||||
### 5.3 桌面版不要展示
|
||||
|
||||
默认不要展示:
|
||||
|
||||
- TokenPocket QR 登录
|
||||
- Open TokenPocket
|
||||
- Open MetaMask
|
||||
- Open imToken
|
||||
- MetaMask / imToken QR fallback
|
||||
- WalletConnect / Reown 说明
|
||||
|
||||
这些对桌面用户来说会造成选择过多。
|
||||
|
||||
### 5.4 桌面版文案建议
|
||||
|
||||
标题:
|
||||
|
||||
```text
|
||||
连接钱包
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
```text
|
||||
签名验证钱包地址,不会发起交易,也不需要 Gas。
|
||||
```
|
||||
|
||||
按钮:
|
||||
|
||||
```text
|
||||
使用浏览器钱包登录
|
||||
```
|
||||
|
||||
辅助说明:
|
||||
|
||||
```text
|
||||
请使用已安装钱包插件的浏览器,例如 MetaMask。
|
||||
```
|
||||
|
||||
## 6. 钱包登录弹窗:手机版
|
||||
|
||||
### 6.1 手机版目标
|
||||
|
||||
手机版主要服务两个场景:
|
||||
|
||||
1. 用户在普通手机浏览器打开网站,需要跳转钱包 App。
|
||||
2. 用户已经在钱包内置浏览器打开网站,可以直接使用当前钱包。
|
||||
|
||||
### 6.2 手机版结构
|
||||
|
||||
建议结构:
|
||||
|
||||
1. 标题:连接钱包
|
||||
2. 简短说明:签名仅用于验证地址,不会产生交易或 gas
|
||||
3. 如果检测到当前浏览器已有钱包:显示「使用当前钱包登录」
|
||||
4. 否则显示「选择钱包 App 打开」
|
||||
5. 钱包 App 按钮列表
|
||||
6. 关闭按钮
|
||||
|
||||
### 6.3 手机版钱包按钮
|
||||
|
||||
显示三个按钮:
|
||||
|
||||
- TokenPocket
|
||||
- MetaMask
|
||||
- imToken
|
||||
|
||||
设计建议:
|
||||
|
||||
- 使用列表或大按钮。
|
||||
- 每个按钮只展示钱包名称和图标。
|
||||
- 不需要额外解释每个钱包的技术路径。
|
||||
|
||||
### 6.4 手机版文案建议
|
||||
|
||||
标题:
|
||||
|
||||
```text
|
||||
连接钱包
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
```text
|
||||
请在钱包 App 中打开本站并签名登录。
|
||||
```
|
||||
|
||||
当前钱包按钮:
|
||||
|
||||
```text
|
||||
使用当前钱包登录
|
||||
```
|
||||
|
||||
钱包 App 分组标题:
|
||||
|
||||
```text
|
||||
打开钱包 App
|
||||
```
|
||||
|
||||
按钮:
|
||||
|
||||
```text
|
||||
TokenPocket
|
||||
MetaMask
|
||||
imToken
|
||||
```
|
||||
|
||||
## 7. QR / 备用方式处理
|
||||
|
||||
如果产品仍希望保留 QR 备用能力,设计上应弱化处理。
|
||||
|
||||
建议:
|
||||
|
||||
- 不默认展开。
|
||||
- 放在底部小字链接:`其他登录方式`。
|
||||
- 点击后才显示 QR / WalletConnect 相关内容。
|
||||
|
||||
但第一版 UI redesign 可以不设计 QR 主流程。
|
||||
|
||||
如果必须设计,注意:
|
||||
|
||||
- TokenPocket QR 是中国用户较稳定路径。
|
||||
- MetaMask / imToken QR 依赖 Reown / WalletConnect,在部分中国网络可能不稳定。
|
||||
- 这些说明不应占据主弹窗视觉中心。
|
||||
|
||||
## 8. 收藏按钮设计
|
||||
|
||||
### 状态
|
||||
|
||||
收藏按钮需要这些状态:
|
||||
|
||||
1. 未收藏
|
||||
2. 已收藏
|
||||
3. 加载中
|
||||
4. 禁用/请求中
|
||||
|
||||
### 视觉建议
|
||||
|
||||
未收藏:
|
||||
|
||||
- 空心心形
|
||||
- 低对比背景
|
||||
|
||||
已收藏:
|
||||
|
||||
- 实心心形
|
||||
- 品牌金色
|
||||
|
||||
加载中:
|
||||
|
||||
- spinner 或轻量 loading
|
||||
|
||||
### 行为
|
||||
|
||||
未登录用户点击收藏:
|
||||
|
||||
- 打开钱包登录弹窗。
|
||||
- 登录成功后自动完成收藏。
|
||||
|
||||
已登录用户点击收藏:
|
||||
|
||||
- 立即反馈状态变化。
|
||||
- 失败时恢复原状态并提示。
|
||||
|
||||
### 摆放要求
|
||||
|
||||
收藏按钮会出现在:
|
||||
|
||||
- 推荐资源卡片
|
||||
- 最新资源卡片
|
||||
- 热门列表
|
||||
- 资源内容流
|
||||
- 我的收藏页面卡片
|
||||
|
||||
设计上需要避免:
|
||||
|
||||
- 挡住主要内容。
|
||||
- 和下载/预览按钮混淆。
|
||||
- 点击收藏时误触进入详情页。
|
||||
|
||||
## 9. 我的收藏页面
|
||||
|
||||
页面路径:
|
||||
|
||||
```text
|
||||
/favorites
|
||||
```
|
||||
|
||||
### 9.1 未登录状态
|
||||
|
||||
用户未连接钱包时,页面显示引导。
|
||||
|
||||
需要包含:
|
||||
|
||||
- 收藏图标或插画
|
||||
- 标题:我的收藏 / My Favorites
|
||||
- 说明:连接钱包后可以查看和管理收藏资源
|
||||
- 主按钮:Connect Wallet / 连接钱包
|
||||
|
||||
### 9.2 已登录状态
|
||||
|
||||
页面需要包含:
|
||||
|
||||
1. 页面标题
|
||||
2. 搜索框
|
||||
3. 排序
|
||||
4. 分类筛选
|
||||
5. 收藏资源列表
|
||||
6. 分页
|
||||
7. 清除筛选按钮
|
||||
|
||||
### 9.3 搜索/筛选区
|
||||
|
||||
支持:
|
||||
|
||||
- 搜索收藏内容
|
||||
- 按分类筛选
|
||||
- 排序
|
||||
|
||||
排序选项:
|
||||
|
||||
- 最近收藏
|
||||
- 最近发布
|
||||
- 热门
|
||||
|
||||
桌面端:
|
||||
|
||||
- 搜索、排序、分类可以一行展示。
|
||||
|
||||
移动端:
|
||||
|
||||
- 纵向堆叠。
|
||||
- 不要太密。
|
||||
|
||||
### 9.4 收藏资源卡片
|
||||
|
||||
每个收藏资源卡片建议展示:
|
||||
|
||||
- 封面图
|
||||
- 标题
|
||||
- 简短描述
|
||||
- 分类
|
||||
- 类型
|
||||
- 更新时间
|
||||
- 收藏数
|
||||
- 收藏按钮
|
||||
|
||||
点击行为:
|
||||
|
||||
- 可用资源:点击卡片进入详情。
|
||||
- 不可用资源:不能进入详情,但可以移除收藏。
|
||||
|
||||
### 9.5 不可用资源状态
|
||||
|
||||
用户收藏过的资源可能之后被下架或隐藏。
|
||||
|
||||
这种资源仍然要显示在收藏列表里。
|
||||
|
||||
设计要求:
|
||||
|
||||
- 显示 unavailable / 不可用标签。
|
||||
- 降低视觉权重。
|
||||
- 不显示可点击详情行为。
|
||||
- 保留移除收藏按钮。
|
||||
|
||||
### 9.6 空状态
|
||||
|
||||
空状态包括:
|
||||
|
||||
1. 用户还没有收藏。
|
||||
2. 搜索/筛选没有结果。
|
||||
|
||||
需要显示:
|
||||
|
||||
- 空状态图标
|
||||
- 简短说明
|
||||
- 如果是筛选无结果,需要提供清除筛选入口
|
||||
|
||||
### 9.7 加载状态
|
||||
|
||||
需要设计:
|
||||
|
||||
- skeleton card
|
||||
- 或列表 loading placeholder
|
||||
|
||||
要求:
|
||||
|
||||
- 不要让布局大幅跳动。
|
||||
|
||||
### 9.8 错误状态
|
||||
|
||||
需要设计:
|
||||
|
||||
- 加载失败提示
|
||||
- 重试或刷新建议
|
||||
|
||||
## 10. 响应式要求
|
||||
|
||||
### Desktop
|
||||
|
||||
- Header 显示完整导航。
|
||||
- 钱包入口在右侧。
|
||||
- 登录弹窗只显示浏览器钱包登录。
|
||||
- 我的收藏页面内容宽度适中。
|
||||
- 搜索/筛选尽量横向排列。
|
||||
|
||||
### Mobile
|
||||
|
||||
- Header 使用 menu。
|
||||
- 钱包入口在 menu 中。
|
||||
- 登录弹窗显示当前钱包登录或钱包 App 跳转。
|
||||
- 我的收藏页面单列展示。
|
||||
- 搜索/筛选纵向排列。
|
||||
- 收藏按钮容易点击。
|
||||
|
||||
## 11. 视觉方向
|
||||
|
||||
当前网站视觉基调:
|
||||
|
||||
- 深色背景
|
||||
- 金色品牌色
|
||||
- 圆角卡片
|
||||
- 半透明/轻玻璃质感
|
||||
- 移动端偏 App 化体验
|
||||
|
||||
UI redesign 可以优化:
|
||||
|
||||
- 登录弹窗更简单
|
||||
- 桌面端只给一个主操作
|
||||
- 手机端强调打开钱包 App
|
||||
- 收藏按钮更清楚
|
||||
- 我的收藏页面筛选区更轻量
|
||||
- 空状态更友好
|
||||
|
||||
## 12. 多语言注意事项
|
||||
|
||||
UI 需要支持:
|
||||
|
||||
- 繁体中文
|
||||
- 简体中文
|
||||
- 英文
|
||||
- 韩文
|
||||
- 日文
|
||||
- 越南文
|
||||
- 印尼文
|
||||
- 马来文
|
||||
|
||||
设计时需要预留文字长度差异。
|
||||
|
||||
尤其注意:
|
||||
|
||||
- 英文按钮可能较长。
|
||||
- 越南文/印尼文/马来文文本可能比中文长。
|
||||
- 移动端按钮不要因为文本过长而溢出。
|
||||
|
||||
## 13. 设计验收清单
|
||||
|
||||
### 钱包登录
|
||||
|
||||
- [ ] 桌面 Header 有 Connect Wallet。
|
||||
- [ ] 手机 menu 有 Connect Wallet。
|
||||
- [ ] 桌面登录弹窗只有一个主操作:使用浏览器钱包登录。
|
||||
- [ ] 手机登录弹窗可以打开 TokenPocket / MetaMask / imToken。
|
||||
- [ ] 签名无交易、无 gas 的说明清楚。
|
||||
- [ ] 已登录状态能显示短地址。
|
||||
- [ ] 用户可以断开连接。
|
||||
|
||||
### 收藏功能
|
||||
|
||||
- [ ] 收藏按钮状态清楚。
|
||||
- [ ] 未收藏和已收藏容易区分。
|
||||
- [ ] loading 状态明确。
|
||||
- [ ] 收藏按钮不会和卡片点击冲突。
|
||||
- [ ] 未登录点击收藏会引导连接钱包。
|
||||
|
||||
### 我的收藏页面
|
||||
|
||||
- [ ] 未登录状态有明确 CTA。
|
||||
- [ ] 已登录页面有搜索、排序、分类筛选。
|
||||
- [ ] 收藏资源卡片信息足够。
|
||||
- [ ] 不可用资源状态清楚且可移除。
|
||||
- [ ] 空状态、加载状态、错误状态完整。
|
||||
- [ ] Desktop 和 mobile 都有设计稿。
|
||||
|
||||
## 14. 设计交付建议
|
||||
|
||||
建议 UI 交付这些画面:
|
||||
|
||||
1. Desktop Header:未登录
|
||||
2. Desktop Header:已登录 dropdown
|
||||
3. Mobile menu:未登录
|
||||
4. Desktop wallet modal:只显示浏览器钱包登录
|
||||
5. Mobile wallet modal:打开钱包 App
|
||||
6. Favorites page:未登录状态
|
||||
7. Favorites page:已登录有列表
|
||||
8. Favorites page:空状态
|
||||
9. Favorites page:不可用资源卡片
|
||||
10. Mobile Favorites page
|
||||
11. Favorite button 状态组件
|
||||
@@ -0,0 +1,210 @@
|
||||
---
|
||||
title: "Telegram-style Resource Stream — Implementation Plan"
|
||||
type: plan
|
||||
date: 2026-05-25
|
||||
workbranch: feat/telegram-stream
|
||||
specs:
|
||||
- .unipi/docs/specs/2026-05-25-telegram-style-resource-stream-design.md
|
||||
---
|
||||
|
||||
# Telegram-style Resource Stream — Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
实现 `/browse` 与 `/category/:slug` 的 Telegram-style 消息流重构。前端先用 mock data 完成全部视觉与交互,等后端 `/api/posts` 系列接口 ready 后切换。同时收尾删除收藏功能与 `ResourceDetail` 详情页。
|
||||
|
||||
分支:`feat/telegram-stream`(在当前目录新建,不走 worktree)。完成后由 Terry 显式确认才 merge。
|
||||
|
||||
## Sequencing
|
||||
|
||||
```
|
||||
Task 1 (基础类型 + mock + utils)
|
||||
↓
|
||||
Task 2 (hooks) ─────────────────┐
|
||||
│
|
||||
Task 3 (overlays) ─────┐ │
|
||||
↓ │
|
||||
Task 4 (bubbles)
|
||||
↓
|
||||
Task 5 (stream 容器)
|
||||
↓
|
||||
Task 6 (页面改写)
|
||||
|
||||
Task 7 (清理收藏 / 详情页) ── 可与 1-5 并行,但合并到 Task 6 之前完成
|
||||
Task 8 (验证 + 文档 + API 契约) ── 最后
|
||||
```
|
||||
|
||||
依赖关键路径:1 → 2/3 → 4 → 5 → 6 → 8。Task 7 独立,建议早做以减少 imports 残留。
|
||||
|
||||
## Tasks
|
||||
|
||||
- unstarted: Task 0 — 创建分支
|
||||
- Description: 在当前目录创建并切到 `feat/telegram-stream` 分支
|
||||
- Dependencies: 无
|
||||
- Acceptance Criteria: `git branch --show-current` 输出 `feat/telegram-stream`;`git status` 干净
|
||||
- Steps:
|
||||
1. `git status --short --branch` 确认无未提交改动
|
||||
2. `git checkout -b feat/telegram-stream`
|
||||
3. 确认 `.unipi/docs/specs/2026-05-25-telegram-style-resource-stream-design.md` 与 `.unipi/docs/plans/2026-05-25-telegram-style-resource-stream-plan.md` 已在该分支
|
||||
|
||||
- unstarted: Task 1 — 类型定义 + Mock data + 纯函数 utils
|
||||
- Description: 建立 Post / Attachment 类型,写覆盖 7 种 bubble 的 mock 样本,写 3 个无依赖纯函数 util
|
||||
- Dependencies: Task 0
|
||||
- Acceptance Criteria:
|
||||
- `src/types/post.ts` 导出 `Post`、`Attachment`、`PostListResponse`、`PostScope`
|
||||
- `src/mocks/mockPosts.ts` 至少 12 条 Post 覆盖:2 图片当文档 + 2 PDF/AI + 2 纯文本+链接 + 1 视频 + 2 单图 + 1 图+文 + 1 三图相册 + 1 七图相册;publishedAt 跨 ≥3 个不同日期
|
||||
- `formatBytes(3549239)` → `"3.4 MB"`(unit tests pass)
|
||||
- `autolink("点 https://x.com 看")` 返回 React 节点数组,链接处为 `<a target="_blank" rel="noopener noreferrer">`(unit tests pass)
|
||||
- `fileIcon({ mime, filename })` 返回 `{ Icon, color }`,PDF 红、AI 橙、PPT 红、图片走 thumbnail 不返回 Icon
|
||||
- `npx tsc --noEmit` 通过
|
||||
- Steps:
|
||||
1. 写 `src/types/post.ts`
|
||||
2. 写 `src/components/messageStream/utils/formatBytes.ts` + `.test.ts`
|
||||
3. 写 `src/components/messageStream/utils/autolink.tsx` + `.test.tsx`
|
||||
4. 写 `src/components/messageStream/utils/fileIcon.ts`
|
||||
5. 写 `src/mocks/mockPosts.ts`(图片用 picsum.photos 占位,视频用公开 sample mp4 + 一张占位 poster)
|
||||
6. 跑 `npm test` 与 `npx tsc --noEmit`
|
||||
|
||||
- unstarted: Task 2 — Stream hooks(usePostStream + useGroupedByDay)
|
||||
- Description: 数据层抽象,mock/real 双模式 + 按日期分组
|
||||
- Dependencies: Task 1
|
||||
- Acceptance Criteria:
|
||||
- `usePostStream({ scope, type, language })` 在 `VITE_USE_MOCK_POSTS !== "false"` 时从 `MOCK_POSTS` 过滤 + 倒序 + cursor 切片(每页 20)+ 200ms 假延迟
|
||||
- 真接口分支调用 `getJSON</api/posts?...>`(占位即可,后端未 ready 时不会走到)
|
||||
- 返回 `{ items, isLoading, error, hasMore, loadMore, reset }`
|
||||
- `useGroupedByDay(posts, lang)` 返回 `Array<{ dayLabel: string; items: Post[] }>`,按本地时区日期分组,dayLabel 通过 `Intl.DateTimeFormat` 按 lang 切换(zh-TW/zh-CN/en)
|
||||
- 单元测试:useGroupedByDay 在跨天数据上分出正确组数
|
||||
- Steps:
|
||||
1. 写 `src/components/messageStream/hooks/usePostStream.ts`
|
||||
2. 写 `src/components/messageStream/hooks/useGroupedByDay.ts` + `.test.ts`
|
||||
3. 在 `.env.example` 加 `VITE_USE_MOCK_POSTS=true` 注释
|
||||
4. 跑 `npm test` 与 `npx tsc --noEmit`
|
||||
|
||||
- unstarted: Task 3 — Overlay 基础设施(ImageLightbox + VideoPlayer)
|
||||
- Description: 全屏画廊与视频播放器,portal 渲染,单一入口 context
|
||||
- Dependencies: Task 1
|
||||
- Acceptance Criteria:
|
||||
- `<ImageLightboxProvider>` 包在 App 根,暴露 `useLightbox()` → `openLightbox(images, startIndex)`
|
||||
- `ImageLightbox` 支持:左右切换(按钮 + 键盘 ← → + 触屏左右滑)、ESC/点遮罩关闭、右上角下载按钮
|
||||
- `VideoPlayer` 支持:全屏遮罩 + `<video controls autoPlay>`、接 `currentTime` 参数避免重新加载、ESC/点遮罩关闭
|
||||
- 两个 overlay 在手机端不溢出、不被底部 nav 遮挡
|
||||
- `npx tsc --noEmit` 通过
|
||||
- Steps:
|
||||
1. 写 `src/components/messageStream/overlays/ImageLightbox.tsx` + Provider/Context
|
||||
2. 写 `src/components/messageStream/overlays/VideoPlayer.tsx`
|
||||
3. 在 `App.tsx` / 根布局挂 Provider
|
||||
4. 手动验证:在 dev console 临时调用 `openLightbox` 看是否正确呈现
|
||||
|
||||
- unstarted: Task 4 — Bubble 子组件(6 个 + 分发器)
|
||||
- Description: 按截图实现 6 种气泡 + `MessageBubble` 分发
|
||||
- Dependencies: Task 1, Task 3
|
||||
- Acceptance Criteria:
|
||||
- `FileDocBubble` 处理 `kind: "document"`:
|
||||
- 若 `mime.startsWith("image/")` → 左侧用 `thumbnailUrl` 缩略 + ↓ 覆盖;右侧 filename.ext + size(截图 1)
|
||||
- 否则 → 左侧蓝圆 ↓ 图标(按 mime 取色)+ 右侧 filename + size(截图 2)
|
||||
- `TextBubble` 渲染 `text`,调 `autolink`(截图 3)
|
||||
- `VideoBubble` 初始显示 posterUrl + ▶️ + 时长,第一次点 inline `<video controls autoPlay>`,第二次点(已播放)调 `openVideoPlayer`(截图 4)
|
||||
- `ImageBubble` 单张图,点击调 `openLightbox([att], 0)`(截图 5)
|
||||
- `ImageWithTextBubble` 单图 + 下方文本(autolink)(截图 6)
|
||||
- `AlbumBubble` 2-4 格 grid,间距 2px;attachments.length > 4 时第 4 格 `bg-black/45 backdrop-blur-sm` 覆盖 `+N`;点任一格调 `openLightbox(images, index)`(截图 7)
|
||||
- `MessageBubble` 实现 spec §4 的 `pickBubble` 分发,右下角绝对定位时间戳 `text-[11px] text-neutral-500`
|
||||
- 所有 bubble 容器:`rounded-2xl bg-ark-panel p-3`(文本气泡 `px-4 py-2.5`),左对齐,无头像
|
||||
- `npx tsc --noEmit` 通过
|
||||
- Steps:
|
||||
1. 写 `MessageBubble.tsx`(含 pickBubble)
|
||||
2. 写 `bubbles/TextBubble.tsx`
|
||||
3. 写 `bubbles/FileDocBubble.tsx`
|
||||
4. 写 `bubbles/ImageBubble.tsx`
|
||||
5. 写 `bubbles/ImageWithTextBubble.tsx`
|
||||
6. 写 `bubbles/AlbumBubble.tsx`
|
||||
7. 写 `bubbles/VideoBubble.tsx`
|
||||
8. 在 dev 中临时挂一个 demo route 跑 MOCK_POSTS 全量渲染,目视检查 7 张截图对照
|
||||
|
||||
- unstarted: Task 5 — Stream 容器 + FilterChips + DaySeparator
|
||||
- Description: 顶层组件接管 fetch、分组、无限滚动、sticky 筛选
|
||||
- Dependencies: Task 2, Task 4
|
||||
- Acceptance Criteria:
|
||||
- `FilterChips`:sticky top,横向滚动 `overflow-x-auto whitespace-nowrap`,类型 chips(all/image/video/ppt/pdf/text/link/archive,沿用 `typeFilterLabel`)+ 语言 chips(all/zh-TW/zh-CN/en),改变时 reset cursor 并同步 URL `?type=&language=`
|
||||
- `DaySeparator`:胶囊样式,居中
|
||||
- `MessageStream`:
|
||||
- 接 `scope: { kind: "all" } | { kind: "category", slug: string }`
|
||||
- 调用 `usePostStream` + `useGroupedByDay`
|
||||
- 用 `IntersectionObserver` 监听底部 sentinel,触发 `loadMore`(loadingRef 守护避免重复)
|
||||
- 容器:手机 `max-w-full px-3 mx-auto`,md+ `max-w-[640px] mx-auto`
|
||||
- 空状态用 `t("noResults")`,错误用红色 inline 横幅 + 重试按钮
|
||||
- `npx tsc --noEmit` 通过
|
||||
- Steps:
|
||||
1. 写 `FilterChips.tsx`
|
||||
2. 写 `DaySeparator.tsx`
|
||||
3. 写 `MessageStream.tsx`
|
||||
4. 在 dev demo route 挂 `<MessageStream scope={{ kind: "all" }} />` 验证滚动 + 筛选 + 分组
|
||||
|
||||
- unstarted: Task 6 — 重写 CategoryPage 与 Browse
|
||||
- Description: 两个页面瘦身为单一组件调用
|
||||
- Dependencies: Task 5, Task 7
|
||||
- Acceptance Criteria:
|
||||
- `src/pages/CategoryPage.tsx`:仅渲染分类标题 + `<MessageStream scope={{ kind: "category", slug }} />`,行数 < 30
|
||||
- `src/pages/Browse.tsx`:仅渲染页面标题 + `<MessageStream scope={{ kind: "all" }} />`,行数 < 30;不再读 `sort` / `tag` / `page` 参数
|
||||
- 排序 tabs 全部去掉
|
||||
- 移除对 `ResourceCard` / `ResourceListFooter` 的 import
|
||||
- `App.tsx` 路由保持 `/browse` 与 `/category/:slug` 不变
|
||||
- `npx tsc --noEmit` 通过
|
||||
- Steps:
|
||||
1. 改写 `CategoryPage.tsx`
|
||||
2. 改写 `Browse.tsx`
|
||||
3. 跑 dev server 在 `/browse` 与 `/category/<slug>` 验证
|
||||
|
||||
- unstarted: Task 7 — 移除收藏功能 + ResourceDetail
|
||||
- Description: 整套清理
|
||||
- Dependencies: Task 0(可与 Task 1-5 并行)
|
||||
- Acceptance Criteria:
|
||||
- 删除文件:`src/pages/FavoritesPage.tsx`、`src/pages/ResourceDetail.tsx`、`src/components/ResourceCard.tsx`、`src/components/ResourceListFooter.tsx`
|
||||
- `App.tsx` 移除 `/favorites` 路由;`/r/:id` 改为新组件 `PostRedirect`(mock 模式下从 `MOCK_POSTS` 找 slug;找不到 → navigate `/browse`)
|
||||
- `src/api.ts` 移除 `postFavoriteDelta`
|
||||
- 全代码无 `postFavoriteDelta` / `FavoritesPage` / `ResourceDetail` / `ResourceCard` / `ResourceListFooter` 引用
|
||||
- Home 中的 `/favorites` 入口(如有)移除
|
||||
- `src/i18n.tsx` 移除 `favorites` / `addFavorite` / `removeFavorite` 等收藏 key(三语言同步)
|
||||
- `npx tsc --noEmit` 通过(无 unused / 未引用错误)
|
||||
- Steps:
|
||||
1. `grep -rn "postFavoriteDelta\|FavoritesPage\|ResourceDetail\|ResourceCard\|ResourceListFooter\|/favorites\|/r/:id" src/` 列出所有引用点
|
||||
2. 写 `src/pages/PostRedirect.tsx`,挂到 `/r/:id` 路由
|
||||
3. 删除 4 个文件 + 修改 `App.tsx` + `api.ts` + `i18n.tsx`
|
||||
4. 再 grep 一次确认清零
|
||||
5. `npx tsc --noEmit`
|
||||
|
||||
- unstarted: Task 8 — 验证 + 文档 + API 契约抽出
|
||||
- Description: 全量验证 + 给后端的接口文档
|
||||
- Dependencies: Task 6, Task 7
|
||||
- Acceptance Criteria:
|
||||
- `npx tsc --noEmit` 通过
|
||||
- `npm run format:check` 通过(若不通过先 `npm run format`)
|
||||
- `npm test` 全绿
|
||||
- `npm run build` 成功
|
||||
- 手动视觉验证:Chrome DevTools iPhone 14 Pro 视口逐一对照 7 张参考截图
|
||||
- 新增 `.unipi/docs/specs/2026-05-25-posts-api-contract.md`:从主 spec §1-§2 抽出后端需要的所有内容(Post/Attachment schema、6 个 endpoint、删除清单、迁移要求)
|
||||
- 更新 `README.md` 增加 `VITE_USE_MOCK_POSTS` 段落
|
||||
- 不 commit、不 push(等 Terry 显式确认)
|
||||
- Steps:
|
||||
1. 跑全套检查命令
|
||||
2. dev server 手机视口检查
|
||||
3. 写 API 契约文档
|
||||
4. 改 README
|
||||
5. 报告完成,等 Terry 审阅与 commit 指令
|
||||
|
||||
## Risks
|
||||
|
||||
- **mock 数据视觉与真实数据偏差**:mock 用 picsum 占位图,可能掩盖真实图片不同宽高比的边界情况。缓解:mockPosts 中包含横图 / 竖图 / 接近正方形三种比例样本。
|
||||
- **video poster 在 mock 模式不易获取**:用一张本地 SVG 占位即可,避免依赖外部链接。
|
||||
- **i18n 删除收藏 key 后未使用的引用**:tsconfig 的 `noUnusedLocals` 不覆盖 i18n key 的字符串引用,需手动 grep。
|
||||
- **`PostRedirect` 在真接口模式下的实现**:当前先写 mock 分支,真接口分支 TODO 注释标明等 `/api/posts/:id` ready 后补。
|
||||
- **infinite scroll + URL 同步**:用户改 filter chip 时既要 reset cursor 又要更新 URL,注意避免 `setSearchParams` 触发额外 effect 循环。
|
||||
- **后端最终 schema 与本 spec 偏差**:如有偏差,必须先回 spec 改契约,再调前端类型,避免散点修改。
|
||||
|
||||
## Out of Scope(本 plan 不涵盖,遵循 spec)
|
||||
|
||||
- Home 页布局调整
|
||||
- Admin 后台 Post 编辑器
|
||||
- 真实后端 API 实现 + 数据迁移
|
||||
- 长按菜单 / 评论 / Reaction
|
||||
- 桌面端多列布局
|
||||
- SEO 优化
|
||||
38
.unipi/docs/quick-work/2026-06-03-bubble-footer-actions.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: "MessageBubble footer — timestamp + favorite + (file) download"
|
||||
type: quick-work
|
||||
date: 2026-06-03
|
||||
---
|
||||
|
||||
# MessageBubble footer — timestamp + favorite + (file) download
|
||||
|
||||
## Task
|
||||
Implement the 全部资料 card layout from Figma `4206-6509`:
|
||||
- Each card shows a bottom row with the publish timestamp on the left and action buttons on the right.
|
||||
- Image / album / video / text / link bubbles → 1 button (FavoriteButton).
|
||||
- File-document bubbles (mp3, pptx, pdf, zip, …) → 2 buttons (FavoriteButton + Download).
|
||||
|
||||
## Changes
|
||||
- `src/components/messageStream/BubbleAttachmentDownloadButton.tsx` (new) — small circular download button visually matched to `FavoriteButton` (sm). Handles its own download/loading state and surfaces the `SaveToAlbumGuide` toast for media kinds.
|
||||
- `src/components/messageStream/MessageBubble.tsx`
|
||||
- Removed the absolute-positioned FavoriteButton for the default variant.
|
||||
- Removed the right-aligned `<time>` block for the default variant.
|
||||
- Added a new flex footer: timestamp on the left, FavoriteButton (+ optional `BubbleAttachmentDownloadButton`) on the right.
|
||||
- File-doc detection is based on `pickBubble(post) === FileDocBubble` and the primary attachment `post.attachments[0]`.
|
||||
- `variant === "latest"` paths are left untouched (latest masonry cards keep the bottom-right absolute FavoriteButton and the existing right-aligned timestamp because `LatestFileCard` already renders its own footer).
|
||||
- `src/components/messageStream/bubbles/FileDocBubble.tsx`
|
||||
- Removed the inline per-row download button from `AttachmentRow` in the default variant (download now lives in the bubble footer).
|
||||
- Trimmed the now-unused state and handlers from `AttachmentRow`; imports remain because `LatestFileCard` still uses them.
|
||||
|
||||
## Verification
|
||||
- `npx tsc --noEmit` — clean.
|
||||
- `npm run format` then `npm run format:check` — clean.
|
||||
- `npm test` — 49/49 passing.
|
||||
- Visual check pending on device — expected to match Figma `4206-6509`:
|
||||
- timestamp + bookmark on image/album/video/text/link cards
|
||||
- timestamp + bookmark + download on file cards
|
||||
|
||||
## Notes
|
||||
- For posts with multiple file attachments, the footer download button currently targets `attachments[0]` only (matches the Figma single-attachment cards). If a multi-attachment file post needs per-attachment download, revisit `AttachmentRow` and re-add a small inline download or expose a list in an overflow menu.
|
||||
- The new download button mirrors `FavoriteButton`'s sm style (h-9 w-9, same border / bg / hover treatment) so the two sit on the same baseline and share visual weight.
|
||||
- The home page's "latest" masonry variant is unaffected — that path renders `LatestFileCard` which already has its own footer.
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: "官方物料 cards — bottom-left filename overlay"
|
||||
type: quick-work
|
||||
date: 2026-06-03
|
||||
---
|
||||
|
||||
# 官方物料 cards — bottom-left filename overlay
|
||||
|
||||
## Task
|
||||
In every content card classified as 官方物料 (`categorySlug === "official-assets"`), display the source filename at the bottom-left of the card — image filename for image bubbles, video filename for video bubbles.
|
||||
|
||||
## Data Source — Confirmed
|
||||
- Endpoint: `/api/posts` (list, called by `src/components/messageStream/hooks/usePostStream.ts:89`) and `/api/posts/:id` (single, used by `MessageStream` deep-links).
|
||||
- Field: `Post.attachments[*].filename` (`src/types/post.ts`).
|
||||
- Category gate: `Post.categorySlug === "official-assets"` (also referenced in `src/lib/categorySvgSlug.ts:13`, `src/pages/Categories/index.tsx:23`, `src/pages/Home/index.tsx:33`).
|
||||
- No additional API call is needed — `filename` already ships with the post payload.
|
||||
|
||||
## Changes
|
||||
- `src/components/messageStream/AttachmentFilenameLabel.tsx` (new) — small dark pill overlay using `filenameWithExtension(att.filename, att.mime)`, positioned `absolute bottom-2 left-2`, with `pointer-events-none`, `max-w-[calc(100%-1rem)]`, `truncate`, and a `title` attribute so the full filename is available on hover. Style mirrors the `AttachmentDownloadPill` (`bg-black/80` + `ring-white/20` + `backdrop-blur-md`) for visual consistency.
|
||||
- `src/components/messageStream/bubbles/SingleImageFrame.tsx` — accepts new `showFilename?: boolean` prop; when true, renders `AttachmentFilenameLabel` alongside the existing top-left download pill.
|
||||
- `src/components/messageStream/bubbles/ImageBubble.tsx` — passes `showFilename={post.categorySlug === "official-assets"}`.
|
||||
- `src/components/messageStream/bubbles/ImageWithTextBubble.tsx` — same gate, threaded through `SingleImageFrame`.
|
||||
- `src/components/messageStream/bubbles/AlbumBubble.tsx` — when category matches, renders the filename label inside each visible tile (skipping the `+N` overflow tile so its overlay stays clean).
|
||||
- `src/components/messageStream/bubbles/VideoBubble.tsx` — `VideoAttachmentCard` accepts `showFilename`, threaded into both the multi-video grid layout and the single-video layout. Skipped on the `+N` overflow tile.
|
||||
|
||||
## Verification
|
||||
- `npx tsc --noEmit` — clean.
|
||||
- `npm run format` then `npm run format:check` — clean.
|
||||
- `npm test` — 49/49 passing.
|
||||
- Visual confirmation pending on a posts feed that contains `official-assets` content.
|
||||
|
||||
## Notes
|
||||
- Reuses existing `filenameWithExtension` util so attachments without an extension still get a sensible label from their MIME type.
|
||||
- The label is `pointer-events-none` so it never blocks the bubble's own tap target (open lightbox / play video).
|
||||
- Only single/album/video bubbles surface this. `FileDocBubble` already renders the filename inline, so no overlay is needed there.
|
||||
- If a future requirement adds more "show filename" categories, switch `showFilename` from a boolean gate to a derived util (e.g. `shouldShowAttachmentFilename(post)`).
|
||||
@@ -0,0 +1,592 @@
|
||||
---
|
||||
title: "ARK Library Frontend — Backend API Requirements"
|
||||
type: api-requirements
|
||||
date: 2026-05-25
|
||||
audience: backend
|
||||
status: draft
|
||||
---
|
||||
|
||||
# ARK Library Frontend — Backend API Requirements
|
||||
|
||||
这份文档列出前端接下来需要后端提供的**全部接口**。重点是新的 Telegram-style 资料流;旧 `resources` 接口可作为过渡,但最终建议统一到 `posts` 模型。
|
||||
|
||||
## 0. 通用约定
|
||||
|
||||
- API base:前端通过 `VITE_API_URL` 指向后端;本地可同源 `/api`。
|
||||
- 上传文件可通过 `/uploads/...` 或完整 URL 返回;前端会用 `assetUrl()` 处理相对路径。
|
||||
- 所有时间字段使用 ISO 8601 字符串,例如 `2026-05-24T14:42:00.000Z`。
|
||||
- 语言字段:`zh-CN` / `en` / `ja` / `ko` / `vi` / `id` / `ms`;默认语言为 `en`。中文只有简体 `zh-CN`,没有繁体中文。
|
||||
- 错误格式:非 2xx + text/plain 或 JSON 均可;前端会显示错误文本。
|
||||
- Admin 接口需要 `Authorization: Bearer <token>`。
|
||||
|
||||
## 1. 核心数据模型
|
||||
|
||||
### 1.1 Category
|
||||
|
||||
```ts
|
||||
type Category = {
|
||||
id: number;
|
||||
slug: string; // 用于 /category/:slug 和 GET /api/posts?category=<slug>
|
||||
name: string; // 已按 lang 返回本地化名称
|
||||
description?: string;
|
||||
iconKey: string; // folder/calendar/megaphone/video/image 等,前端已有 icon map
|
||||
sortOrder: number;
|
||||
};
|
||||
```
|
||||
|
||||
### 1.2 Post(新资料流核心)
|
||||
|
||||
```ts
|
||||
type AttachmentKind = "image" | "video" | "document";
|
||||
|
||||
type Attachment = {
|
||||
id: string;
|
||||
kind: AttachmentKind;
|
||||
url: string; // 原始文件或可访问文件 URL
|
||||
mime: string; // image/jpeg, application/pdf, video/mp4, ...
|
||||
filename: string;
|
||||
sizeBytes: number;
|
||||
width?: number; // image/video 建议提供
|
||||
height?: number;
|
||||
durationSec?: number; // video 建议提供
|
||||
posterUrl?: string; // video preview
|
||||
thumbnailUrl?: string; // image/document preview
|
||||
};
|
||||
|
||||
type Post = {
|
||||
id: string;
|
||||
categoryId: number;
|
||||
categorySlug: string;
|
||||
language: "zh-CN" | "en" | "ja" | "ko" | "vi" | "id" | "ms";
|
||||
text?: string;
|
||||
attachments: Attachment[];
|
||||
isRecommended: boolean;
|
||||
publishedAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type PostListResponse = {
|
||||
items: Post[];
|
||||
nextCursor?: string;
|
||||
};
|
||||
```
|
||||
|
||||
### 1.3 Post 显示规则(后端必须按这个模型返回)
|
||||
|
||||
- 纯文字/链接:`attachments: []`,`text` 非空。
|
||||
- 单张图片:`attachments.length === 1` 且 `kind: "image"`。
|
||||
- 图片 + 文字:`kind: "image"` + `text`。
|
||||
- 视频:`kind: "video"`,建议提供 `posterUrl` / `durationSec`。
|
||||
- 文件:`kind: "document"`,前端显示下载卡。
|
||||
- 图片当文件上传:`kind: "document"` 且 `mime` 是 image;前端会显示缩略图 + 下载按钮。
|
||||
- 多图:
|
||||
- 2 / 3 / 4 张图:前端会独立纵向显示每张图,同一个 Post 只显示一次时间。
|
||||
- 超过 4 张图:前端显示 2×2 合并格,第 4 格模糊并显示 `+N`。
|
||||
|
||||
## 2. Public API(前台用户)
|
||||
|
||||
### 2.1 分类列表
|
||||
|
||||
```http
|
||||
GET /api/categories?lang=en
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```ts
|
||||
Category[]
|
||||
```
|
||||
|
||||
用途:Home 资料分类、CategoryPage 标题、Admin 表单分类选择。
|
||||
|
||||
---
|
||||
|
||||
### 2.2 全部资料 / 分类资料流
|
||||
|
||||
```http
|
||||
GET /api/posts?lang=en&limit=20&cursor=<cursor>&type=all&language=&category=<slug>
|
||||
```
|
||||
|
||||
Query:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
| ---------- | ---: | ------------------------------------------- |
|
||||
| `lang` | 是 | UI 语言 |
|
||||
| `limit` | 否 | 默认 20,最大建议 50 |
|
||||
| `cursor` | 否 | 后端返回的不透明 cursor |
|
||||
| `category` | 否 | 不传 = 全部资料;传 slug = 单分类 |
|
||||
| `type` | 否 | `all/image/video/ppt/pdf/text/link/archive` |
|
||||
| `language` | 否 | 资料语言:`zh/en/ja/ko/vi/id/ms` |
|
||||
|
||||
Response:
|
||||
|
||||
```ts
|
||||
PostListResponse;
|
||||
```
|
||||
|
||||
排序:`publishedAt DESC`。
|
||||
|
||||
用途:
|
||||
|
||||
- `/browse`:不传 `category`
|
||||
- `/category/:slug`:传 `category=<slug>`
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Home 推荐资料
|
||||
|
||||
```http
|
||||
GET /api/posts/recommended?lang=en&limit=12
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```ts
|
||||
{ items: Post[] }
|
||||
```
|
||||
|
||||
用途:Home「官方推荐」section。按 `sortOrder ASC` + `publishedAt DESC` 或后端自定义推荐顺序。
|
||||
|
||||
> 过渡期:当前前端 Home 仍可接受旧 `/api/resources/recommended`,但建议后端新做 `posts/recommended` 后前端再切换。
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Home 最新资料
|
||||
|
||||
```http
|
||||
GET /api/posts/latest?lang=en&limit=8
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```ts
|
||||
{ items: Post[] }
|
||||
```
|
||||
|
||||
用途:Home「最新更新」section。按 `publishedAt DESC`。
|
||||
|
||||
> 过渡期:当前前端 Home 仍可接受旧 `/api/resources/latest`。
|
||||
|
||||
---
|
||||
|
||||
### 2.5 单条 Post(旧链接落地)
|
||||
|
||||
```http
|
||||
GET /api/posts/:id
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```ts
|
||||
Post;
|
||||
```
|
||||
|
||||
用途:旧 `/resource/:id` 前端重定向:拿 `categorySlug` 后跳到 `/category/<slug>#post-<id>`。
|
||||
|
||||
---
|
||||
|
||||
### 2.6 搜索
|
||||
|
||||
建议新接口:
|
||||
|
||||
```http
|
||||
GET /api/posts/search?q=<keyword>&lang=en&type=all&language=&cursor=&limit=20
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```ts
|
||||
PostListResponse;
|
||||
```
|
||||
|
||||
搜索范围建议:`text`、`filename`、`categoryName`、tags。
|
||||
|
||||
过渡期当前前端仍使用:
|
||||
|
||||
```http
|
||||
GET /api/resources?q=<keyword>&lang=&type=&language=&limit=50
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```ts
|
||||
{ items: Resource[] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.7 搜索日志
|
||||
|
||||
```http
|
||||
POST /api/search-log
|
||||
Content-Type: application/json
|
||||
|
||||
{ "query": "ARK" }
|
||||
```
|
||||
|
||||
Response:204 或 `{ ok: true }`。
|
||||
|
||||
用途:记录用户搜索词;失败不阻断用户体验。
|
||||
|
||||
---
|
||||
|
||||
### 2.8 下载统计(可选)
|
||||
|
||||
文件下载目前前端可直接打开 `Attachment.url`。如果后端需要统计下载,提供:
|
||||
|
||||
```http
|
||||
POST /api/posts/:postId/attachments/:attachmentId/download
|
||||
```
|
||||
|
||||
Response:204 或 `{ ok: true }`。
|
||||
|
||||
> 过渡期旧 Home 推荐卡还可能调用 `POST /api/resources/:id/download`。
|
||||
|
||||
## 3. Filter 语义
|
||||
|
||||
`GET /api/posts` 的 `type` 参数建议按以下规则命中:
|
||||
|
||||
| type | 命中条件 |
|
||||
| --------- | ----------------------------------------------------------------- |
|
||||
| `all` | 全部 |
|
||||
| `image` | 任一 attachment `kind === "image"` |
|
||||
| `video` | 任一 attachment `kind === "video"` 或 `mime.startsWith("video/")` |
|
||||
| `pdf` | 任一 attachment 扩展名 `pdf` 或 `mime === "application/pdf"` |
|
||||
| `ppt` | 任一 attachment 扩展名 `ppt/pptx/key` 或 mime 含 `presentation` |
|
||||
| `archive` | 任一 attachment 扩展名 `zip/rar/7z/tar/gz` |
|
||||
| `text` | `text` 非空 |
|
||||
| `link` | `text` 中包含 `https?://` |
|
||||
|
||||
## 4. Wallet Auth API
|
||||
|
||||
### 4.1 取得签名 nonce/message
|
||||
|
||||
```http
|
||||
POST /api/auth/wallet/nonce
|
||||
Content-Type: application/json
|
||||
|
||||
{ "address": "0x..." }
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```ts
|
||||
{
|
||||
message: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 验证签名并签发 token
|
||||
|
||||
```http
|
||||
POST /api/auth/wallet/verify
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"address": "0x...",
|
||||
"message": "...",
|
||||
"signature": "0x..."
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```ts
|
||||
{
|
||||
token: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 验证当前 wallet session
|
||||
|
||||
```http
|
||||
GET /api/auth/wallet/me
|
||||
Authorization: Bearer <wallet-token>
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```ts
|
||||
{
|
||||
wallet: string;
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Admin API
|
||||
|
||||
### 5.1 Admin 登录
|
||||
|
||||
```http
|
||||
POST /api/admin/login
|
||||
Content-Type: application/json
|
||||
|
||||
{ "username": "...", "password": "..." }
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```ts
|
||||
{
|
||||
token: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.2 Admin dashboard
|
||||
|
||||
```http
|
||||
GET /api/admin/dashboard
|
||||
Authorization: Bearer <admin-token>
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```ts
|
||||
type AdminDashboard = {
|
||||
totalResources: number; // 若迁移到 Post,可理解为 totalPosts
|
||||
published: number;
|
||||
todayNew: number;
|
||||
totalViews: number;
|
||||
totalDownloads: number;
|
||||
totalFavorites: number; // 收藏下线后可返回 0,避免旧 admin UI 崩
|
||||
totalShares: number;
|
||||
hotResources: {
|
||||
id: string;
|
||||
title: string;
|
||||
downloads: number;
|
||||
favorites: number; // 可返回 0
|
||||
views: number;
|
||||
}[];
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.3 文件上传
|
||||
|
||||
```http
|
||||
POST /api/admin/upload
|
||||
Authorization: Bearer <admin-token>
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
file=<File>
|
||||
```
|
||||
|
||||
最低 Response:
|
||||
|
||||
```ts
|
||||
{
|
||||
url: string;
|
||||
}
|
||||
```
|
||||
|
||||
建议 Response(更方便前端自动建 Attachment):
|
||||
|
||||
```ts
|
||||
{
|
||||
url: string;
|
||||
mime: string;
|
||||
filename: string;
|
||||
sizeBytes: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
durationSec?: number;
|
||||
thumbnailUrl?: string;
|
||||
posterUrl?: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.4 Admin Post 列表
|
||||
|
||||
```http
|
||||
GET /api/admin/posts?limit=25&page=1&status=&category=&type=&q=
|
||||
Authorization: Bearer <admin-token>
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```ts
|
||||
{
|
||||
items: AdminPost[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
type AdminPost = Post & {
|
||||
isPublic: boolean;
|
||||
sortOrder: number;
|
||||
status: "draft" | "published" | "archived";
|
||||
viewCount?: number;
|
||||
downloadCount?: number;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.5 Admin Post 详情
|
||||
|
||||
```http
|
||||
GET /api/admin/posts/:id
|
||||
Authorization: Bearer <admin-token>
|
||||
```
|
||||
|
||||
Response:`AdminPost`。
|
||||
|
||||
---
|
||||
|
||||
### 5.6 创建 Post
|
||||
|
||||
```http
|
||||
POST /api/admin/posts
|
||||
Authorization: Bearer <admin-token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Request:
|
||||
|
||||
```ts
|
||||
type UpsertPostPayload = {
|
||||
categoryId: number;
|
||||
language: "zh-CN" | "en" | "ja" | "ko" | "vi" | "id" | "ms";
|
||||
text?: string;
|
||||
attachments: Attachment[];
|
||||
isPublic: boolean;
|
||||
isRecommended: boolean;
|
||||
sortOrder: number;
|
||||
status: "draft" | "published" | "archived";
|
||||
publishedAt?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
```
|
||||
|
||||
Response:`AdminPost`。
|
||||
|
||||
---
|
||||
|
||||
### 5.7 更新 Post
|
||||
|
||||
```http
|
||||
PUT /api/admin/posts/:id
|
||||
Authorization: Bearer <admin-token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Request:`UpsertPostPayload`。
|
||||
Response:`AdminPost`。
|
||||
|
||||
---
|
||||
|
||||
### 5.8 删除 Post
|
||||
|
||||
```http
|
||||
DELETE /api/admin/posts/:id
|
||||
Authorization: Bearer <admin-token>
|
||||
```
|
||||
|
||||
Response:204 或 `{ ok: true }`。
|
||||
|
||||
---
|
||||
|
||||
### 5.9 Admin 搜索日志
|
||||
|
||||
```http
|
||||
GET /api/admin/search-logs?limit=300
|
||||
Authorization: Bearer <admin-token>
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```ts
|
||||
{
|
||||
items: {
|
||||
id: string;
|
||||
query: string;
|
||||
createdAt: string;
|
||||
count?: number;
|
||||
}[];
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 过渡期旧 Resource API(如果 admin 尚未迁移)
|
||||
|
||||
当前部分前端/admin 代码仍可能使用旧接口。后端可以短期保留,或前端后续再统一切到 Post:
|
||||
|
||||
```http
|
||||
GET /api/resources?lang=&limit=&page=&sort=&q=&type=&language=&tag=
|
||||
GET /api/resources/recommended?lang=&limit=
|
||||
GET /api/resources/latest?lang=&limit=
|
||||
POST /api/resources/:id/download
|
||||
GET /api/admin/resources?limit=&page=
|
||||
GET /api/admin/resources/:id
|
||||
POST /api/admin/resources
|
||||
PUT /api/admin/resources/:id
|
||||
```
|
||||
|
||||
旧 `Resource` shape:
|
||||
|
||||
```ts
|
||||
type Resource = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
type: string;
|
||||
language: string;
|
||||
categoryId: number;
|
||||
categorySlug: string;
|
||||
categoryName: string;
|
||||
coverImage?: string;
|
||||
fileUrl?: string;
|
||||
previewUrl?: string;
|
||||
externalUrl?: string;
|
||||
bodyText?: string;
|
||||
badgeLabel?: string;
|
||||
isDownloadable: boolean;
|
||||
isRecommended: boolean;
|
||||
publishedAt?: string;
|
||||
updatedAt: string;
|
||||
tags?: string[];
|
||||
};
|
||||
```
|
||||
|
||||
## 7. 已下线 / 不需要实现
|
||||
|
||||
- 用户收藏:`/favorites` 页面已移除。
|
||||
- `POST /api/resources/:id/favorite` 不需要。
|
||||
- Reaction / 点赞 / 评论不需要。
|
||||
- Telegram 管理员标签、头像、群组名不需要。
|
||||
|
||||
## 8. 前后端切换计划
|
||||
|
||||
1. 后端先实现 `/api/posts`、`/api/posts/:id`、`/api/categories`。
|
||||
2. 前端 staging 设置:`VITE_USE_MOCK_POSTS=false`。
|
||||
3. 确认 `/browse` 和 `/category/:slug` 正常拉真数据。
|
||||
4. 再实现 `/api/posts/recommended`、`/api/posts/latest`,前端把 Home 从旧 resources 切到 posts。
|
||||
5. 最后迁移 Admin 从 `/api/admin/resources` 到 `/api/admin/posts`。
|
||||
|
||||
## 9. 最小可上线优先级
|
||||
|
||||
### P0(前台资料流必需)
|
||||
|
||||
- `GET /api/categories`
|
||||
- `GET /api/posts`
|
||||
- `GET /api/posts/:id`
|
||||
- `POST /api/admin/upload`
|
||||
- `POST /api/admin/posts`
|
||||
- `PUT /api/admin/posts/:id`
|
||||
- `GET /api/admin/posts`
|
||||
- `GET /api/admin/posts/:id`
|
||||
|
||||
### P1(首页/搜索/统计)
|
||||
|
||||
- `GET /api/posts/recommended`
|
||||
- `GET /api/posts/latest`
|
||||
- `GET /api/posts/search`
|
||||
- `POST /api/search-log`
|
||||
- `GET /api/admin/dashboard`
|
||||
- `GET /api/admin/search-logs`
|
||||
|
||||
### P2(账户)
|
||||
|
||||
- Wallet nonce / verify / me
|
||||
176
.unipi/docs/specs/2026-05-25-posts-api-contract.md
Normal file
@@ -0,0 +1,176 @@
|
||||
---
|
||||
title: "Posts API Contract (for backend)"
|
||||
type: api-contract
|
||||
date: 2026-05-25
|
||||
audience: backend
|
||||
status: draft
|
||||
---
|
||||
|
||||
# Posts API Contract
|
||||
|
||||
> 这份文档是从 `2026-05-25-telegram-style-resource-stream-design.md` §1–§2 抽出,供后端实现使用。前端已用 mock data 完成视觉;上线时把 `VITE_USE_MOCK_POSTS=false` 即可切真接口。
|
||||
|
||||
## 1. 数据模型
|
||||
|
||||
```ts
|
||||
type AttachmentKind = "image" | "video" | "document";
|
||||
|
||||
type Attachment = {
|
||||
id: string; // 唯一 id
|
||||
kind: AttachmentKind; // 三大类,前端按此分支渲染
|
||||
url: string; // 原始文件地址
|
||||
mime: string; // image/jpeg, application/pdf, video/mp4, ...
|
||||
filename: string; // 显示用文件名,含扩展名
|
||||
sizeBytes: number; // 字节数;前端格式化为 "3.5 MB"
|
||||
width?: number; // image/video 用于占位比例(CLS 优化)
|
||||
height?: number;
|
||||
durationSec?: number; // video 专用
|
||||
posterUrl?: string; // video 海报缩略图
|
||||
thumbnailUrl?: string; // image 缩略,列表用减少流量
|
||||
};
|
||||
|
||||
type Post = {
|
||||
id: string;
|
||||
categoryId: number;
|
||||
categorySlug: string;
|
||||
language: string; // "zh-CN" | "en" | "ja" | "ko" | "vi" | "id" | "ms"
|
||||
text?: string; // 可选,纯文本/图说;前端做 https → 链接自动识别
|
||||
attachments: Attachment[]; // 0~N;text-only post 时为 []
|
||||
isRecommended: boolean;
|
||||
publishedAt: string; // ISO 8601;用于排序 + 日期分组
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type PostListResponse = {
|
||||
items: Post[];
|
||||
nextCursor?: string; // 不透明 cursor;undefined = 没有下一页
|
||||
};
|
||||
```
|
||||
|
||||
### 关键约定
|
||||
|
||||
- **图片当文档**(在前端显示为「文件下载卡」):`kind === "document"` 且 `mime.startsWith("image/")`。Admin 上传时通过开关决定走 image 还是 document 通道。
|
||||
- **图片当图片**(前端显示为图片预览):`kind === "image"`。
|
||||
- **多图相册**:一个 Post 带多个 `kind === "image"` 的 attachments。前端会在 2-4 grid 中渲染,attachments.length > 4 时第 4 格模糊 + `+N`。
|
||||
- **图片 + 文字**:Post 同时有 `text` 与 attachments。
|
||||
- **纯文本 / 链接**:Post 仅有 `text`,`attachments: []`。
|
||||
- **视频**:`kind === "video"` 单 attachment。`posterUrl` 用于预览,`durationSec` 用于角标。
|
||||
- Attachment 内不携带任何「上传者头像 / 管理员标签」等社交字段(前端已下线)。
|
||||
|
||||
## 2. Endpoints
|
||||
|
||||
### 2.1 列表(核心)
|
||||
|
||||
```
|
||||
GET /api/posts
|
||||
```
|
||||
|
||||
Query 参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
| ---------- | ---- | ---------------------------------------------------------------------------------- |
|
||||
| `lang` | 是 | UI 语言;后端可据此选择不同语言版本的 `text` |
|
||||
| `category` | 否 | category slug;不传 = 全部分类 |
|
||||
| `type` | 否 | `all` / `image` / `video` / `pdf` / `ppt` / `text` / `link` / `archive`;语义见 §3 |
|
||||
| `language` | 否 | 资源语言:`zh-CN` / `en` / `ja` / `ko` / `vi` / `id` / `ms` |
|
||||
| `cursor` | 否 | 上一次返回的 `nextCursor`;不传 = 第一页 |
|
||||
| `limit` | 否 | 默认 20,最大 50 |
|
||||
|
||||
返回:`PostListResponse`
|
||||
|
||||
排序:`publishedAt DESC`。
|
||||
|
||||
### 2.2 Home 用聚合接口(可选,沿用现状)
|
||||
|
||||
```
|
||||
GET /api/posts/recommended?lang=&limit=
|
||||
GET /api/posts/latest?lang=&limit=
|
||||
```
|
||||
|
||||
返回:`{ items: Post[] }`(不分页)
|
||||
|
||||
### 2.3 单条(用于老链接 301 落地)
|
||||
|
||||
```
|
||||
GET /api/posts/:id
|
||||
```
|
||||
|
||||
返回:`Post`(或 404)
|
||||
|
||||
前端 `/resource/:id` 现在是轻量重定向:拿到 `categorySlug` → `/category/<slug>#post-<id>` 锚点滚动。
|
||||
|
||||
### 2.4 分类(不变)
|
||||
|
||||
```
|
||||
GET /api/categories?lang=
|
||||
```
|
||||
|
||||
返回:现有 `Category[]`。
|
||||
|
||||
### 2.5 Admin CRUD
|
||||
|
||||
```
|
||||
POST /api/admin/posts
|
||||
PUT /api/admin/posts/:id
|
||||
DELETE /api/admin/posts/:id
|
||||
GET /api/admin/posts?... (含未发布草稿)
|
||||
```
|
||||
|
||||
需求:
|
||||
|
||||
- 支持多附件上传(一次 multipart 或先 `POST /api/admin/upload` 拿到 url 再创建 Post)。
|
||||
- Admin UI 需要一个开关:「图片以图片形式呈现 / 以文档形式呈现」,对应 attachment.kind 的 image vs document。
|
||||
- 支持发布/隐藏、置顶/官方推荐。
|
||||
|
||||
> Admin UI 改造单独建 spec / plan,本契约仅说明后端必须支持这些字段。
|
||||
|
||||
## 3. `type` 参数语义
|
||||
|
||||
一个 Post 命中某个 `type`,规则:
|
||||
|
||||
| type | 命中条件 |
|
||||
| --------- | -------------------------------------------------------------------------- |
|
||||
| `all` | 全部 |
|
||||
| `image` | `attachments` 中至少一个 `kind === "image"` 或 `mime.startsWith("image/")` |
|
||||
| `video` | 至少一个 `kind === "video"` 或 `mime.startsWith("video/")` |
|
||||
| `pdf` | 至少一个 `mime === "application/pdf"` 或扩展名为 `pdf` |
|
||||
| `ppt` | 至少一个扩展名为 `ppt` / `pptx` / `key` 或 mime 含 `presentation` |
|
||||
| `archive` | 至少一个扩展名为 `zip` / `rar` / `7z` / `tar` / `gz` |
|
||||
| `text` | `text` 非空 |
|
||||
| `link` | `text` 非空且匹配 `https?://` |
|
||||
|
||||
前端 mock 已按此规则过滤,便于切真接口时口径一致。
|
||||
|
||||
## 4. 删除 / 废弃
|
||||
|
||||
| 项 | 处理 |
|
||||
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `POST /api/resources/:id/favorite` | 删除 |
|
||||
| `GET /api/favorites` / 收藏列表 | 删除(前端 `/favorites` 路由已移除) |
|
||||
| `/r/:id` 老前端路由 | 已合并到 `/resource/:id` 重定向 |
|
||||
| 老 `/api/resources*` 系列 | 后端可保留过渡期。建议提供数据迁移脚本:每个老 Resource → 一个 Post(带 1 个 attachment 或 text-only)。`isRecommended` / `language` / `categorySlug` 字段迁移;`favorite count` 字段丢弃。 |
|
||||
| Resource.coverImage 与 Resource.fileUrl 二选一 | 转为 attachments[0](kind 由后端判断 image vs document) |
|
||||
|
||||
## 5. Search
|
||||
|
||||
`GET /api/resources?q=...` 当前仍被 SearchPage 使用(在新 schema 上线前过渡)。后端可视情况:
|
||||
|
||||
- 短期:保留旧接口
|
||||
- 长期:新增 `GET /api/posts/search?q=...` 返回 `PostListResponse`,前端再切
|
||||
|
||||
## 6. 错误格式
|
||||
|
||||
沿用现状(HTTP 状态码 + 文本 body)。前端 `getJSON` 会把非 2xx 当作 `Error(text)` 抛出,`MessageStream` 显示红色横幅 + 重试按钮。
|
||||
|
||||
## 7. 兼容性 / 灰度
|
||||
|
||||
后端 ready 时步骤:
|
||||
|
||||
1. 把示例数据导入到 Posts 表
|
||||
2. `/api/posts` 在 staging 通过
|
||||
3. 前端 staging 部署设 `VITE_USE_MOCK_POSTS=false`,跑通后再 prod
|
||||
4. 前端代码层面:删除 `src/mocks/mockPosts.ts` 与 `usePostStream.ts` 中的 mock 分支,或保留 mock 用于本地离线开发
|
||||
|
||||
## 8. 联系
|
||||
|
||||
前端:Terry。Spec 主文档:`.unipi/docs/specs/2026-05-25-telegram-style-resource-stream-design.md`。
|
||||
@@ -0,0 +1,327 @@
|
||||
---
|
||||
title: "Telegram-style Resource Stream(资料分类查看全部 UI 重构)"
|
||||
type: brainstorm
|
||||
date: 2026-05-25
|
||||
---
|
||||
|
||||
# Telegram-style Resource Stream
|
||||
|
||||
## Problem Statement
|
||||
|
||||
当前"查看全部"打开的资料列表(`/browse` 和 `/category/:slug`)使用统一的卡片网格,无法表达"admin 上传的内容本质是不同类型的消息"(一张图、一段文字+链接、一个视频、一个 PDF、4+ 张图相册等)。手机端用户体验偏弱,缺少 Telegram 那种按类型差异化呈现的直观感受。
|
||||
|
||||
本次重构目标:把 `/browse` 与 `/category/:slug` 改成**单列、按时间倒序、按日期分组、按上传类型差异化渲染**的 Telegram-style 消息流,手机优先,保留 ARK 既有色系(深底 + 金色高亮)。
|
||||
|
||||
> 后端 endpoint 尚未实现。本次前端先用 mock data 完成视觉与交互,验收后再交接 API 契约给后端。
|
||||
|
||||
## Context
|
||||
|
||||
### 当前实现
|
||||
|
||||
- `src/pages/Home.tsx`:分类 section 头部"查看全部" → `/browse`;分类卡片 → `/category/<slug>`。
|
||||
- `src/pages/Browse.tsx`(221 行):含排序 tabs(最新/推荐/热门/发布)+ 类型 chips + 语言 chips + tag 过滤 + 分页(每页 24)。
|
||||
- `src/pages/CategoryPage.tsx`(156 行):含类型 chips + 语言 chips + 分页。
|
||||
- `src/pages/ResourceDetail.tsx`(229 行):`/r/:id` 资源详情独立路由。
|
||||
- `src/pages/FavoritesPage.tsx` + `postFavoriteDelta`:收藏功能。
|
||||
- `src/components/ResourceCard.tsx`:统一卡片,不区分资源类型。
|
||||
- `Resource` schema(`src/api.ts`)扁平:1 个 resource = 1 个文件(`coverImage` / `fileUrl`),无 attachments 数组。
|
||||
|
||||
### 设计参考(用户提供 7 张 Telegram 截图)
|
||||
|
||||
1. 图片当文档上传:缩略图 + ↓ + filename.ext + size + 右下时间,无头像、无 reaction。
|
||||
2. PDF / AI / PPT 等文档:蓝圆 ↓ + filename + size。
|
||||
3. 纯文本 + 链接:`https://...` 自动识别为可点链接。
|
||||
4. 视频:海报 + ▶️,第一次点 inline 播放预览,第二次点全屏;下方可有 admin 写的说明文字。
|
||||
5. 单张图片:直接显示,点全屏。
|
||||
6. 图片 + 文字:图片上方/下方文字,文字内链接 autolink。
|
||||
7. 4+ 张图相册:1-4 格 grid,第 4 张模糊 + `+N`;点开后全屏画廊。
|
||||
|
||||
## Chosen Approach
|
||||
|
||||
**方案 A:自建 `MessageStream` + 多态 `MessageBubble` 家族 + Mock-data layer**
|
||||
|
||||
- 新建一个 `MessageStream` 容器组件,被 `Browse.tsx` 和 `CategoryPage.tsx` 共用,差异通过 `scope` props 注入。
|
||||
- `MessageBubble` 内根据 `Post.attachments` 的形状分发到 6 个子组件(FileDoc / Text / Video / Image / ImageWithText / Album)。
|
||||
- 全屏交互(图片画廊 / 视频全屏)走 portal overlay。
|
||||
- 数据层用 `usePostStream` hook 抽象,默认走 `src/mocks/mockPosts.ts`(受 `VITE_USE_MOCK_POSTS` 控制),后端 ready 后切换为真 API。
|
||||
- 同时**收尾**收藏功能与详情页:删除 `/favorites`、`/r/:id`、heart 按钮、`postFavoriteDelta`。
|
||||
|
||||
## Why This Approach
|
||||
|
||||
### 拒绝方案 B(聊天 UI 库 `@chatscope/chat-ui-kit-react` 等)
|
||||
|
||||
- 默认主题与 ARK 深底+金色严重冲突,改主题成本 ≈ 自己写。
|
||||
- 不支持"4+ 图相册 +N 模糊"自定义。
|
||||
- 增加包体积与灰盒 bug 风险。
|
||||
|
||||
### 拒绝方案 C(重样 `ResourceCard`)
|
||||
|
||||
- 当前卡片统一渲染,无法满足"按类型差异化"(视频内嵌播放器 vs 多图相册 vs 文本+链接)。
|
||||
- 改动表面但不达 Telegram 风格。
|
||||
|
||||
### 拒绝其他子选项
|
||||
|
||||
- **保留排序 tabs**:Telegram 流天然按时间倒序,多余 tabs 破坏隐喻。Home 页仍保留"官方推荐 / 最新更新" section 作为入口。
|
||||
- **保留收藏功能在列表/详情页**:用户明确要求"不需要 reaction",且收藏与 Telegram 隐喻不符;整体下线最干净。
|
||||
- **保留 `ResourceDetail` 作 fallback**:所有交互(下载 / 全屏 / 链接)都能就地完成,独立详情页冗余;老 `/r/:id` 改 301 重定向到 `/category/<slug>#post-<id>` 锚点。
|
||||
- **`kind` 枚举铺开**:后端枚举膨胀难维护;前端按 `mime` / 文件后缀做细分图标更灵活。
|
||||
|
||||
## Design
|
||||
|
||||
### 1. 数据模型(前端使用 + 后端接口契约)
|
||||
|
||||
```ts
|
||||
type Post = {
|
||||
id: string;
|
||||
categoryId: number;
|
||||
categorySlug: string;
|
||||
language: string; // zh-TW | zh-CN | en
|
||||
text?: string; // 可选;纯文本/图说,前端自动识别 https 链接
|
||||
attachments: Attachment[]; // 0~N;text-only post 时为 []
|
||||
isRecommended: boolean;
|
||||
publishedAt: string; // ISO,用于排序 + 日期分组
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type Attachment = {
|
||||
id: string;
|
||||
kind: "image" | "video" | "document";
|
||||
url: string;
|
||||
mime: string; // image/jpeg | application/pdf | video/mp4 | ...
|
||||
filename: string; // "ARK项目一图读懂-01.jpg"
|
||||
sizeBytes: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
durationSec?: number; // video 专用
|
||||
posterUrl?: string; // video 海报
|
||||
thumbnailUrl?: string; // image 缩略
|
||||
};
|
||||
```
|
||||
|
||||
关键约定:
|
||||
- `kind: "document" + mime.startsWith("image/")` = 图片当文档上传(截图 1)。
|
||||
- `kind: "image"` = 图片当图片呈现(截图 5、6、7)。该开关在 admin 上传 UI 决定,传到后端落库。
|
||||
- 多图相册 = 一个 Post 带多个 `kind: "image"` 的 attachments。
|
||||
- 图片+文字 = Post 同时有 `text` 与 attachments。
|
||||
- 纯文本+链接 = Post 仅有 `text`,`attachments: []`。
|
||||
|
||||
### 2. 后端 API 契约(移交给后端)
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
|---|---|---|
|
||||
| GET | `/api/posts?category=<slug>&lang=&type=&language=&cursor=&limit=20` | 分类内消息流 |
|
||||
| GET | `/api/posts?lang=&type=&language=&cursor=&limit=20` | 全部消息流(`/browse`) |
|
||||
| GET | `/api/posts/recommended?lang=&limit=` | Home 推荐 section |
|
||||
| GET | `/api/posts/latest?lang=&limit=` | Home 最新 section |
|
||||
| GET | `/api/posts/:id` | 单条(用于老 `/r/:id` 301 重定向落地,前端拿到 `categorySlug` 后跳锚点) |
|
||||
| GET | `/api/categories` | 不变 |
|
||||
| POST/PUT/DELETE | `/api/admin/posts/...` | Admin CRUD,支持多附件 + 文本 + "图片是否以文档呈现"开关 |
|
||||
|
||||
废弃:
|
||||
- `/api/resources/:id/favorite`
|
||||
- 老 `/api/resources*` 系列保留过渡期,由后端写迁移脚本:每个老 Resource → 一个 Post。
|
||||
|
||||
返回结构:`{ items: Post[], nextCursor?: string }`,cursor 由后端不透明字符串提供。
|
||||
|
||||
`type` 参数语义:`all` / `image` / `video` / `pdf` / `ppt` / `text` / `link` / `archive`。一个 Post 命中条件 = `attachments[*].mime` 或 `text` 满足;具体由后端定义。
|
||||
|
||||
### 3. 组件结构
|
||||
|
||||
```
|
||||
src/pages/
|
||||
CategoryPage.tsx ← 重写:<MessageStream scope={{ kind:'category', slug }} />
|
||||
Browse.tsx ← 重写:<MessageStream scope={{ kind:'all' }} />
|
||||
|
||||
src/components/messageStream/
|
||||
MessageStream.tsx 顶层:fetch + 无限滚动 + 日期分组 + sticky filter chips
|
||||
FilterChips.tsx 类型 + 语言 chips(横向滚动,sticky top)
|
||||
DaySeparator.tsx "2 月 27 日" 胶囊
|
||||
MessageBubble.tsx 单条 Post 容器:决定子组件 + 右下角时间戳
|
||||
bubbles/
|
||||
FileDocBubble.tsx 截图 1 + 2:文档(图片当文档 / pdf / ai / ppt / docx)
|
||||
TextBubble.tsx 截图 3:纯文本 + autolink
|
||||
VideoBubble.tsx 截图 4:海报 + ▶️,先 inline 后全屏
|
||||
ImageBubble.tsx 截图 5:单张图片
|
||||
ImageWithTextBubble.tsx 截图 6:图片 + 文本 + autolink
|
||||
AlbumBubble.tsx 截图 7:2-4 格 grid,4+ 时第 4 格模糊 + `+N`
|
||||
overlays/
|
||||
ImageLightbox.tsx 全屏画廊(左右滑、缩放、关闭、下载)
|
||||
VideoPlayer.tsx 全屏视频播放器
|
||||
hooks/
|
||||
usePostStream.ts cursor 分页 + IntersectionObserver;mock/real 切换
|
||||
useGroupedByDay.ts 按 publishedAt 本地日期分组
|
||||
utils/
|
||||
autolink.tsx 文本中 https?://... → <a target="_blank" rel="noopener">
|
||||
fileIcon.ts 按 mime/扩展名返回图标 + 颜色
|
||||
formatBytes.ts 3,549,239 → "3.5 MB"
|
||||
|
||||
src/mocks/
|
||||
mockPosts.ts 覆盖 7 种 bubble 类型的样本数据(图片用 picsum 占位或本地)
|
||||
```
|
||||
|
||||
### 4. Bubble 分发逻辑
|
||||
|
||||
```ts
|
||||
function pickBubble(post: Post) {
|
||||
const a = post.attachments;
|
||||
if (a.length === 0) return TextBubble;
|
||||
if (a.length >= 2 && a.every(x => x.kind === "image")) return AlbumBubble;
|
||||
const only = a[0];
|
||||
if (only.kind === "video") return VideoBubble;
|
||||
if (only.kind === "image") {
|
||||
return post.text ? ImageWithTextBubble : ImageBubble;
|
||||
}
|
||||
return FileDocBubble; // document(含图片当文档:内部用 thumbnail 替代蓝圆图标)
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 移动端布局规范
|
||||
|
||||
- 容器宽度:手机 `max-w-full px-3`;md+ `max-w-[640px] mx-auto`。桌面端不做多列,保持单列聊天流(左右大留白)。
|
||||
- 气泡:`rounded-2xl bg-ark-panel`,左对齐,无头像,内边距 `p-3`(文本 `px-4 py-2.5`)。
|
||||
- 时间戳:右下角 `text-[11px] text-neutral-500`,绝对定位。
|
||||
- 文档下载按钮:圆形 36×36,金色 `bg-ark-gold` + 黑色 ↓。
|
||||
- Day separator:胶囊 `rounded-full bg-ark-panel/70 backdrop-blur px-3 py-1 text-xs text-neutral-400`,居中、sticky 在 FilterChips 下。
|
||||
- 多图 grid:宽度 100%,2×2,间距 2px;4+ 时第 4 格 `relative` 叠 `bg-black/45 backdrop-blur-sm` + `+N` 居中文字。
|
||||
- FilterChips 容器:`sticky top-0 z-10 bg-ark-bg/90 backdrop-blur` + 横向滚动 `overflow-x-auto whitespace-nowrap`。
|
||||
|
||||
### 6. 交互
|
||||
|
||||
| 交互 | 行为 |
|
||||
|---|---|
|
||||
| 点击文档下载按钮 | `window.open(attachment.url, "_blank")` 触发浏览器下载 |
|
||||
| 点击单张图片 | 打开 `ImageLightbox`(单图) |
|
||||
| 点击相册任一图 / `+N` | 打开 `ImageLightbox`,可左右切换 |
|
||||
| 点击视频海报 | 第一次:bubble 内 `<video controls autoPlay>` inline 播放 |
|
||||
| 点击播放中的视频 | 打开 `VideoPlayer` 全屏 overlay |
|
||||
| 文本中的链接 | `target="_blank" rel="noopener noreferrer"` 新标签打开 |
|
||||
| 滚动到底部 | IntersectionObserver 触发下一页 cursor 拉取 |
|
||||
| 筛选 chips 变化 | 重置 cursor,重新拉取;同步 URL `?type=&language=` |
|
||||
| 长按气泡 | 暂不做,列入 Open Questions |
|
||||
|
||||
### 7. Mock data 层
|
||||
|
||||
`src/mocks/mockPosts.ts` 导出 `MOCK_POSTS: Post[]`,至少包含:
|
||||
|
||||
- 2 条"图片当文档"(不同 mime:jpg、png)
|
||||
- 2 条文档(pdf、ai)
|
||||
- 2 条纯文本+链接(含中文 + 多链接 + emoji)
|
||||
- 1 条视频(带 posterUrl + duration)
|
||||
- 2 条单图(不同宽高比)
|
||||
- 1 条图+文字
|
||||
- 1 条 3 图相册
|
||||
- 1 条 7 图相册(验证 `+N` 行为)
|
||||
- 跨多天的 `publishedAt`,验证 DaySeparator
|
||||
|
||||
`usePostStream` 行为:
|
||||
|
||||
```ts
|
||||
const useMock = import.meta.env.VITE_USE_MOCK_POSTS !== "false";
|
||||
if (useMock) {
|
||||
// 1. 按 scope.slug / type / language 过滤 MOCK_POSTS
|
||||
// 2. 按 publishedAt 倒序
|
||||
// 3. 按 cursor(数字 offset 字符串)切 20 条
|
||||
// 4. setTimeout 200ms 模拟延迟
|
||||
// 5. 返回 nextCursor = offset+20 或 undefined
|
||||
} else {
|
||||
// fetch /api/posts?... 真接口
|
||||
}
|
||||
```
|
||||
|
||||
切真接口时只需在部署环境设 `VITE_USE_MOCK_POSTS=false`(或干脆删 mock 分支)。
|
||||
|
||||
### 8. 锚点 + 分享
|
||||
|
||||
- 每个 bubble 渲染为 `<article id="post-${post.id}">`。
|
||||
- 老路由 `/r/:id` 改为一个轻量重定向组件:fetch `/api/posts/:id` 拿到 `categorySlug` → `navigate(/category/${slug}#post-${id}, { replace: true })` → `scrollIntoView`。
|
||||
- Mock 模式下从 `MOCK_POSTS` 找。
|
||||
|
||||
### 9. 移除清单
|
||||
|
||||
文件:
|
||||
- `src/pages/ResourceDetail.tsx`
|
||||
- `src/pages/FavoritesPage.tsx`
|
||||
- `src/components/ResourceCard.tsx`
|
||||
- `src/components/ResourceListFooter.tsx`
|
||||
|
||||
代码:
|
||||
- `postFavoriteDelta` 及所有调用点
|
||||
- i18n keys:`favorites`, `addFavorite`, `removeFavorite` 等收藏相关
|
||||
- Home 中的 `/favorites` 入口
|
||||
|
||||
路由:
|
||||
- `/favorites`:删除
|
||||
- `/r/:id`:保留为轻量重定向
|
||||
|
||||
### 10. 测试 / 验证策略
|
||||
|
||||
- 视觉验证:本地 `npm run dev`,手机模拟器(Chrome DevTools iPhone 14 Pro 视口)逐一对照 7 张截图。
|
||||
- 单元测试:`pickBubble` 分发逻辑、`autolink` 正则、`formatBytes`、`useGroupedByDay`。
|
||||
- 类型检查:`npx tsc --noEmit`(项目 strict)。
|
||||
- 格式化:`npm run format`。
|
||||
- 删除后回归:确认 `/favorites` 与 `/r/:id` 老链接不报 404 而是合理跳转或 410。
|
||||
|
||||
### 11. 风险与缓解
|
||||
|
||||
- **真接口 schema 与 mock 不一致**:spec 是合同;后端实现时若需偏离,必须先回来改 spec。前端 hook 内 `Post` 类型从 `src/types/post.ts` 单一来源导入。
|
||||
- **`+N` 相册和单图 lightbox 的状态管理混乱**:用 React Context(`<ImageLightboxProvider>`)暴露 `openLightbox(images, startIndex)` 单一入口,所有 bubble 调它。
|
||||
- **视频 inline → 全屏切换的状态丢失**:全屏 overlay 接 `currentTime` 参数,避免重新加载。
|
||||
- **scroll restoration**:cursor 分页页内来回滑动时 IntersectionObserver 容易重复触发;用 `loadingRef` 守护。
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
> 全部项已被 `.unipi/docs/plans/2026-05-25-telegram-style-resource-stream-plan.md` 覆盖。
|
||||
|
||||
- [x] 定义 `src/types/post.ts`:`Post`、`Attachment`、`PostListResponse`
|
||||
- [x] 创建 `src/mocks/mockPosts.ts`:覆盖 7 种 bubble 类型 + 跨日期样本
|
||||
- [x] 创建 `src/components/messageStream/hooks/usePostStream.ts`(mock + real 双模式 + cursor 分页 + IntersectionObserver)
|
||||
- [x] 创建 `src/components/messageStream/hooks/useGroupedByDay.ts`
|
||||
- [x] 创建 `src/components/messageStream/utils/autolink.tsx`
|
||||
- [x] 创建 `src/components/messageStream/utils/fileIcon.ts`
|
||||
- [x] 创建 `src/components/messageStream/utils/formatBytes.ts`
|
||||
- [x] 创建 `FilterChips.tsx`(sticky + 横向滚动)
|
||||
- [x] 创建 `DaySeparator.tsx`
|
||||
- [x] 创建 `MessageBubble.tsx`(含 `pickBubble` 分发)
|
||||
- [x] 创建 `bubbles/FileDocBubble.tsx`(图片当文档 + pdf/ai/ppt)
|
||||
- [x] 创建 `bubbles/TextBubble.tsx`(autolink)
|
||||
- [x] 创建 `bubbles/VideoBubble.tsx`(inline 播放 + 全屏触发)
|
||||
- [x] 创建 `bubbles/ImageBubble.tsx`
|
||||
- [x] 创建 `bubbles/ImageWithTextBubble.tsx`
|
||||
- [x] 创建 `bubbles/AlbumBubble.tsx`(2-4 grid + `+N`)
|
||||
- [x] 创建 `overlays/ImageLightbox.tsx` + `ImageLightboxProvider` context
|
||||
- [x] 创建 `overlays/VideoPlayer.tsx`
|
||||
- [x] 创建 `MessageStream.tsx` 顶层组件
|
||||
- [x] 重写 `src/pages/CategoryPage.tsx` 为 `<MessageStream scope={{ kind:'category', slug }} />`
|
||||
- [x] 重写 `src/pages/Browse.tsx` 为 `<MessageStream scope={{ kind:'all' }} />`
|
||||
- [x] 删除 `src/pages/ResourceDetail.tsx`,将 `/r/:id` 改为重定向组件(mock 模式下从 `MOCK_POSTS` 查)
|
||||
- [x] 删除 `src/pages/FavoritesPage.tsx`、`src/components/ResourceCard.tsx`、`src/components/ResourceListFooter.tsx`
|
||||
- [x] 移除 `postFavoriteDelta` 及全部调用点
|
||||
- [x] 移除 `App.tsx` 中 `/favorites` 路由 + Home 入口
|
||||
- [x] 清理 i18n favorites 相关 keys
|
||||
- [x] 单元测试:`pickBubble`、`autolink`、`formatBytes`、`useGroupedByDay`
|
||||
- [x] 视觉对照 7 张参考截图(iPhone 14 Pro 视口)
|
||||
- [x] 运行 `npx tsc --noEmit && npm run format:check && npm test`
|
||||
- [x] 文档:在 README 注明 `VITE_USE_MOCK_POSTS` 用法
|
||||
- [x] 交付后端 API 契约文档(本 spec 的 §2 部分单独抽出 markdown 给后端)
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **长按 / 右键菜单**:是否需要"复制链接"、"举报"、"分享"?v2 决定。
|
||||
- **`type` 筛选语义边界**:一个 Post 含多种 attachment 时(图+视频混合,目前 mock 不出现),`type=video` 命中规则由后端定,前端按返回展示即可。
|
||||
- **空状态文案**:消息流为空时显示什么?目前沿用 `t("noResults")`。
|
||||
- **错误重试**:网络失败时是否提供"重试"按钮?建议下方加一个 inline 重试条。
|
||||
- **视频自动暂停**:滚出视口时是否自动暂停?建议做,体验更顺。
|
||||
- **i18n 时间戳格式**:是否需要适配繁体/简体/英文不同的日期分组格式?沿用 `Intl.DateTimeFormat` 按 `lang` 切换。
|
||||
- **SEO**:删除 `/r/:id` 详情页后,搜索引擎抓取深度受影响吗?目前站点未做强 SEO,可忽略;如需保留可让 `/r/:id` 渲染服务端可解析的 `<noscript>` 摘要后再 JS 重定向。
|
||||
- **Admin 上传 UI 改造**:本次只覆盖前台浏览端;admin 端 Post 编辑器(多附件 + 文本 + 图片呈现方式开关)需要单独的 spec / 任务。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Home 页面布局调整(分类卡片网格、推荐/最新 section 保持不变)
|
||||
- Admin 后台 UI 改造(单独 spec)
|
||||
- 真实 API 实现(后端工作)
|
||||
- 后端数据迁移脚本
|
||||
- 长按菜单、举报、分享等社交功能
|
||||
- 评论 / Reaction
|
||||
- 离线缓存 / Service Worker
|
||||
- 桌面端多列布局
|
||||
157
.unipi/docs/specs/2026-05-28-browse-figma-redesign-design.md
Normal file
@@ -0,0 +1,157 @@
|
||||
---
|
||||
title: "Browse 页(全部资料)Figma 1:1 视觉对齐"
|
||||
type: brainstorm
|
||||
date: 2026-05-28
|
||||
---
|
||||
|
||||
# Browse 页(全部资料)Figma 1:1 视觉对齐
|
||||
|
||||
## Problem Statement
|
||||
|
||||
`/browse` 路由(「全部资料」页)的视觉与 Figma 设计稿(node `4206-6051`)有两处显著落差,需要对齐:
|
||||
|
||||
1. **筛选条 (FilterChips)** 当前是椭圆 pill chip + 边框 + 溢出折叠按钮,Figma 是下划线 tab 风格(文字 + 橘色底线,无边框、无背景)。
|
||||
2. **mobile 底部导航第三格**目前是「官方推荐」(连到 `/browse?sort=recommended`),Figma 是「我的收藏」。
|
||||
|
||||
页面标题、Header、讯息流 bubble 已对齐,不需要再动。
|
||||
|
||||
## Context
|
||||
|
||||
- 当前 FilterChips 实现:`src/components/messageStream/FilterChips.tsx`,含 9 种 type(all/image/video/music/ppt/pdf/text/link/archive),有 `SlidersHorizontal` overflow expand 按钮。
|
||||
- Figma 只展示 mobile viewport,且只露 6 种 type(全部/图片/视频/音乐/PPT/PDF)。
|
||||
- 现有 mobile 底部 nav:`src/layouts/PublicLayout.tsx` 内的 `BottomNavIcon`,4 格:home / document / heart / update。
|
||||
- 资产已经存在 `heart-active.svg` / `heart-inactive.svg`,不需要新增图示档。
|
||||
- 「我的收藏」功能本身**不实作**,只做 stub「即将推出」页。等用户系统建立后再做真功能。
|
||||
- text / link / archive 三种 type 现在确实有资料可筛,不能直接砍掉。
|
||||
|
||||
## Chosen Approach
|
||||
|
||||
**Approach A + 全 viewport responsive**:
|
||||
|
||||
- 筛选条改成下划线 tab 风格,**所有 viewport** 统一使用(mobile / tablet / desktop)。
|
||||
- 9 种 type 全部保留,溢出改成「水平横向滚动」(无 expand 按钮,自由滑),符合 Telegram 风格 + Figma 风格。
|
||||
- mobile 底部 nav 第 3 格:图示沿用 heart,label 改 `t("favorites")`,连结改 `/favorites`。
|
||||
- 新增 `/favorites` route,stub 页内容为「我的收藏 — 即将推出」+ 返回首页 CTA。
|
||||
- 桌机顶部 nav **不动**(「官方推荐」入口保留在 desktop top nav)。
|
||||
|
||||
## Why This Approach
|
||||
|
||||
- **为什么所有 viewport 统一筛选条样式**:Terry 明确要求 responsive across screen sizes;同一组件维护两套样式是不必要的复杂度;下划线 tab 在桌机宽度下也清爽(更胜 pill chip)。
|
||||
- **为什么水平滚动而非「⋯」展开**:Figma 没画出展开行为;标准的「Telegram filter bar」模式就是横滑;少一个状态机;本专案的 `MessageStream` 已经是无限滚动列表,再加一个 expand 反而割裂体验。
|
||||
- **为什么不砍掉 text/link/archive**:这些是真有资料的 type,砍掉会让桌机用户失去筛选能力;横滑设计已经能容纳全部 9 种。
|
||||
- **为什么 favorites 做 stub 不做完整功能**:Terry 选 Q2 选项 3(等用户系统再做),明确不留 localStorage 技术债。
|
||||
- **为什么沿用 heart 图示**:资产已经存在;Figma 视觉风格与现有 heart 相容;不增加设计审查负担。
|
||||
|
||||
### 已拒绝的替代方案
|
||||
|
||||
- **B(桌机也加「我的收藏」顶部 nav 入口)**:会让桌机 nav 多一个空壳连结,不必要。
|
||||
- **C(移除 text/link/archive type 入口)**:损失既有功能,不符合「视觉对齐 ≠ 砍功能」原则。
|
||||
- **localStorage 收藏功能**:Terry 选 Q2 选项 3,明确不做。
|
||||
- **「⋯」expand 按钮(Figma-faithful)**:Figma 没明确画出展开行为,是我推测的;横滑更直接。
|
||||
|
||||
## Design
|
||||
|
||||
### 1. FilterChips 重做
|
||||
|
||||
**文件**:`src/components/messageStream/FilterChips.tsx`(改写,非新增)
|
||||
|
||||
**新视觉规范**:
|
||||
- 容器:水平横向滚动 (`overflow-x-auto`),隐藏 scrollbar,sticky top 保留
|
||||
- 每个 tab:纯文字按钮,无 border、无 background、无 rounded
|
||||
- inactive:`text-neutral-400`,hover `text-ark-gold/80`
|
||||
- active:`text-ark-gold` + 底部 `2px` 橘色下划线(`border-b-2 border-ark-gold`),inactive 底部 `2px` 透明 border(占位防跳动)
|
||||
- 间距:tab 之间 `gap-5`(或 `gap-6`,实作时微调)
|
||||
- padding:每个 tab 上下 `py-3`,左右无(让 text 自然贴齐 underline)
|
||||
- 字号:`text-sm` 或 `text-[15px]`,依照 Figma 比例
|
||||
- 移除 `SlidersHorizontal` expand 按钮与 `expanded` state
|
||||
- 移除 measure 隐藏元素 + `ResizeObserver`(不再需要侦测溢出)
|
||||
|
||||
**type 顺序保持不变**:all / image / video / music / ppt / pdf / text / link / archive
|
||||
|
||||
**响应性**:
|
||||
- 因为是水平滚动,所有宽度都能容纳;mobile 极窄屏自然横滑,桌机宽屏会自然撑开置左
|
||||
|
||||
### 2. 新增 `/favorites` Stub 页
|
||||
|
||||
**文件**:`src/pages/Favorites/index.tsx`(新增)
|
||||
|
||||
**结构**:
|
||||
- 居中容器(`flex items-center justify-center min-h-[60vh]`)
|
||||
- 心形 icon(用 lucide `Heart`,size 48px,`text-ark-gold/70`)
|
||||
- 标题:`t("favorites")` → 「我的收藏」
|
||||
- 副标:`t("favoritesComingSoon")` → 「功能即将推出」
|
||||
- 描述:`t("favoritesComingSoonDesc")` → 简短说明(一行)
|
||||
- CTA:「返回首页」按钮 → `Link to="/"`,使用既有 `ark-gold` 风格
|
||||
|
||||
**响应性**:
|
||||
- 单栏居中,所有 viewport 都用同一份 layout
|
||||
- 文字 `text-base` 起跳,md 以上放大
|
||||
|
||||
### 3. App.tsx 加 route
|
||||
|
||||
**文件**:`src/App.tsx`
|
||||
|
||||
- 在公开 routes 区段加入 `<Route path="/favorites" element={<Favorites />} />`
|
||||
- 透过 `PublicLayout` Outlet 渲染(继承 Header + 底部 nav)
|
||||
|
||||
### 4. PublicLayout 底部 nav 第 3 格改写
|
||||
|
||||
**文件**:`src/layouts/PublicLayout.tsx`
|
||||
|
||||
- 第 3 个 `BottomNavIcon`:
|
||||
- `to="/browse?sort=recommended"` → `to="/favorites"`
|
||||
- `label={t("official")}` → `label={t("favorites")}`
|
||||
- `active={...recommended}` → `active={pathname === "/favorites"}`
|
||||
- icon 保持 `heart`(资产已存在)
|
||||
|
||||
### 5. i18n 增加 key
|
||||
|
||||
**文件**:`src/i18n.tsx`
|
||||
|
||||
- 三语言(zh-TW / zh-CN / en)各加:
|
||||
- `favorites`:「我的收藏」/「My Favorites」
|
||||
- `favoritesComingSoon`:「即将推出」/「Coming Soon」
|
||||
- `favoritesComingSoonDesc`:一行说明
|
||||
- `backToHome`:「返回首页」/「Back to Home」(如不存在)
|
||||
|
||||
### Data Flow
|
||||
|
||||
- FilterChips 的 prop API(`type` / `onTypeChange`)**不变**,纯视觉重做,使用方(`MessageStream` 等)无需调整。
|
||||
- `/favorites` stub 无任何资料请求,纯静态页。
|
||||
|
||||
### Error Handling
|
||||
|
||||
- `/favorites` 是纯静态,无错误状态。
|
||||
- FilterChips 横滑容器:测试在 `overflow-x: hidden` 的 ancestor 中是否仍可滚动;既有 `global_overflow_x_hidden_mobile_2026_05_27` 的全域规则需要确认不会卡住此处的 horizontal scroll(可能需要 `overflow-x-auto` 强制覆盖)。
|
||||
|
||||
### Testing
|
||||
|
||||
- 既有 `npm test` 应该全过(API 没变)
|
||||
- 视觉测试需要:mobile (375px) / tablet (768px) / desktop (1280px) 三档手测
|
||||
- TypeScript: `npx tsc --noEmit`
|
||||
- Format: `npm run format:check`
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] FilterChips.tsx 重写:移除 pill 样式与 expand 按钮,改为下划线 tab + 水平滚动
|
||||
- [ ] 新增 `src/pages/Favorites/index.tsx` stub 页(含 Heart icon、标题、副标、返回首页 CTA)
|
||||
- [ ] `src/App.tsx` 加入 `/favorites` route 与 import
|
||||
- [ ] `src/layouts/PublicLayout.tsx` 底部 nav 第 3 格改 label / route / active 判断
|
||||
- [ ] `src/i18n.tsx` 三语加入 `favorites` / `favoritesComingSoon` / `favoritesComingSoonDesc` / `backToHome`
|
||||
- [ ] 验证 FilterChips 横滑在 `global overflow-x-hidden` mobile 规则下仍正常
|
||||
- [ ] 跑 `npx tsc --noEmit` + `npm run format:check` + `npm test`
|
||||
- [ ] mobile / tablet / desktop 三档视觉手测
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 「我的收藏」stub 页的副标描述文字具体怎么写?(建议:「登入功能开发中,敬请期待」之类,可在实作时定)
|
||||
- FilterChips 的字号要 `text-sm` 还是 `text-[15px]`?需要量一下 Figma 才能 1:1(实作时对图调整)
|
||||
- 桌机顶部 nav 的「官方推荐」是否要在未来 phase 一起处理?(本 spec 暂不动)
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 真正的「我的收藏」功能(依赖未来用户系统,独立 spec)
|
||||
- 桌机顶部 nav 调整(包括「官方推荐」入口存废)
|
||||
- 讯息流 bubble 本身的视觉调整(既有已对齐)
|
||||
- 移除 text / link / archive 三种 type
|
||||
- 任何后端改动
|
||||
@@ -0,0 +1,367 @@
|
||||
---
|
||||
title: "China-Friendly Wallet Login"
|
||||
type: brainstorm
|
||||
date: 2026-06-01
|
||||
---
|
||||
|
||||
# China-Friendly Wallet Login
|
||||
|
||||
## Problem Statement
|
||||
|
||||
ARK Library needs wallet-based login so users can later access account-bound features such as favorites. The login must work for China-based users without requiring VPN access where possible. The goal is not to perform on-chain reads or transactions; it is only to verify wallet address ownership through message signing and bind that address to a backend session/JWT.
|
||||
|
||||
The practical problem is that mobile users may open the DApp in different environments: desktop browser with extension, wallet DApp browser, or a normal mobile browser. A normal mobile browser cannot directly talk to a wallet app unless there is a bridge such as WalletConnect/Reown, a wallet-specific SDK, or a wallet-specific callback/deep-link flow.
|
||||
|
||||
## Context
|
||||
|
||||
Existing backend wallet authentication is partially available:
|
||||
|
||||
- `POST /api/auth/wallet/nonce`
|
||||
- `POST /api/auth/wallet/verify`
|
||||
- `GET /api/auth/wallet/me`
|
||||
|
||||
Backend findings:
|
||||
|
||||
- Wallet nonce currently expires after 15 minutes.
|
||||
- Wallet JWT currently lasts 30 days.
|
||||
- There is no refresh-token mechanism.
|
||||
- There is no user-bound favorites API yet.
|
||||
- Existing `/api/resources/{id}/favorite` only changes global favorite count and does not bind favorites to a wallet address.
|
||||
|
||||
Frontend findings:
|
||||
|
||||
- There is currently no wallet login implementation.
|
||||
- Favorites page is currently a placeholder.
|
||||
- Wallet login should be designed separately from the full favorites feature.
|
||||
|
||||
Research findings:
|
||||
|
||||
- RainbowKit is a wallet UI layer. Its generic scan/login flow usually depends on WalletConnect/Reown.
|
||||
- Reown/WalletConnect relay was previously found unstable for China access.
|
||||
- RainbowKit can appear stable when users are actually connecting through injected providers (`window.ethereum`) in extensions or wallet DApp browsers; that stability does not prove WalletConnect/Reown scan stability.
|
||||
- TokenPocket supports stable external-browser flows through `tpoutside://pull.activity` deep links and callback URLs.
|
||||
- TokenPocket `tp-js-sdk` only works inside TokenPocket's DApp browser, so it is not the external-browser QR bridge by itself.
|
||||
- imToken QR login generally uses WalletConnect-style bridging.
|
||||
- MetaMask QR login can use WalletConnect/RainbowKit-style bridging or MetaMask SDK. For this project, MetaMask QR is handled through RainbowKit as a fallback rather than a China-stable primary path.
|
||||
- OKX Connect SDK was considered but rejected because OKX Wallet is not a target wallet for this product.
|
||||
|
||||
## Chosen Approach
|
||||
|
||||
Use a hybrid wallet login approach:
|
||||
|
||||
1. **Stable primary path:** custom injected-provider login plus TokenPocket QR/callback login.
|
||||
2. **Compatibility fallback:** RainbowKit/Reown QR login for MetaMask and imToken users who want scan-login from a separate device.
|
||||
|
||||
Supported wallets for this design:
|
||||
|
||||
1. TokenPocket
|
||||
2. MetaMask
|
||||
3. imToken
|
||||
|
||||
Supported login paths:
|
||||
|
||||
| Wallet | Injected / DApp browser login | Click / deep-link login | QR login back to current browser |
|
||||
|---|---:|---:|---:|
|
||||
| TokenPocket | Yes | Yes | Yes, via TokenPocket callback |
|
||||
| MetaMask | Yes | Yes | Yes, via RainbowKit/Reown fallback |
|
||||
| imToken | Yes | Yes | Yes, via RainbowKit/Reown fallback |
|
||||
|
||||
The UI must not imply that all QR methods are equally stable in China. It should distinguish:
|
||||
|
||||
- **TokenPocket QR login** — recommended China-stable QR path.
|
||||
- **MetaMask / imToken QR login** — compatibility fallback powered by RainbowKit/Reown; may fail or be slow depending on network environment.
|
||||
|
||||
## Why This Approach
|
||||
|
||||
This approach balances China stability with the user's requirement that MetaMask and imToken also have QR login options.
|
||||
|
||||
Accepted trade-offs:
|
||||
|
||||
- TokenPocket gets the primary QR login because it provides a direct callback mechanism that avoids Reown/WalletConnect relay instability.
|
||||
- MetaMask and imToken get QR login through RainbowKit/Reown, but this is explicitly treated as a fallback and not the recommended China-stable path.
|
||||
- The frontend will include heavier wallet dependencies for the fallback path: RainbowKit, wagmi, viem, and Reown/WalletConnect configuration.
|
||||
- A WalletConnect/Reown project ID is required through environment configuration.
|
||||
- Full favorites behavior is out of scope for this spec and should be designed separately.
|
||||
|
||||
Rejected alternatives:
|
||||
|
||||
1. **RainbowKit/Reown for all wallets including TokenPocket**
|
||||
- Rejected because it would make the China-stable TokenPocket flow depend on the relay that was already found unstable.
|
||||
|
||||
2. **TokenPocket-only QR login**
|
||||
- Rejected because the desired product behavior now includes MetaMask and imToken QR login, even if those QR paths are less reliable in China.
|
||||
|
||||
3. **OKX Connect SDK**
|
||||
- Rejected because OKX Wallet is not a target wallet for the current product requirement.
|
||||
|
||||
4. **MetaMask SDK separate integration**
|
||||
- Rejected for now because RainbowKit/Reown gives a broader compatibility fallback for both MetaMask and imToken with one integration.
|
||||
|
||||
5. **Favorites in the same plan**
|
||||
- Rejected because wallet login and user-bound favorites are separate subsystems. Favorites needs its own backend endpoints and product decisions.
|
||||
|
||||
## Design
|
||||
|
||||
### Architecture
|
||||
|
||||
The login feature should be split into small units:
|
||||
|
||||
1. **Wallet auth API client**
|
||||
- Requests nonce.
|
||||
- Verifies signed messages.
|
||||
- Fetches current wallet session.
|
||||
- Stores and clears JWT.
|
||||
|
||||
2. **Wallet session provider**
|
||||
- Owns login state: loading, logged out, logged in, error.
|
||||
- Exposes wallet address, shortened address, token status, login actions, logout action.
|
||||
- Restores session through `/api/auth/wallet/me` when a token exists.
|
||||
|
||||
3. **Injected provider adapter**
|
||||
- Uses `window.ethereum` when available.
|
||||
- Requests accounts.
|
||||
- Signs nonce via `personal_sign`.
|
||||
- Sends address/signature to backend verify endpoint.
|
||||
- Covers desktop extensions and wallet DApp browsers.
|
||||
|
||||
4. **TokenPocket QR adapter**
|
||||
- Creates a server-recognized `actionId` / login request.
|
||||
- Gets or constructs a TokenPocket `tpoutside://pull.activity` login/sign deep link.
|
||||
- Displays it as QR code.
|
||||
- Frontend polls backend for callback result.
|
||||
- Once backend has the address/signature result, frontend finalizes login through normal wallet verification.
|
||||
- This is the recommended QR path for China users.
|
||||
|
||||
5. **RainbowKit QR fallback adapter**
|
||||
- Configures RainbowKit/wagmi/WalletConnect for MetaMask and imToken QR login.
|
||||
- Uses `VITE_WALLETCONNECT_PROJECT_ID` for the Reown project ID.
|
||||
- After wallet connection, requests a backend nonce and asks the connected wallet to sign the nonce.
|
||||
- Sends address/signature to `/api/auth/wallet/verify` and stores the returned JWT.
|
||||
- UI copy must label this as a fallback that may be unstable on some China networks.
|
||||
|
||||
6. **Wallet deep-link helper**
|
||||
- Provides buttons for TokenPocket, MetaMask, and imToken.
|
||||
- Opens the current URL in the selected wallet's DApp browser when no injected provider is available or when the user chooses the DApp-browser path.
|
||||
|
||||
7. **Wallet login modal**
|
||||
- Shows wallet options.
|
||||
- Shows TokenPocket QR as the recommended scan option.
|
||||
- Shows MetaMask/imToken QR via RainbowKit as an alternate scan option.
|
||||
- Shows device-specific copy:
|
||||
- Desktop TokenPocket QR: "Use TokenPocket on your phone to scan this QR code."
|
||||
- Mobile TokenPocket QR: "Use TokenPocket on another device to scan this QR code."
|
||||
- RainbowKit fallback: "MetaMask / imToken QR uses WalletConnect/Reown and may be unstable on some networks. If it fails, open this site inside your wallet app."
|
||||
|
||||
### User Flow: Injected Login
|
||||
|
||||
1. User clicks Connect Wallet.
|
||||
2. Frontend detects `window.ethereum`.
|
||||
3. Frontend requests wallet accounts.
|
||||
4. Frontend requests nonce from backend.
|
||||
5. User signs nonce through wallet.
|
||||
6. Frontend sends address and signature to backend verify endpoint.
|
||||
7. Backend verifies signature and returns JWT.
|
||||
8. Frontend stores JWT and updates UI to shortened address.
|
||||
|
||||
### User Flow: TokenPocket QR Login
|
||||
|
||||
1. User opens login modal and chooses TokenPocket QR.
|
||||
2. Frontend creates a TokenPocket QR login request with a unique `actionId`.
|
||||
3. Frontend displays a QR code for TokenPocket.
|
||||
4. User scans QR with TokenPocket on another device.
|
||||
5. TokenPocket asks user to sign the login message.
|
||||
6. TokenPocket sends result to backend callback URL.
|
||||
7. Frontend polls backend for `actionId` result.
|
||||
8. Frontend receives address/signature result and completes verify flow.
|
||||
9. Frontend stores JWT and updates UI.
|
||||
|
||||
### User Flow: MetaMask / imToken QR Fallback
|
||||
|
||||
1. User chooses MetaMask/imToken QR login.
|
||||
2. Frontend opens RainbowKit's connection flow with WalletConnect/Reown configured.
|
||||
3. User scans the QR using MetaMask or imToken.
|
||||
4. WalletConnect/Reown establishes the session.
|
||||
5. Frontend requests a backend nonce.
|
||||
6. User signs the nonce through the connected wallet.
|
||||
7. Frontend sends address/signature to `/api/auth/wallet/verify`.
|
||||
8. Frontend stores JWT and updates UI.
|
||||
9. If connection fails or times out, UI recommends TokenPocket QR or opening the site inside the wallet DApp browser.
|
||||
|
||||
### User Flow: MetaMask / imToken Deep Link
|
||||
|
||||
1. User clicks MetaMask or imToken button.
|
||||
2. If injected provider exists, use injected login.
|
||||
3. If no injected provider exists, open current site URL in the selected wallet's DApp browser using that wallet's deep-link/universal-link format.
|
||||
4. The login completes inside the wallet DApp browser using injected login.
|
||||
|
||||
### Logged-In UI
|
||||
|
||||
- Desktop/header should show shortened address such as `0x12...ab34`.
|
||||
- Clicking the address opens a small menu with Disconnect.
|
||||
- Disconnect clears the local JWT/session and returns UI to logged-out state.
|
||||
- No ENS lookup is required.
|
||||
- No remote avatar lookup is required.
|
||||
|
||||
### Backend API Contract
|
||||
|
||||
Existing wallet auth endpoints should remain the canonical verification path:
|
||||
|
||||
```http
|
||||
POST /api/auth/wallet/nonce
|
||||
POST /api/auth/wallet/verify
|
||||
GET /api/auth/wallet/me
|
||||
```
|
||||
|
||||
The frontend needs exact request/response contracts confirmed before implementation. Expected shape:
|
||||
|
||||
```http
|
||||
POST /api/auth/wallet/nonce
|
||||
Content-Type: application/json
|
||||
|
||||
{ "address": "0x..." }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "nonce": "message to sign" }
|
||||
```
|
||||
|
||||
```http
|
||||
POST /api/auth/wallet/verify
|
||||
Content-Type: application/json
|
||||
|
||||
{ "address": "0x...", "signature": "0x..." }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "token": "jwt", "address": "0x..." }
|
||||
```
|
||||
|
||||
For TokenPocket QR login, backend needs additional endpoints or equivalent behavior:
|
||||
|
||||
```http
|
||||
POST /api/auth/wallet/tp-login-request
|
||||
```
|
||||
|
||||
Creates a short-lived login request and returns data needed to render the QR.
|
||||
|
||||
Expected output:
|
||||
|
||||
```json
|
||||
{
|
||||
"actionId": "unique-id",
|
||||
"message": "message to sign",
|
||||
"qrUrl": "tpoutside://pull.activity?param=...",
|
||||
"expiresAt": "ISO timestamp"
|
||||
}
|
||||
```
|
||||
|
||||
```http
|
||||
POST /api/auth/wallet/tp-callback
|
||||
```
|
||||
|
||||
Called by TokenPocket after user signs. Backend validates the callback payload shape, stores the result by `actionId`, and expires it quickly.
|
||||
|
||||
```http
|
||||
GET /api/auth/wallet/tp-result?actionId=...
|
||||
```
|
||||
|
||||
Frontend polls this endpoint until result is pending, completed, expired, or failed.
|
||||
|
||||
Expected states:
|
||||
|
||||
```json
|
||||
{ "status": "pending" }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "completed",
|
||||
"address": "0x...",
|
||||
"signature": "0x..."
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{ "status": "expired" }
|
||||
```
|
||||
|
||||
The final JWT should still come from `/api/auth/wallet/verify` so all wallet-login paths share one verification endpoint.
|
||||
|
||||
RainbowKit/Reown QR fallback does not require new backend endpoints beyond the canonical nonce/verify/me endpoints, but it does require frontend environment configuration:
|
||||
|
||||
```env
|
||||
VITE_WALLETCONNECT_PROJECT_ID=...
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
- No wallet detected: show wallet choices, TokenPocket QR login, and RainbowKit QR fallback.
|
||||
- User rejects signature: show a clear retryable error.
|
||||
- Nonce expired: request a fresh nonce and retry.
|
||||
- TokenPocket QR expired: generate a new QR.
|
||||
- TokenPocket callback never arrives: show timeout and retry option.
|
||||
- RainbowKit/Reown connection fails: explain that scan login may be blocked or slow on this network; recommend TokenPocket QR or wallet DApp browser.
|
||||
- Invalid signature: show login failed and do not store token.
|
||||
- `/me` fails with expired/invalid token: clear token and return to logged-out state.
|
||||
|
||||
### Testing
|
||||
|
||||
Frontend testing should cover:
|
||||
|
||||
- Session provider restores logged-in state when `/me` succeeds.
|
||||
- Session provider clears state when `/me` fails.
|
||||
- Injected adapter signs and verifies through mocked provider/API.
|
||||
- TokenPocket QR polling handles pending, completed, expired, failed, and timeout states.
|
||||
- RainbowKit fallback handles connected, rejected, timeout/failure, and signed-message states.
|
||||
- Login modal copy correctly distinguishes TokenPocket QR from RainbowKit/Reown fallback QR.
|
||||
- Logout clears token and resets UI.
|
||||
|
||||
Backend testing should cover:
|
||||
|
||||
- TokenPocket login request creates short-lived action IDs.
|
||||
- Callback stores exactly one completed result per action ID.
|
||||
- Expired action IDs cannot be completed.
|
||||
- Polling endpoint returns correct states.
|
||||
- Verify endpoint still validates signatures and returns JWT.
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] Confirm exact existing wallet auth request/response shapes with backend.
|
||||
- [ ] Confirm TokenPocket callback payload fields from official docs or a sandbox callback test.
|
||||
- [ ] Confirm WalletConnect/Reown project ID ownership and add `VITE_WALLETCONNECT_PROJECT_ID` to env docs.
|
||||
- [ ] Add backend TokenPocket login request endpoint.
|
||||
- [ ] Add backend TokenPocket callback endpoint.
|
||||
- [ ] Add backend TokenPocket polling/result endpoint.
|
||||
- [ ] Add frontend install plan for RainbowKit, wagmi, viem, and required query provider dependency.
|
||||
- [ ] Add frontend wallet auth API client and token storage helpers.
|
||||
- [ ] Add frontend wallet session provider/hook.
|
||||
- [ ] Add injected provider login adapter.
|
||||
- [ ] Add TokenPocket QR login adapter and polling flow.
|
||||
- [ ] Add RainbowKit/Reown QR fallback for MetaMask and imToken.
|
||||
- [ ] Add wallet deep-link helpers for TokenPocket, MetaMask, and imToken.
|
||||
- [ ] Add wallet login modal and header logged-in state UI.
|
||||
- [ ] Wire logout to clear token and session state.
|
||||
- [ ] Add frontend tests for session, injected login, TP QR polling, RainbowKit fallback, and logout.
|
||||
- [ ] Add backend tests for TokenPocket request/callback/result behavior.
|
||||
- [ ] Document the backend API contract for Louis/backend implementation.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. What exact message format should users sign? It should include domain, wallet address, nonce, issued-at time, and purpose such as "Sign in to ARK Library".
|
||||
2. Should JWT remain 30 days, or should backend add refresh tokens later?
|
||||
3. What exact TokenPocket callback payload will be received for EVM personal-sign login?
|
||||
4. Which public icon URL should TokenPocket QR metadata use for ARK Library?
|
||||
5. Should login UI appear only in the header and favorites flow, or also in mobile menu?
|
||||
6. Should favorites trigger wallet login immediately when clicked, or should that be decided in the separate favorites design?
|
||||
7. Which Reown/WalletConnect project ID should production use, and who owns that Reown project?
|
||||
8. Should RainbowKit fallback be hidden or visually de-emphasized for China users, or simply shown with warning copy?
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Full user-bound favorites implementation.
|
||||
- Favorites page real list UI.
|
||||
- Favorites database schema.
|
||||
- OKX Connect SDK.
|
||||
- MetaMask SDK separate integration.
|
||||
- ENS names, ENS avatars, or chain data reads.
|
||||
- On-chain transactions.
|
||||
504
.unipi/docs/specs/2026-06-01-user-favorites-design.md
Normal file
@@ -0,0 +1,504 @@
|
||||
---
|
||||
title: "User Favorites"
|
||||
type: brainstorm
|
||||
date: 2026-06-01
|
||||
---
|
||||
|
||||
# User Favorites
|
||||
|
||||
## Problem Statement
|
||||
|
||||
ARK Library needs a real user-level favorites feature tied to wallet login. Users should be able to save resources for later, see their own saved resources on `/favorites`, and use favorites as a personal library rather than just incrementing a public counter.
|
||||
|
||||
The current implementation only has a public `POST /api/resources/{id}/favorite` counter endpoint. It does not know who favorited a resource, does not prevent duplicate favorites, and cannot power a "My Favorites" page. The frontend `/favorites` page is currently a placeholder.
|
||||
|
||||
This feature should support user-bound favorites while preserving existing popularity/favorite count behavior for rankings and admin metrics.
|
||||
|
||||
## Context
|
||||
|
||||
Existing backend/frontend facts:
|
||||
|
||||
- Backend `resources.favorite_count` exists and is used in popularity ordering/admin stats.
|
||||
- Backend currently exposes `POST /api/resources/{id}/favorite` with `{ add: true/false }`, but it is unauthenticated and only changes a global counter.
|
||||
- Backend wallet auth exists through `/api/auth/wallet/nonce`, `/api/auth/wallet/verify`, and `/api/auth/wallet/me`.
|
||||
- Wallet login is being designed separately in `2026-06-01-china-friendly-wallet-login-design.md`.
|
||||
- Frontend `src/pages/Favorites/index.tsx` is a "Coming Soon" page.
|
||||
- Resource list endpoints return paginated public resources and support filters such as `q`, `category`, and `sort`.
|
||||
|
||||
Product decisions from brainstorming:
|
||||
|
||||
- Favorites are user-level and keyed by wallet address.
|
||||
- Favorite target is only `resources.id`, not posts, collections, or arbitrary entities.
|
||||
- Favorite buttons appear both on resource cards/lists and resource detail/post pages.
|
||||
- If an unauthenticated user clicks favorite, the wallet login modal opens; after successful login, the original favorite action completes automatically.
|
||||
- Favorites page supports sortable, filterable, searchable favorites.
|
||||
- Sort options: favorited time, resource published time, and hot/popular.
|
||||
- Favorites page supports category filter and keyword search.
|
||||
- If a favorited resource later becomes unavailable, the favorites page still shows it as unavailable and lets the user remove it.
|
||||
- Existing favorite counts should be preserved as historical heat rather than reset.
|
||||
|
||||
## Chosen Approach
|
||||
|
||||
Use **user favorites + batch favorite state + favorites-page query API**.
|
||||
|
||||
Backend adds an authenticated `user_favorites` table and `/api/me/favorites` endpoints. Frontend adds a shared favorite state layer, reusable favorite button, batch status lookup for lists, and a real `/favorites` page.
|
||||
|
||||
The old unauthenticated favorite counter endpoint should be deprecated or changed so public users cannot freely mutate `favorite_count` without a wallet identity.
|
||||
|
||||
## Why This Approach
|
||||
|
||||
This approach balances user experience, backend clarity, and future extensibility.
|
||||
|
||||
Accepted trade-offs:
|
||||
|
||||
- A batch-state endpoint is added so list pages can show filled/unfilled hearts without N requests.
|
||||
- Favorites page gets its own query API because it needs wallet scoping, sort, category filter, search, pagination, and unavailable-resource handling.
|
||||
- Favorite counts remain materialized for ranking/admin performance, but backend must maintain them consistently when user favorites change.
|
||||
- A pending favorite action must survive the wallet-login modal flow so users do not need to click favorite twice.
|
||||
|
||||
Rejected alternatives:
|
||||
|
||||
1. **Global counter only**
|
||||
- Rejected because it cannot power "My Favorites" and can be spammed.
|
||||
|
||||
2. **Minimal add/remove/list only**
|
||||
- Rejected because resource lists would not know current favorite state efficiently.
|
||||
|
||||
3. **Collections/folders**
|
||||
- Rejected as out of scope. The current need is simple resource saving, not multi-folder organization.
|
||||
|
||||
4. **Polymorphic favorites (`target_type`, `target_id`)**
|
||||
- Rejected because only `resources.id` is needed now. Simpler schema is easier to index and reason about.
|
||||
|
||||
5. **Reset all historical favorite counts**
|
||||
- Rejected because current counts may already contribute to heat/ranking. Preserve them as historical base values.
|
||||
|
||||
## Design
|
||||
|
||||
### Backend Data Model
|
||||
|
||||
Add a wallet-scoped favorites table:
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_favorites (
|
||||
wallet_address TEXT NOT NULL,
|
||||
resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (wallet_address, resource_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_favorites_wallet_created
|
||||
ON user_favorites (wallet_address, created_at DESC);
|
||||
|
||||
CREATE INDEX idx_user_favorites_resource
|
||||
ON user_favorites (resource_id);
|
||||
```
|
||||
|
||||
Wallet addresses should be stored in canonical checksum form if the backend already normalizes wallet auth to checksum addresses. Queries should compare using the same normalized representation.
|
||||
|
||||
To preserve historical favorites, add a base count field or equivalent migration strategy:
|
||||
|
||||
```sql
|
||||
ALTER TABLE resources
|
||||
ADD COLUMN favorite_base_count INT NOT NULL DEFAULT 0;
|
||||
|
||||
UPDATE resources SET favorite_base_count = favorite_count;
|
||||
```
|
||||
|
||||
Then define the visible favorite count as:
|
||||
|
||||
```text
|
||||
visibleFavoriteCount = favorite_base_count + count(user_favorites for resource)
|
||||
```
|
||||
|
||||
Implementation options:
|
||||
|
||||
1. Keep `resources.favorite_count` materialized and update it on add/remove:
|
||||
- Migration sets `favorite_base_count = favorite_count`.
|
||||
- `favorite_count` starts as the existing historical value.
|
||||
- Each new user favorite increments/decrements `favorite_count` exactly once.
|
||||
- Fast reads, but backend must maintain consistency carefully.
|
||||
|
||||
2. Compute `favorite_base_count + COUNT(user_favorites)` in queries:
|
||||
- Most accurate by construction.
|
||||
- May require careful indexing or view/materialization for popular sorting.
|
||||
|
||||
Recommended: keep `resources.favorite_count` materialized for existing popularity/admin queries, but make all future changes go through authenticated user-favorite endpoints. Add a periodic or admin-only consistency check later if needed.
|
||||
|
||||
### Backend API Contract
|
||||
|
||||
All `/api/me/favorites*` endpoints require wallet JWT:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <wallet-jwt>
|
||||
```
|
||||
|
||||
The backend identifies the wallet address from the JWT, not from request body.
|
||||
|
||||
#### List current user's favorites
|
||||
|
||||
```http
|
||||
GET /api/me/favorites?sort=favorited_at&page=1&limit=24&category=project-ppt&q=ark&includeUnavailable=true
|
||||
```
|
||||
|
||||
Query params:
|
||||
|
||||
| Param | Values | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `page` | positive integer | `1` | Page number |
|
||||
| `limit` | `1..100` | `24` | Page size |
|
||||
| `sort` | `favorited_at`, `published_at`, `hot` | `favorited_at` | `hot` uses popularity score |
|
||||
| `category` | category slug | none | Optional category filter |
|
||||
| `q` | text | none | Search title/description/tag/body as appropriate |
|
||||
| `includeUnavailable` | `true`, `false` | `true` | Whether to include unpublished/private/deleted-like resources still referenced by favorites |
|
||||
| `lang` | UI language code | optional | Category display language, matching existing resources endpoints |
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"favoritedAt": "2026-06-01T12:00:00Z",
|
||||
"resource": {
|
||||
"id": "uuid",
|
||||
"title": "ARK resource title",
|
||||
"description": "...",
|
||||
"type": "video",
|
||||
"language": "zh-TW",
|
||||
"categoryId": 1,
|
||||
"categorySlug": "project-ppt",
|
||||
"categoryName": "項目資料(PPT)",
|
||||
"coverImage": "/uploads/cover.png",
|
||||
"fileUrl": "/uploads/file.pdf",
|
||||
"previewUrl": "/uploads/preview.mp4",
|
||||
"externalUrl": null,
|
||||
"isDownloadable": true,
|
||||
"isRecommended": false,
|
||||
"publishedAt": "2026-05-01T12:00:00Z",
|
||||
"updatedAt": "2026-05-02T12:00:00Z",
|
||||
"tags": ["官方推薦"],
|
||||
"favoriteCount": 12,
|
||||
"availability": "available"
|
||||
}
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
"limit": 24,
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
Unavailable resources should return enough metadata for the favorites page to show the item and allow removal. Suggested shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"favoritedAt": "2026-06-01T12:00:00Z",
|
||||
"resource": {
|
||||
"id": "uuid",
|
||||
"title": "Previously favorited resource",
|
||||
"categoryName": "...",
|
||||
"updatedAt": "2026-05-02T12:00:00Z",
|
||||
"favoriteCount": 12,
|
||||
"availability": "unavailable",
|
||||
"unavailableReason": "unpublished"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For `sort=hot`, use the same general popularity concept as existing popular resources, for example:
|
||||
|
||||
```sql
|
||||
(download_count + favorite_count + share_count) DESC, updated_at DESC
|
||||
```
|
||||
|
||||
For `sort=published_at`:
|
||||
|
||||
```sql
|
||||
published_at DESC NULLS LAST, updated_at DESC
|
||||
```
|
||||
|
||||
For `sort=favorited_at`:
|
||||
|
||||
```sql
|
||||
user_favorites.created_at DESC
|
||||
```
|
||||
|
||||
#### Batch favorite status
|
||||
|
||||
```http
|
||||
GET /api/me/favorites/ids?resourceIds=id1,id2,id3
|
||||
```
|
||||
|
||||
Returns which of the provided resource IDs are favorited by the authenticated wallet.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"ids": ["id1", "id3"]
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `resourceIds` may be comma-separated.
|
||||
- Backend should cap number of IDs, e.g. max 100.
|
||||
- Unknown IDs are ignored.
|
||||
- Requires wallet JWT.
|
||||
|
||||
#### Add favorite
|
||||
|
||||
```http
|
||||
POST /api/me/favorites/{resourceId}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"resourceId": "uuid",
|
||||
"favorited": true,
|
||||
"favoritedAt": "2026-06-01T12:00:00Z",
|
||||
"favoriteCount": 13
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Requires wallet JWT.
|
||||
- Idempotent: if already favorited, return success without double incrementing count.
|
||||
- Should allow favoriting only existing resources.
|
||||
- Product preference: favoriting unavailable/private resources from public UI should not normally happen; backend may reject unavailable resources for new favorites with `404` or `409`.
|
||||
|
||||
#### Remove favorite
|
||||
|
||||
```http
|
||||
DELETE /api/me/favorites/{resourceId}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"resourceId": "uuid",
|
||||
"favorited": false,
|
||||
"favoriteCount": 12
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Requires wallet JWT.
|
||||
- Idempotent: if not favorited, return success without decrementing count.
|
||||
- If resource is unavailable but favorite row exists, removal should still work.
|
||||
|
||||
#### Legacy counter endpoint
|
||||
|
||||
Existing endpoint:
|
||||
|
||||
```http
|
||||
POST /api/resources/{id}/favorite
|
||||
```
|
||||
|
||||
Should be deprecated for public use. Options:
|
||||
|
||||
1. Return `410 Gone` or `405 Method Not Allowed` once the new feature ships.
|
||||
2. Keep it temporarily but route authenticated requests to `POST/DELETE /api/me/favorites/{resourceId}` semantics.
|
||||
3. Keep only for backwards compatibility during deploy, then remove from docs.
|
||||
|
||||
Recommended: deprecate it in docs and stop frontend usage. Do not allow unauthenticated clients to mutate user-visible favorite counts.
|
||||
|
||||
### Frontend Components and State
|
||||
|
||||
Add a shared favorites layer:
|
||||
|
||||
1. **Favorites API client**
|
||||
- `listFavorites(params, token)`
|
||||
- `getFavoriteIds(resourceIds, token)`
|
||||
- `addFavorite(resourceId, token)`
|
||||
- `removeFavorite(resourceId, token)`
|
||||
|
||||
2. **Favorites state/provider or hook**
|
||||
- Tracks favorite IDs for currently visible resources.
|
||||
- Provides `isFavorite(resourceId)`.
|
||||
- Provides `toggleFavorite(resourceId)`.
|
||||
- Handles pending actions while wallet login is in progress.
|
||||
- Clears state on wallet logout.
|
||||
|
||||
3. **FavoriteButton**
|
||||
- Reusable heart button for cards and detail pages.
|
||||
- Supports states: idle, favorited, loading, disabled/unavailable.
|
||||
- Has localized accessible labels:
|
||||
- Add to favorites
|
||||
- Remove from favorites
|
||||
- Login to favorite
|
||||
|
||||
4. **Favorites page**
|
||||
- Replaces Coming Soon placeholder.
|
||||
- Shows list/grid of favorited resources.
|
||||
- Supports sort tabs/dropdown: favorited time, published time, hot.
|
||||
- Supports category filter.
|
||||
- Supports search input scoped to current user's favorites.
|
||||
- Shows empty states:
|
||||
- Not logged in: prompt to connect wallet.
|
||||
- Logged in but no favorites: prompt to browse resources.
|
||||
- Filter/search no results: prompt to clear filters.
|
||||
- Shows unavailable items with clear badge and remove action.
|
||||
|
||||
### Frontend Data Flow
|
||||
|
||||
#### Resource list pages
|
||||
|
||||
```text
|
||||
Resource list endpoint returns items
|
||||
↓
|
||||
If wallet logged in, call /api/me/favorites/ids with visible resource IDs
|
||||
↓
|
||||
FavoriteButton receives favorited state
|
||||
↓
|
||||
User toggles favorite
|
||||
↓
|
||||
Optimistically update UI
|
||||
↓
|
||||
POST/DELETE backend
|
||||
↓
|
||||
On success, reconcile favoriteCount if returned
|
||||
↓
|
||||
On failure, rollback and show error
|
||||
```
|
||||
|
||||
#### Unauthenticated favorite click
|
||||
|
||||
```text
|
||||
User clicks FavoriteButton while logged out
|
||||
↓
|
||||
Store pending action: { type: "favorite", resourceId }
|
||||
↓
|
||||
Open wallet login modal
|
||||
↓
|
||||
Wallet login succeeds
|
||||
↓
|
||||
Run pending favorite action with new token
|
||||
↓
|
||||
Update button state and count
|
||||
```
|
||||
|
||||
If login is cancelled, the pending action is cleared and no favorite is added.
|
||||
|
||||
#### Favorites page
|
||||
|
||||
```text
|
||||
User opens /favorites
|
||||
↓
|
||||
If logged out, show login prompt
|
||||
↓
|
||||
If logged in, call /api/me/favorites with sort/filter/search/page
|
||||
↓
|
||||
Render resources with favorited=true
|
||||
↓
|
||||
Removing an item updates list immediately
|
||||
```
|
||||
|
||||
### Localization
|
||||
|
||||
New UI copy must be added to all supported locale files:
|
||||
|
||||
- `zh-CN`
|
||||
- `en`
|
||||
- `ko`
|
||||
- `ja`
|
||||
- `vi`
|
||||
- `id`
|
||||
- `ms`
|
||||
|
||||
Suggested keys:
|
||||
|
||||
- `favoriteAdd`
|
||||
- `favoriteRemove`
|
||||
- `favoriteLoginRequired`
|
||||
- `favoriteAdded`
|
||||
- `favoriteRemoved`
|
||||
- `favoritesEmptyTitle`
|
||||
- `favoritesEmptyDesc`
|
||||
- `favoritesFilterAllCategories`
|
||||
- `favoritesSortFavoritedAt`
|
||||
- `favoritesSortPublishedAt`
|
||||
- `favoritesSortHot`
|
||||
- `favoritesSearchPlaceholder`
|
||||
- `favoritesUnavailable`
|
||||
- `favoritesClearFilters`
|
||||
|
||||
### Error Handling
|
||||
|
||||
- `401` from favorites API: clear wallet session or prompt re-login.
|
||||
- `404` add favorite: resource no longer available; show message and refresh list.
|
||||
- Network error during toggle: rollback optimistic state and show retryable error.
|
||||
- Login cancelled after favorite click: do nothing and keep resource un-favorited.
|
||||
- Batch favorite IDs fails on list pages: leave buttons unfilled but clickable; clicking can still prompt login or retry.
|
||||
- Remove unavailable favorite fails: keep item visible and show retryable error.
|
||||
|
||||
### Testing
|
||||
|
||||
Frontend tests should cover:
|
||||
|
||||
- FavoriteButton renders add/remove/loading states.
|
||||
- Unauthenticated click opens wallet login and completes pending favorite after login.
|
||||
- Toggle favorite performs optimistic update and rollback on error.
|
||||
- Batch favorite IDs marks visible resources correctly.
|
||||
- Favorites page handles logged-out, empty, results, unavailable, filtered, and error states.
|
||||
- Logout clears favorite state.
|
||||
|
||||
Backend tests should cover:
|
||||
|
||||
- Add favorite creates exactly one row and increments count once.
|
||||
- Re-adding existing favorite is idempotent and does not double-count.
|
||||
- Remove favorite deletes row and decrements count once.
|
||||
- Removing missing favorite is idempotent and does not decrement.
|
||||
- Batch IDs returns only IDs favorited by the current wallet.
|
||||
- Favorites list respects wallet scoping, sort, category, search, pagination, and includeUnavailable.
|
||||
- Legacy public counter endpoint no longer allows unauthenticated count manipulation.
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] Confirm backend wallet JWT middleware can protect `/api/me/*` routes.
|
||||
- [ ] Add backend migration for `user_favorites` and favorite count preservation.
|
||||
- [ ] Decide exact count maintenance strategy: materialized `resources.favorite_count` vs computed count.
|
||||
- [ ] Add `GET /api/me/favorites` with sort/filter/search/pagination/unavailable support.
|
||||
- [ ] Add `GET /api/me/favorites/ids` batch status endpoint.
|
||||
- [ ] Add `POST /api/me/favorites/{resourceId}` idempotent add endpoint.
|
||||
- [ ] Add `DELETE /api/me/favorites/{resourceId}` idempotent remove endpoint.
|
||||
- [ ] Deprecate or disable unauthenticated `POST /api/resources/{id}/favorite`.
|
||||
- [ ] Update backend API docs for favorites and legacy endpoint behavior.
|
||||
- [ ] Add frontend favorites API client.
|
||||
- [ ] Add frontend favorites state/hook with pending post-login action support.
|
||||
- [ ] Add reusable `FavoriteButton` component.
|
||||
- [ ] Add favorite buttons to resource cards/list components.
|
||||
- [ ] Add favorite button to detail/post page UI.
|
||||
- [ ] Replace `/favorites` Coming Soon page with real favorites list UI.
|
||||
- [ ] Add sorting, category filter, and scoped search to favorites page.
|
||||
- [ ] Add unavailable-resource display and remove action.
|
||||
- [ ] Add localized copy for all supported languages.
|
||||
- [ ] Add frontend tests for favorite button, pending login action, batch state, and favorites page states.
|
||||
- [ ] Add backend tests for add/remove/list/batch/count/deprecated endpoint behavior.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Should unavailable resources expose title/category only, or also old cover/description if still present in the database?
|
||||
2. Should newly adding a favorite be allowed for draft/private resources if a logged-in user somehow knows the ID? Recommendation: no.
|
||||
3. Should favorite counts update immediately in all visible lists after toggle, or only the clicked card? Recommendation: clicked card immediately; other instances can update when state is shared.
|
||||
4. Should wallet address casing be stored as checksum exactly or lowercase canonical form? It must match wallet auth claims consistently.
|
||||
5. Should the legacy public favorite endpoint be removed immediately or kept temporarily during deploy for backwards compatibility?
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Wallet login implementation details.
|
||||
- TokenPocket/RainbowKit login flows.
|
||||
- Collections/folders for favorites.
|
||||
- Sharing favorites publicly.
|
||||
- Admin editing of user favorites.
|
||||
- Import/export of favorites.
|
||||
- Notifications when favorited resources update.
|
||||
- Translating backend-returned resource content.
|
||||
@@ -6,7 +6,7 @@ This file is the first-stop context for AI coding agents working in this repo.
|
||||
|
||||
- Project: Arkie Library Frontend / ARK database web UI.
|
||||
- Package name: `ark-database-web`.
|
||||
- Stack: React 18, TypeScript, Vite, Tailwind CSS, React Router, RainbowKit/Wagmi.
|
||||
- Stack: React 18, TypeScript, Vite, Tailwind CSS, React Router.
|
||||
- Backend API is expected at `/api`; uploaded assets under `/uploads`.
|
||||
|
||||
## Branch rules
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
ARG VITE_WALLETCONNECT_PROJECT_ID=
|
||||
ARG VITE_API_URL=
|
||||
ARG VITE_ADMIN_UI_PREFIX=
|
||||
ARG VITE_ADMIN_ONLY=
|
||||
ARG VITE_DISABLE_ADMIN=
|
||||
ENV VITE_WALLETCONNECT_PROJECT_ID=$VITE_WALLETCONNECT_PROJECT_ID
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
ENV VITE_ADMIN_UI_PREFIX=$VITE_ADMIN_UI_PREFIX
|
||||
ENV VITE_ADMIN_ONLY=$VITE_ADMIN_ONLY
|
||||
|
||||
24
README.md
@@ -1,6 +1,6 @@
|
||||
# Arkie Library Frontend
|
||||
|
||||
React + Vite frontend for the ARK Library / ARK database site. The app serves public resource browsing, search, favorites, wallet login UI, and an optional admin UI for resource management.
|
||||
React + Vite frontend for the ARK Library / ARK database site. The app serves public resource browsing, search, favorites, and an optional admin UI for resource management.
|
||||
|
||||
## Tech stack
|
||||
|
||||
@@ -8,7 +8,6 @@ React + Vite frontend for the ARK Library / ARK database site. The app serves pu
|
||||
- Vite 5
|
||||
- React Router
|
||||
- Tailwind CSS
|
||||
- RainbowKit / Wagmi / Viem for wallet connection
|
||||
- Gitea Actions deploy workflow on `main`
|
||||
|
||||
## Quick start
|
||||
@@ -51,13 +50,18 @@ npm test
|
||||
|
||||
Create a local `.env` only when needed. Do not commit secrets. See `.env.example` for a template.
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `VITE_API_URL` | API/upload origin. Empty means same-origin and Vite dev proxy handles local `/api` and `/uploads`. Production deploy currently uses `https://api.ark-library.com`. |
|
||||
| `VITE_WALLETCONNECT_PROJECT_ID` | Reown / WalletConnect project id. Needed for QR/mobile wallet connection. |
|
||||
| `VITE_DISABLE_ADMIN` | When set to `"true"`, public build redirects admin routes away. Production public deploy sets this to `"true"`. |
|
||||
| `VITE_ADMIN_ONLY` | When set to `"true"`, builds the admin-only app entry instead of the public app. |
|
||||
| `VITE_ADMIN_UI_PREFIX` | Optional admin UI base path. If absent in admin-only mode, code uses the secret prefix from `src/adminPaths.ts`. |
|
||||
| Variable | Purpose |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `VITE_API_URL` | API/upload origin. Empty means same-origin and Vite dev proxy handles local `/api` and `/uploads`. Production deploy currently uses `https://api.ark-library.com`. |
|
||||
| `VITE_DISABLE_ADMIN` | When set to `"true"`, public build redirects admin routes away. Production public deploy sets this to `"true"`. |
|
||||
| `VITE_ADMIN_ONLY` | When set to `"true"`, builds the admin-only app entry instead of the public app. |
|
||||
| `VITE_ADMIN_UI_PREFIX` | Optional admin UI base path. If absent in admin-only mode, code uses the secret prefix from `src/adminPaths.ts`. |
|
||||
| `VITE_USE_MOCK_POSTS` | Telegram-style resource stream (`/browse`, `/category/:slug`) uses mock posts from `src/mocks/mockPosts.ts` only when set to `"true"`. Leave unset or set to `"false"` to hit the real `/api/posts` API. See `.unipi/docs/specs/2026-05-25-posts-api-contract.md`. |
|
||||
| `VITE_WALLETCONNECT_PROJECT_ID` | Reown/WalletConnect project ID used by the RainbowKit QR fallback for MetaMask/imToken. TokenPocket QR login does not use this. Required before testing or deploying the fallback scan flow. |
|
||||
|
||||
## Wallet login notes
|
||||
|
||||
Wallet login is used to attach a wallet address to user favorites. The frontend connects an injected wallet (`window.ethereum`), sends the selected address to `POST /api/auth/wallet/login`, and stores the returned wallet JWT in `localStorage` as a simple MVP session mechanism. This keeps the implementation small, but any future XSS vulnerability could expose a wallet session. A more secure future iteration should move wallet sessions to backend-set `httpOnly` cookies or shorten the token lifetime with refresh-token support.
|
||||
|
||||
## Project layout
|
||||
|
||||
@@ -67,7 +71,7 @@ src/
|
||||
App.tsx # public app + optional admin routes
|
||||
AppAdminOnly.tsx # admin-only app entry
|
||||
api.ts # fetch helpers and shared API types
|
||||
i18n.tsx # zh-TW / zh-CN / en copy dictionary
|
||||
i18n.tsx # zh-CN / en / ja / ko / vi / id / ms dictionary
|
||||
adminPaths.ts # admin UI prefix logic
|
||||
adminRouteTree.tsx # admin routes
|
||||
components/ # reusable public components
|
||||
|
||||
105
deploy/nginx-frontend-locations.inc
Normal file
@@ -0,0 +1,105 @@
|
||||
# Shared SPA locations. Browser calls same-origin /apnew/api/ (VITE_API_PREFIX=/apnew).
|
||||
# /apnew/api/ avoids ALB listener rule that sends /api/* to an unreachable backend target group.
|
||||
# Nginx proxies internally to ark-library-backend-1 (Tailscale); Host header for backend TLS.
|
||||
# Legacy /api/ locations are commented below for reference only.
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 5;
|
||||
gzip_types text/plain text/css application/javascript application/json application/xml image/svg+xml;
|
||||
gzip_min_length 256;
|
||||
|
||||
location ^~ /apnew/api/admin {
|
||||
return 404;
|
||||
}
|
||||
# Legacy same-origin /api admin block. Disabled while production uses /apnew/api.
|
||||
# location ^~ /api/admin {
|
||||
# return 404;
|
||||
# }
|
||||
location ^~ /admin {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location ^~ /apnew/api/ {
|
||||
proxy_pass https://100.93.205.19/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_set_header Host api.ark-library.com;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 512m;
|
||||
client_body_timeout 600s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
}
|
||||
|
||||
# Legacy same-origin /api proxy. Disabled while production uses /apnew/api.
|
||||
# location ^~ /api/ {
|
||||
# proxy_pass https://100.93.205.19/api/;
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_ssl_server_name on;
|
||||
# proxy_set_header Host api.ark-library.com;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# client_max_body_size 512m;
|
||||
# client_body_timeout 600s;
|
||||
# proxy_read_timeout 600s;
|
||||
# proxy_send_timeout 600s;
|
||||
# }
|
||||
|
||||
location ^~ /uploads/ {
|
||||
proxy_pass https://100.93.205.19/uploads/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_set_header Host api.ark-library.com;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /health {
|
||||
default_type text/plain;
|
||||
return 200 "ok\n";
|
||||
}
|
||||
|
||||
location = /healthz {
|
||||
default_type text/plain;
|
||||
return 200 "ok\n";
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
}
|
||||
|
||||
# Exact `/` so the HTML shell is never edge-cached without validators (avoids stale index.html → 404 on hashed /index-*.js or /assets/*).
|
||||
location = / {
|
||||
try_files /index.html =404;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
}
|
||||
|
||||
location = /assets/logo-primary.webp {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "public, max-age=3600, stale-while-revalidate=86400" always;
|
||||
}
|
||||
location ^~ /assets/ark-library/ {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "public, max-age=86400, stale-while-revalidate=604800" always;
|
||||
}
|
||||
|
||||
location ^~ /assets/ {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
}
|
||||
|
||||
# Hashed entry chunk at /index-[hash].js (Vite entryFileNames). Do not 308 to /assets — file lives here.
|
||||
location ~* ^/index-[A-Za-z0-9_-]+\.(js|mjs)$ {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
18
deploy/nginx-frontend-native.conf
Normal file
@@ -0,0 +1,18 @@
|
||||
# Native system nginx (not Docker). SPA root: /var/www/ark-library
|
||||
# Snippet: /etc/nginx/snippets/ark-library-frontend.inc
|
||||
# ALB terminates TLS; apex uses X-Forwarded-Proto so we do not 301-loop behind the LB.
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name ark-library.com www.ark-library.com;
|
||||
|
||||
if ($http_x_forwarded_proto != "https") {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
root /var/www/ark-library;
|
||||
index index.html;
|
||||
|
||||
include /etc/nginx/snippets/ark-library-frontend.inc;
|
||||
}
|
||||
161
docs/backend-requirements-wallet-favorites.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# 后端需求与核对文档:钱包登录 + 收藏
|
||||
|
||||
> 面向后端(`Arkie-Library-Backend`)。
|
||||
> 本文是在前端审计「登录 + 收藏」bug 与 UI 重设计时,对后端现有实现的逐条核对,以及由此产生的后端待办。
|
||||
>
|
||||
> **核心结论:后端目前几乎已经满足前端全部需求。本次重设计与 bug 修复基本是纯前端工作。后端真正需要新增的只有少量「可选项」,外加几处需要确认的契约。请不要把已完成的功能再派一遍。**
|
||||
|
||||
日期:2026-06-02(2026-06-02 更新:新增 §0.5 部署阻塞)
|
||||
相关分支:`terry-wallet-login`
|
||||
|
||||
---
|
||||
|
||||
## 0.5 🔴 关键阻塞:线上后端是旧版本,收藏与 TokenPocket 端点未部署
|
||||
|
||||
> 源码(`Arkie-Library-Backend`)里这些端点都有;但**当前线上 / dev 代理指向的后端**(`https://ark-library.com/apnew`)是**旧构建**,下列路由全部 404。
|
||||
> **前端已就绪,但收藏与 TokenPocket 扫码登录在生产上无法工作,直到后端把含这些路由的版本部署上线。** 这正是用户实测「收藏用不了、扫码登录用不了」的根因。
|
||||
|
||||
curl 实证(经 vite dev 代理打到线上后端,2026-06-02):
|
||||
|
||||
| 端点 | 方法 | 实测状态 | 结论 |
|
||||
|---|---|---|---|
|
||||
| `/api/auth/wallet/nonce` | POST | **200** | ✅ 已部署 |
|
||||
| `/api/auth/wallet/verify` | POST | **400**(入参错误)| ✅ 已部署 |
|
||||
| `/api/auth/wallet/me` | GET | **401**(需鉴权)| ✅ 已部署 |
|
||||
| `/api/auth/wallet/tp-login-request` | POST | **404** | ❌ 未部署 |
|
||||
| `/api/auth/wallet/tp-result` | GET | **404** | ❌ 未部署 |
|
||||
| `/api/auth/wallet/tp-callback` | POST | **404** | ❌ 未部署 |
|
||||
| `/api/me/favorites` | GET | **404**(应为 401)| ❌ 未部署 |
|
||||
| `/api/me/favorites/ids` | GET | **404** | ❌ 未部署 |
|
||||
| `/api/me/favorites/{id}` | POST | **404** | ❌ 未部署 |
|
||||
|
||||
**后端动作(必做,按优先级最高):**
|
||||
1. 部署包含 favorites(`internal/handlers/favorites.go`)+ TokenPocket(`internal/handlers/wallet_tp.go`)路由的后端版本(`cmd/server/main.go` 已注册这些路由)。
|
||||
2. 为 TokenPocket 登录设置 `PUBLIC_BASE_URL`(`buildTokenPocketSignURL` 需要它生成回调 URL,否则 tp-login-request 会 500)。
|
||||
3. 确保 `wallet_tp_login_requests` / `user_favorites` 等表已迁移(`EnsureWalletAuthSchema` 会建表)。
|
||||
|
||||
> 复测命令:`curl -s -o /dev/null -w "%{http_code}" -X POST https://ark-library.com/apnew/api/auth/wallet/tp-login-request -d '{}'` 应返回 200(且 body 的 `qrUrl` 形如 `tpoutside://pull.activity?param=...`)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话给后端
|
||||
|
||||
> 钱包认证、TokenPocket 扫码、收藏列表/筛选/分页/可用性,**都已实现且符合前端契约**。
|
||||
> 下面 §1 是「已完成、勿动」的核对;§2 是「真正可能要后端做的事」;§3 是「前端会改但与后端无关,别误接」。
|
||||
|
||||
---
|
||||
|
||||
## 1. 已实现并符合前端契约(✅ 无需改动)
|
||||
|
||||
逐条核对自后端源码(`internal/handlers/wallet_auth.go`、`wallet_tp.go`、`favorites.go`、`public.go`、`cmd/server/main.go`)。
|
||||
|
||||
### 1.1 钱包认证
|
||||
|
||||
| 端点 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| `POST /api/auth/wallet/nonce` | ✅ | 返回 `{nonce, message}`,message 含一次性码,写入 `wallet_auth_nonces`,TTL 15 分钟 |
|
||||
| `POST /api/auth/wallet/verify` | ✅ | EIP-191 `personal_sign` 验签恢复地址,签发 JWT |
|
||||
| `GET /api/auth/wallet/me` | ✅ | Bearer JWT → `{wallet, role:"user"}` |
|
||||
|
||||
关键事实(对前端 bug 很重要):
|
||||
|
||||
- **验签完全链无关。** `recoverPersonalSign` 只做 EIP-191 文本哈希恢复,不校验任何 chainId。签名消息文案是
|
||||
`"ARK Database — wallet sign-in … Sign this message to log in. No transaction or gas fee."`,**不引用任何链**。
|
||||
→ 因此前端登录时强制切到 BNB 链(`ensureBnbChain`)是**多余的**,删除它**不影响后端**。这是一项纯前端修复。
|
||||
- JWT:HS256,**有效期 30 天**(`SignUserWallet(..., 30*24h)`),无状态。
|
||||
- nonce 用后即删,过期自动清理。
|
||||
|
||||
### 1.2 TokenPocket 扫码登录
|
||||
|
||||
| 端点 | 状态 |
|
||||
|---|---|
|
||||
| `POST /api/auth/wallet/tp-login-request` | ✅ 生成 actionId/nonce/message/qrUrl,写 `wallet_tp_login_requests` |
|
||||
| `POST /api/auth/wallet/tp-callback` | ✅ 钱包回调写入签名,校验 `callbackToken` |
|
||||
| `GET /api/auth/wallet/tp-result?actionId=` | ✅ 轮询返回 `pending/completed/expired/failed` |
|
||||
|
||||
→ 前端把扫码从「手机端」挪到「桌面端」只是 UI 位置调整,**后端无需改动**。
|
||||
|
||||
### 1.3 收藏
|
||||
|
||||
| 端点 | 状态 | 支持的能力 |
|
||||
|---|---|---|
|
||||
| `GET /api/me/favorites` | ✅ | `q`(title/description/body_text/tag ILIKE)、`category`(slug)、`sort`(`favorited_at`/`published_at`/`hot`)、`includeUnavailable`(默认 true)、`page`/`limit`(≤100)、返回 `total`、tags、`favoriteCount`、`availability` |
|
||||
| `GET /api/me/favorites/ids?resourceIds=` | ✅ | 批量查询收藏状态 |
|
||||
| `POST /api/me/favorites/{id}` | ✅ | 加收藏,返回 `{ok,resourceId,favorited,favoriteCount}` |
|
||||
| `DELETE /api/me/favorites/{id}` | ✅ | 取消收藏,`favorite_count` 不低于 `favorite_base_count` |
|
||||
|
||||
关键事实:
|
||||
|
||||
- **下架资源可用性已支持。** `scanFavoriteItem` 会把 `status!='published' 或 is_public=false` 的资源标为
|
||||
`availability:"unavailable"`,且默认 `includeUnavailable=true` 仍返回。→ 前端「不可用资源卡片」逻辑后端已就绪。
|
||||
- `sort=hot` 定义 = `download_count + favorite_count + share_count` 降序。
|
||||
- 鉴权失败统一返回 **401**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 真正可能需要后端做的事
|
||||
|
||||
按优先级。除 2.1 外多为**可选/按产品决定**。
|
||||
|
||||
### 2.1 【需确认】CORS 允许前端源 + Authorization 头
|
||||
|
||||
前端通过 `apiBase` 调 `/api/me/favorites`,并带 `Authorization: Bearer <jwt>`。
|
||||
若前端与 API 不同源,需确认 CORS 允许:
|
||||
|
||||
- 来源:前端正式域名(及预览/本地开发源)
|
||||
- 方法:`GET, POST, DELETE`
|
||||
- 请求头:`Authorization, Content-Type`
|
||||
|
||||
**动作**:确认现有 CORS 配置覆盖以上;若 `apiBase` 同源则可忽略。
|
||||
|
||||
### 2.2 【可选】服务端登出 / Token 失效
|
||||
|
||||
现状:JWT 无状态,前端「断开连接」只清本地 localStorage,旧 token 在 30 天内仍有效。
|
||||
|
||||
若产品需要「真正的远程登出 / 失效被盗 token」,后端需引入二选一:
|
||||
|
||||
- token 版本号(用户级 `token_version`,签发与校验时比对);或
|
||||
- token 黑名单(jti 撤销表)
|
||||
|
||||
**默认建议**:第一版**不做**,保持无状态。仅在有安全需求时再做。
|
||||
|
||||
### 2.3 【可选】缩短或可配置 JWT 有效期
|
||||
|
||||
现为固定 30 天。若希望更安全或可配置,可将 TTL 提为环境变量(如 `USER_JWT_TTL`)。
|
||||
|
||||
**默认建议**:30 天对「只验证地址、无资产操作」的场景可接受,可暂不动。
|
||||
|
||||
### 2.4 【按产品决定】MetaMask / imToken 扫码兜底(WalletConnect/Reown)
|
||||
|
||||
如果前端最终保留 WalletConnect 扫码路径:**后端无需任何改动**——`/verify` 接受任何 `personal_sign` 签名,与连接方式无关。
|
||||
此项列出只为说明「即便前端接了 WalletConnect,也不产生后端工作」。
|
||||
|
||||
### 2.5 【可选打磨】收藏列表 `q` 搜索性能
|
||||
|
||||
当前 `q` 用多列 `ILIKE '%..%'`,数据量大时无法走索引。量级变大后可考虑 `pg_trgm` 或全文索引。
|
||||
|
||||
**默认建议**:当前数据量下不必做,记录备查。
|
||||
|
||||
---
|
||||
|
||||
## 3. 前端会改、但与后端无关(请勿误派给后端)
|
||||
|
||||
这些是本次 bug/重设计的主体,**全部在前端完成,不涉及后端**:
|
||||
|
||||
1. 删除登录时强制切 BNB 链(`ensureBnbChain`)—— 验签链无关(见 §1.1)。
|
||||
2. 桌面登录弹窗简化为「使用浏览器钱包登录」单一主操作;扫码降级为「其他方式」。
|
||||
3. 扫码从手机端挪到桌面端。
|
||||
4. 手机端「打开钱包 App」死路修复(反馈、未安装兜底)。
|
||||
5. 移除/收敛未被登录流程使用的 RainbowKit/WalletConnect 装配(纯前端依赖与体积问题)。
|
||||
6. 全站「我的收藏」入口缺失(导航 / 手机菜单 / 钱包下拉加入口)。
|
||||
7. 收藏按钮状态视觉、收藏页筛选区移动端密度、空/错误状态打磨。
|
||||
8. token 过期时前端自动登出并引导重新登录(消费后端已返回的 401,无需后端改)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 给后端的「确认清单」
|
||||
|
||||
- [ ] §2.1 CORS 是否已允许前端源 + `Authorization` 头?(唯一可能的必做项)
|
||||
- [ ] 是否需要 §2.2 服务端登出/撤销?(默认否)
|
||||
- [ ] 是否需要 §2.3 可配置 JWT TTL?(默认否,维持 30 天)
|
||||
- [ ] 知悉:§3 全部为前端工作,无需后端介入。
|
||||
333
docs/backend-wallet-favorites-production-fixes.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# Backend fixes required for Wallet Login + Favorites production readiness
|
||||
|
||||
Date: 2026-06-04
|
||||
Environment tested: `https://arkie-library-stag.com/apnew/api`
|
||||
|
||||
## Summary
|
||||
|
||||
Frontend has been updated to the new backend contract:
|
||||
|
||||
- Wallet login: `POST /api/auth/wallet/login` with `{ address }`
|
||||
- Wallet session check: `GET /api/auth/wallet/me` with `Authorization: Bearer <token>`
|
||||
- Favorites list/status: `GET /api/favorites` and `GET /api/favorites?ids=...`
|
||||
- Favorite mutation: `POST /api/posts/{id}/favorite` with `{ add: true|false }`
|
||||
|
||||
Staging confirms the new login endpoint works, but favorite mutation currently accepts an invalid Bearer token. This must be fixed before production trust.
|
||||
|
||||
---
|
||||
|
||||
## Priority 0 — Fix favorite mutation authentication
|
||||
|
||||
### Current staging behavior
|
||||
|
||||
The following request currently returns `200 OK` even with an invalid token:
|
||||
|
||||
```bash
|
||||
curl -i -X POST \
|
||||
"https://arkie-library-stag.com/apnew/api/posts/8f4a571c-3477-4b05-91be-d85907048de5/favorite" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer invalid-token" \
|
||||
--data '{"add":true}'
|
||||
```
|
||||
|
||||
Observed response:
|
||||
|
||||
```http
|
||||
HTTP 200
|
||||
{"ok":true}
|
||||
```
|
||||
|
||||
### Required behavior
|
||||
|
||||
`POST /api/posts/{id}/favorite` must require a valid wallet JWT.
|
||||
|
||||
Invalid, missing, expired, malformed, or unverifiable tokens must return:
|
||||
|
||||
```http
|
||||
HTTP 401 Unauthorized
|
||||
```
|
||||
|
||||
Recommended response body:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "unauthorized"
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance tests
|
||||
|
||||
#### Missing token
|
||||
|
||||
```bash
|
||||
curl -i -X POST \
|
||||
"https://arkie-library-stag.com/apnew/api/posts/{postId}/favorite" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '{"add":true}'
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```http
|
||||
HTTP 401
|
||||
```
|
||||
|
||||
#### Invalid token
|
||||
|
||||
```bash
|
||||
curl -i -X POST \
|
||||
"https://arkie-library-stag.com/apnew/api/posts/{postId}/favorite" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer invalid-token" \
|
||||
--data '{"add":true}'
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```http
|
||||
HTTP 401
|
||||
```
|
||||
|
||||
#### Valid token
|
||||
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST \
|
||||
"https://arkie-library-stag.com/apnew/api/auth/wallet/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '{"address":"0x0000000000000000000000000000000000000001"}' \
|
||||
| jq -r .token)
|
||||
|
||||
curl -i -X POST \
|
||||
"https://arkie-library-stag.com/apnew/api/posts/{postId}/favorite" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
--data '{"add":true}'
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```http
|
||||
HTTP 200
|
||||
```
|
||||
|
||||
Response should include at least:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"favorited": true
|
||||
}
|
||||
```
|
||||
|
||||
Then cancel:
|
||||
|
||||
```bash
|
||||
curl -i -X POST \
|
||||
"https://arkie-library-stag.com/apnew/api/posts/{postId}/favorite" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
--data '{"add":false}'
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"favorited": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Priority 1 — Confirm wallet login security model
|
||||
|
||||
### Current contract
|
||||
|
||||
```http
|
||||
POST /api/auth/wallet/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"address": "0x..."
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "<jwt>",
|
||||
"wallet": "0x..."
|
||||
}
|
||||
```
|
||||
|
||||
This is what the frontend now uses.
|
||||
|
||||
### Production risk
|
||||
|
||||
This flow does not prove wallet ownership. Any client can submit any wallet address and receive a token for that address.
|
||||
|
||||
If wallet identity is only used for low-risk favorites, this may be acceptable as an MVP. If wallet identity will be used for user identity, permissions, membership, rewards, asset ownership, admin behavior, or anything security-sensitive, backend should require signature verification.
|
||||
|
||||
### Recommended secure production flow
|
||||
|
||||
If stronger security is required, backend should use nonce + signature:
|
||||
|
||||
1. `POST /api/auth/wallet/nonce` with `{ address }`
|
||||
2. Backend returns a one-time message / nonce.
|
||||
3. Frontend asks wallet to sign the message.
|
||||
4. `POST /api/auth/wallet/verify` with `{ address, message, signature }`
|
||||
5. Backend verifies recovered address equals requested address.
|
||||
6. Backend issues JWT.
|
||||
|
||||
If backend decides to keep the simplified `{ address }` login, please explicitly confirm that this is an accepted production risk.
|
||||
|
||||
---
|
||||
|
||||
## Priority 2 — Normalize favorites response contract
|
||||
|
||||
Frontend currently supports the staging response shape, but the response must be made explicit and self-sufficient. The frontend renders favorites as plain strings and does not perform per-resource translation, slug-to-name lookup, category fetching, or localization fallback.
|
||||
|
||||
### `lang` semantics
|
||||
|
||||
`?lang=<ui-lang>` on `GET /api/favorites` is a **display resolution hint**, not a filter. It must NOT filter favorites by post language. A user who favorited Chinese and English posts must see both regardless of `lang`. `lang` only tells the backend which language to resolve display strings into.
|
||||
|
||||
**Current staging behavior is wrong**: sending `?lang=en` on staging returns zero items for users whose favorites are Chinese posts, and vice versa. Because of this, the frontend currently does NOT send `lang` on `GET /api/favorites`. Once the backend treats `lang` as a resolve hint instead of a filter, the frontend will send `lang` again so resolved strings come back in the user's UI language.
|
||||
|
||||
### Favorites list
|
||||
|
||||
```http
|
||||
GET /api/favorites?lang=&limit=&page=&sort=&category=&q=
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
Required production response:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "...",
|
||||
"title": "...",
|
||||
"description": "...",
|
||||
"type": "...",
|
||||
"categoryId": 11,
|
||||
"categorySlug": "official-assets",
|
||||
"categoryName": "...",
|
||||
"language": "...",
|
||||
"sourceLanguage": "...",
|
||||
"coverImage": "...",
|
||||
"updatedAt": "...",
|
||||
"publishedAt": "...",
|
||||
"favoriteCount": 0,
|
||||
"availability": "available"
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
"limit": 24,
|
||||
"total": 0
|
||||
}
|
||||
```
|
||||
|
||||
Fields that must be present and pre-resolved by the backend when `lang` is supplied:
|
||||
|
||||
- `title` — already in `lang`. If a translation does not exist, fall back to the post's source language.
|
||||
- `description` — same rule as `title`.
|
||||
- `categoryName` — localized category name for `lang`. Frontend must not look up categories by slug.
|
||||
- `type` — a string the frontend can display directly. If you need both a raw type code and a label, add `typeLabel` and use that for display.
|
||||
- `language` — a human-readable label for the post's source language, in `lang`. e.g. for `lang=zh-CN` a Chinese post returns `language: "中文"`. If you prefer to keep `language` as a code, add `languageLabel` and use it for display.
|
||||
- `coverImage` — a usable image URL. The frontend will not fall back to attachment arrays.
|
||||
- `updatedAt`, `publishedAt` — ISO timestamps.
|
||||
- `favoriteCount` — optional but recommended.
|
||||
- `availability` — `"available" | "unavailable"`.
|
||||
|
||||
`page`, `limit`, and `total` are needed for correct pagination.
|
||||
|
||||
The frontend must never need to: load `/api/categories`, parse `localizations` maps, walk `attachments`, or translate `type` / `language` codes for this page.
|
||||
|
||||
### Favorite status by ids
|
||||
|
||||
```http
|
||||
GET /api/favorites?ids=id1,id2,id3
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
Current staging response observed:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
```
|
||||
|
||||
This works, but for frontend performance and clarity, recommended response is:
|
||||
|
||||
```json
|
||||
{
|
||||
"ids": ["id1", "id3"]
|
||||
}
|
||||
```
|
||||
|
||||
Meaning: only IDs that are already favorited by the current wallet user.
|
||||
|
||||
Frontend currently accepts both:
|
||||
|
||||
- `{ ids: string[] }`
|
||||
- `{ items: Resource[] }`
|
||||
|
||||
But backend should document and standardize one shape.
|
||||
|
||||
---
|
||||
|
||||
## Priority 3 — Required status codes
|
||||
|
||||
Please standardize these responses:
|
||||
|
||||
| Case | Expected status |
|
||||
| --- | --- |
|
||||
| Missing Bearer token on protected endpoint | `401` |
|
||||
| Invalid/expired Bearer token | `401` |
|
||||
| Valid token but post ID does not exist | `404` |
|
||||
| Invalid JSON body | `400` |
|
||||
| Invalid `add` value | `400` |
|
||||
| Successful favorite add/remove | `200` |
|
||||
|
||||
Protected endpoints:
|
||||
|
||||
- `GET /api/auth/wallet/me`
|
||||
- `GET /api/favorites`
|
||||
- `GET /api/favorites?ids=...`
|
||||
- `POST /api/posts/{id}/favorite`
|
||||
|
||||
---
|
||||
|
||||
## Frontend compatibility notes
|
||||
|
||||
The frontend currently calls these staging paths through the same-origin prefix:
|
||||
|
||||
```txt
|
||||
/apnew/api/auth/wallet/login
|
||||
/apnew/api/auth/wallet/me
|
||||
/apnew/api/favorites
|
||||
/apnew/api/favorites?ids=...
|
||||
/apnew/api/posts/{id}/favorite
|
||||
```
|
||||
|
||||
In frontend source this is written as `/api/...`; staging build uses `VITE_API_PREFIX=/apnew`.
|
||||
|
||||
Please keep backend routes under `/api/...` behind the proxy.
|
||||
|
||||
---
|
||||
|
||||
## Final production checklist
|
||||
|
||||
Backend should confirm all of the following before production release:
|
||||
|
||||
- [ ] `POST /api/posts/{id}/favorite` rejects missing token with `401`.
|
||||
- [ ] `POST /api/posts/{id}/favorite` rejects invalid token with `401`.
|
||||
- [ ] `POST /api/posts/{id}/favorite` only changes favorites for the wallet from the validated JWT.
|
||||
- [ ] `GET /api/favorites` requires a valid Bearer token.
|
||||
- [ ] `GET /api/favorites?ids=...` requires a valid Bearer token, unless explicitly declared public/legacy.
|
||||
- [ ] `GET /api/auth/wallet/me` validates token and returns the wallet address from the token.
|
||||
- [ ] Backend explicitly confirms whether simplified `{ address }` login is acceptable for production, or switches to nonce/signature verification.
|
||||
91
docs/cloudflare-cache-purge.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Cloudflare Cache Purge Manual
|
||||
|
||||
Use this when the deployed frontend has already updated on the servers, but the public site still shows an old version because Cloudflare or browser cache is serving stale files.
|
||||
|
||||
## Current API-token note
|
||||
|
||||
The current `ark-library.com` Cloudflare Zone ID is:
|
||||
|
||||
```text
|
||||
ca1368486d3b0bf9f1066f2a2281dced
|
||||
```
|
||||
|
||||
Cloudflare cache purge by API requires both a valid API token and this Zone ID. If API access is unavailable, use the manual dashboard method below.
|
||||
|
||||
> Security note: if a token was pasted into chat, terminal logs, or docs by mistake, revoke it in Cloudflare and create a new one.
|
||||
|
||||
## Manual purge in Cloudflare Dashboard
|
||||
|
||||
1. Open <https://dash.cloudflare.com/> and log in.
|
||||
2. Go to **Websites**.
|
||||
3. Select **ark-library.com**.
|
||||
4. Go to **Caching** → **Configuration**.
|
||||
5. Click **Purge Cache**.
|
||||
6. Choose one of these:
|
||||
- **Purge Everything**: safest when a deploy looks stale.
|
||||
- **Custom Purge**: use when only specific pages/assets are stale.
|
||||
7. For a stale frontend deploy, purge at least:
|
||||
- `https://ark-library.com/`
|
||||
- `https://ark-library.com/index.html`
|
||||
8. Wait 10–60 seconds, then hard-refresh the browser:
|
||||
- macOS Chrome/Safari: `Cmd + Shift + R`
|
||||
- Windows/Linux Chrome: `Ctrl + Shift + R`
|
||||
|
||||
## Find the Cloudflare Zone ID
|
||||
|
||||
1. Open <https://dash.cloudflare.com/>.
|
||||
2. Select **ark-library.com**.
|
||||
3. Open **Overview**.
|
||||
4. In the right sidebar, find **API** → **Zone ID**.
|
||||
5. Copy the Zone ID for API purge commands.
|
||||
|
||||
For this project, the known Zone ID is `ca1368486d3b0bf9f1066f2a2281dced`.
|
||||
|
||||
## API purge after you have Zone ID
|
||||
|
||||
Store the token in an environment variable instead of writing it directly into commands:
|
||||
|
||||
```bash
|
||||
export CLOUDFLARE_API_TOKEN='replace-with-new-token'
|
||||
export CLOUDFLARE_ZONE_ID='replace-with-zone-id'
|
||||
```
|
||||
|
||||
Purge everything:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST \
|
||||
"https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/purge_cache" \
|
||||
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '{"purge_everything":true}'
|
||||
```
|
||||
|
||||
Purge only the main frontend files:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST \
|
||||
"https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/purge_cache" \
|
||||
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '{"files":["https://ark-library.com/","https://ark-library.com/index.html"]}'
|
||||
```
|
||||
|
||||
Expected success response includes:
|
||||
|
||||
```json
|
||||
{"success":true}
|
||||
```
|
||||
|
||||
## Create a safer Cloudflare API token
|
||||
|
||||
1. In Cloudflare Dashboard, open **My Profile** → **API Tokens**.
|
||||
2. Click **Create Token**.
|
||||
3. Use **Custom token**.
|
||||
4. Add permissions:
|
||||
- `Zone` → `Cache Purge` → `Purge`
|
||||
- `Zone` → `Zone` → `Read`
|
||||
5. Set zone resources:
|
||||
- `Include` → `Specific zone` → `ark-library.com`
|
||||
6. Create the token, copy it once, and store it in a password manager or CI secret.
|
||||
|
||||
Do not commit Cloudflare tokens into this repository.
|
||||
258
docs/link-preview.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# Link preview (`/api/link-preview`)
|
||||
|
||||
Telegram-style rich card for the **first URL** found in a post's text.
|
||||
Front-end renders a single clickable card showing site name, title,
|
||||
description, and a thumbnail; the data is fetched from a back-end proxy
|
||||
that scrapes Open Graph / oEmbed / Twitter Card metadata once and caches
|
||||
it.
|
||||
|
||||
> **Scope**: only the first link in the post text gets a preview, matching
|
||||
> Telegram's behaviour. Any additional URLs in the same post still render
|
||||
> as inline autolinks but do not get their own card.
|
||||
|
||||
## Why a back-end proxy
|
||||
|
||||
Browsers cannot fetch arbitrary cross-origin pages, so OG metadata must be
|
||||
fetched server-side. A single proxy endpoint keeps secrets / outbound IPs on
|
||||
the server and lets us cache so each URL is only scraped once for the whole
|
||||
audience.
|
||||
|
||||
---
|
||||
|
||||
## Endpoint contract
|
||||
|
||||
```
|
||||
GET /api/link-preview?url=<encoded-absolute-url>
|
||||
```
|
||||
|
||||
| Query | Required | Notes |
|
||||
| ----- | -------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `url` | yes | Absolute `http://` or `https://` URL. Must be `URI` encoded so query strings inside the target URL survive the round trip. |
|
||||
|
||||
### Success — `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://app.safe.global/welcome",
|
||||
"canonicalUrl": "https://app.safe.global/welcome",
|
||||
"siteName": "app.safe.global",
|
||||
"title": "Safe{Wallet}",
|
||||
"description": "Safe{Wallet} is the most trusted smart account wallet on Ethereum with over $100B secured.",
|
||||
"imageUrl": "https://app.safe.global/og.png",
|
||||
"imageWidth": 1200,
|
||||
"imageHeight": 630,
|
||||
"favicon": "https://app.safe.global/favicon.ico",
|
||||
"themeColor": "#12FF80",
|
||||
"fetchedAt": "2026-05-29T10:00:00Z",
|
||||
"cacheTtlSeconds": 86400
|
||||
}
|
||||
```
|
||||
|
||||
- All string fields except `url` may be empty. The front-end gracefully hides
|
||||
rows that are missing (e.g. no `imageUrl` → image area is omitted).
|
||||
- `url` echoes the original input so the client can match the response
|
||||
against the URL it asked about, even if the request was racy.
|
||||
- `canonicalUrl` is the URL the client should open when the card is tapped.
|
||||
Defaults to `url` if no `<link rel=canonical>` was found.
|
||||
|
||||
### Already cached / freshly cached — same shape
|
||||
|
||||
The endpoint is idempotent and the response shape is identical whether
|
||||
the metadata is hot, warm, or freshly scraped.
|
||||
|
||||
### Errors
|
||||
|
||||
| Status | When | Body shape |
|
||||
| ------ | --------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `400` | Missing / invalid / non-http(s) `url` | `{ "error": "invalid_url" }` |
|
||||
| `422` | URL passed validation but resolves to a private/internal address (SSRF guard) | `{ "error": "blocked_target" }` |
|
||||
| `404` | Target returned 404 or fetch produced no metadata | `{ "error": "not_found" }` |
|
||||
| `408` | Target took longer than the timeout to respond | `{ "error": "timeout" }` |
|
||||
| `502` | Target returned 5xx | `{ "error": "upstream_error" }` |
|
||||
| `429` | Rate limit on this client / IP | `{ "error": "rate_limited", "retryAfter": 60 }` |
|
||||
|
||||
The front-end treats every non-`200` as “no preview available” and
|
||||
silently renders nothing. No toasts. URLs already render as inline
|
||||
clickable text via `autolink`, so the user is never blocked.
|
||||
|
||||
---
|
||||
|
||||
## Caching strategy
|
||||
|
||||
Store one row per `canonicalUrl` (or normalized `url` if `canonicalUrl` is
|
||||
absent). Suggested TTLs:
|
||||
|
||||
- Successful preview: **24 hours** (`cacheTtlSeconds: 86400`).
|
||||
- 404 / timeout / blocked: **6 hours** negative cache. Otherwise transient
|
||||
failures on the target site will hammer the proxy.
|
||||
- Send `Cache-Control: public, max-age=86400` so CDN / browser also cache.
|
||||
|
||||
Cache key normalization:
|
||||
- Lowercase scheme + host.
|
||||
- Strip the trailing slash on the path when it's the only character.
|
||||
- Strip `utm_*`, `ref`, `referrer`, `fbclid`, `gclid` query params.
|
||||
- Keep the rest of the query and fragment as-is.
|
||||
|
||||
---
|
||||
|
||||
## SSRF and abuse guard (must-have)
|
||||
|
||||
The proxy will fetch any URL the front-end asks about, which is dangerous.
|
||||
Before issuing the outbound request:
|
||||
|
||||
1. Resolve the host to all of its A/AAAA records.
|
||||
2. Reject if any resolved IP is in: loopback, link-local, private
|
||||
(RFC1918), `0.0.0.0/8`, multicast, broadcast, or the internal cluster
|
||||
CIDR.
|
||||
3. Reject schemes other than `http` and `https`.
|
||||
4. Cap response body at **5 MB**; abort on overflow.
|
||||
5. Cap request total time at **5 s**; abort on timeout.
|
||||
6. Cap redirect chain at **3 hops**; re-validate target IP at each hop.
|
||||
7. Do not forward client cookies, auth headers, or `Referer` to the target.
|
||||
8. Use a clear `User-Agent` such as `ArkLibraryLinkBot/1.0 (+https://ark-library.com/bot)`.
|
||||
9. Per-client (IP or session) rate limit, e.g. 60 req / min.
|
||||
|
||||
---
|
||||
|
||||
## Metadata extraction precedence
|
||||
|
||||
For each field, pick the first present:
|
||||
|
||||
| Field | Sources (in order) |
|
||||
| ------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `title` | `og:title` → `twitter:title` → `<title>` → empty |
|
||||
| `description` | `og:description` → `twitter:description` → `<meta name="description">` → empty |
|
||||
| `imageUrl` | `og:image:secure_url` → `og:image` → `twitter:image` → first prominent `<img>` (skip if <200×200) → empty |
|
||||
| `siteName` | `og:site_name` → `application-name` → hostname (sans `www.`) |
|
||||
| `canonicalUrl`| `<link rel="canonical">` → request URL |
|
||||
| `favicon` | `<link rel="icon">` → `<link rel="shortcut icon">` → `/favicon.ico` |
|
||||
| `themeColor` | `<meta name="theme-color">` |
|
||||
|
||||
Resolve any relative URLs (`og:image`, `favicon`, `canonical`) against the
|
||||
final response URL (after redirects).
|
||||
|
||||
---
|
||||
|
||||
## Provider quirks worth handling
|
||||
|
||||
These quirks save a lot of "why doesn't this site preview?" debugging later.
|
||||
|
||||
- **Twitter / X**: `x.com` and `twitter.com` strip OG when not signed in. Use
|
||||
the public oEmbed endpoint
|
||||
`https://publish.twitter.com/oembed?url=...&omit_script=1` for
|
||||
Twitter/X URLs and map: `title = author_name`, `description = html` stripped
|
||||
to text, `imageUrl = thumbnail_url` if available.
|
||||
- **YouTube**: prefer `https://noembed.com/embed?url=...` or
|
||||
`https://www.youtube.com/oembed?url=...&format=json` (no key).
|
||||
- **Reddit / Mastodon**: standard OG works fine.
|
||||
- **Sites behind Cloudflare bot challenge**: surface 502 to the client.
|
||||
Don't retry hot — let the negative-cache TTL absorb it.
|
||||
- **AMP pages**: prefer `og:url` when present so the cached entry points to
|
||||
the canonical page, not the AMP variant.
|
||||
|
||||
---
|
||||
|
||||
## Front-end integration
|
||||
|
||||
### Type addition (`src/types/post.ts`)
|
||||
|
||||
```ts
|
||||
export type LinkPreview = {
|
||||
url: string;
|
||||
canonicalUrl: string;
|
||||
siteName: string;
|
||||
title: string;
|
||||
description: string;
|
||||
imageUrl?: string;
|
||||
imageWidth?: number;
|
||||
imageHeight?: number;
|
||||
favicon?: string;
|
||||
themeColor?: string;
|
||||
};
|
||||
|
||||
export type Post = {
|
||||
// ...existing fields
|
||||
/** Preview for the first URL in `text`. At most one per post. */
|
||||
linkPreview?: LinkPreview;
|
||||
};
|
||||
```
|
||||
|
||||
### Which URL gets previewed
|
||||
|
||||
The back-end picks the **first** URL it finds in `text` using the same
|
||||
regex as the front-end's `autolink` (`/(https?:\/\/[^\s<>"]+[^\s<>".,;:!?)\]}'])/i`).
|
||||
Only that URL is fetched, stored, and returned as `post.linkPreview`. Any
|
||||
later URLs in the same post are ignored for preview purposes (still
|
||||
clickable inline via `autolink`).
|
||||
|
||||
### Where data comes from
|
||||
|
||||
Two viable paths — pick one when wiring the back-end.
|
||||
|
||||
1. **Inline on `Post`** (preferred): the post API enriches each post with
|
||||
`linkPreview`. The first URL in `text` is resolved once at write time
|
||||
(or lazily on first read with a background job). The client renders
|
||||
without making any extra request.
|
||||
2. **Client-side lookup**: the client extracts the first URL via the
|
||||
existing `autolink` regex, calls `/api/link-preview?url=...` once per
|
||||
post (with in-memory dedupe across posts that share the same URL), and
|
||||
renders the card when the response comes back. Slower first paint but
|
||||
keeps the posts endpoint cheap.
|
||||
|
||||
Recommend (1) for the public feed and keep `/api/link-preview` available for
|
||||
(2) only on admin previews.
|
||||
|
||||
### Rendering
|
||||
|
||||
- New component: `src/components/messageStream/LinkPreviewCard.tsx`
|
||||
- Renders a card with a left vertical 3px accent bar (`themeColor` →
|
||||
fallback `bg-ark-gold`).
|
||||
- Layout:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ ▍ siteName (12px, neutral-400) │
|
||||
│ ▍ Title (15px, bold, neutral-100) │
|
||||
│ ▍ Description (13px, neutral-300, 3-line clamp) │
|
||||
│ ▍ ┌────────────────────────────────────────────┐ │
|
||||
│ ▍ │ imageUrl (lazy, aspect-video, rounded) │ │
|
||||
│ ▍ └────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Whole card is `<a href={canonicalUrl} target="_blank" rel="noopener noreferrer">`.
|
||||
- Reuse the bubble background (`bg-[#272632]` is OK, slightly lift with
|
||||
`bg-white/[0.03]` overlay so the card reads as inset within the bubble).
|
||||
- Mount points (text-bearing bubbles only): `TextBubble`,
|
||||
`ImageWithTextBubble`, `AlbumBubble`, `VideoBubble`, `FileDocBubble`.
|
||||
Render below the existing `CollapsibleText` so cards stay visible even
|
||||
when long text is collapsed.
|
||||
|
||||
### Picking the URL to preview
|
||||
|
||||
If `post.linkPreview` is present, render that single card. Otherwise the
|
||||
bubble renders nothing extra (URLs still autolink inline). The front-end
|
||||
never picks the URL itself — that decision lives on the back-end so the
|
||||
client and server agree on which URL was chosen.
|
||||
|
||||
### Falling back gracefully
|
||||
|
||||
- No `imageUrl` → omit the image area, keep the text block.
|
||||
- Title shorter than 8 characters → hide the description below (treat as
|
||||
a low-confidence preview).
|
||||
- Title empty and description empty → render nothing.
|
||||
|
||||
---
|
||||
|
||||
## Open questions for the back-end
|
||||
|
||||
- Where in the stack will OG extraction live? Existing post pipeline, a
|
||||
worker queue, or inline on read?
|
||||
- Storage: a new `link_previews` table keyed by `canonicalUrl`, with a
|
||||
`post_link_previews` join table preserving original URL order, or just a
|
||||
JSON column on `posts`?
|
||||
- How aggressive should re-scrape be? E.g. re-scrape every 30 days for
|
||||
successful previews, every 24 hours for `themeColor` updates.
|
||||
- Should admin be able to override / hide a preview per post? Telegram has
|
||||
a "no preview" toggle and editors often want it.
|
||||
- Do we want a manual "refresh preview" button in the admin UI?
|
||||
89
docs/posts-title-api.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Posts title fields for list/card surfaces
|
||||
|
||||
Back-end should provide short title fields on `Post` responses so front-end can render concise list/card titles without using the full Telegram/body text.
|
||||
|
||||
## Product requirement
|
||||
|
||||
Any list/card surface that is meant for quick browsing should display a short `title`, not the full `text` body. Full Telegram-style body copy is too noisy for compact surfaces.
|
||||
|
||||
This includes:
|
||||
|
||||
- Home **官方推荐** carousel/cards
|
||||
- Home **最新资料**
|
||||
- Home **热门资料** ranking list
|
||||
- `/official-recommendations`
|
||||
- `/browse` (**全部资料**)
|
||||
- `/category/:slug`
|
||||
- Search result previews/lists
|
||||
|
||||
The full body text should remain available as `text` for message/detail rendering, expansion, and search indexing.
|
||||
|
||||
## Current front-end implementation status
|
||||
|
||||
Already consuming `title` through `postToResource` / `Resource.title`:
|
||||
|
||||
- Home **官方推荐** carousel/cards
|
||||
- `/official-recommendations`
|
||||
- Home **热门资料** ranking list
|
||||
|
||||
Pending front-end follow-up after the back-end fields are available:
|
||||
|
||||
- Home **最新资料**
|
||||
- `/browse` (**全部资料**)
|
||||
- `/category/:slug`
|
||||
- Search results
|
||||
|
||||
Those pending surfaces currently render `text` via `MessageBubble`. Once the back-end consistently provides `title`, front-end can decide the final UI treatment, but the desired display content for compact browsing is the short `title`.
|
||||
|
||||
## Affected endpoints
|
||||
|
||||
Any endpoint returning `Post` items should include a short title when available:
|
||||
|
||||
- `GET /api/posts`
|
||||
- `GET /api/posts/search`
|
||||
- `GET /api/posts/recommended`
|
||||
- `GET /api/posts/:id`
|
||||
|
||||
## Recommended response shape
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "string",
|
||||
"title": "ARK 2026 共识加速计划", // optional global fallback title
|
||||
"text": "完整正文 / Telegram-style body text...",
|
||||
"localizations": {
|
||||
"zh": {
|
||||
"title": "ARK 2026 共识加速计划",
|
||||
"text": "完整中文正文..."
|
||||
},
|
||||
"en": {
|
||||
"title": "ARK 2026 Consensus Acceleration Plan",
|
||||
"text": "Full English body..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Front-end fallback order
|
||||
|
||||
For resource card/list `Resource.title`, front-end reads:
|
||||
|
||||
1. `localizations[currentLang].title`
|
||||
2. `post.title`
|
||||
3. first non-empty line of localized/full `text`
|
||||
4. first attachment filename
|
||||
5. `post.id`
|
||||
|
||||
So backend can roll this out gradually: old posts without `title` still render, but resource card/list surfaces reduce long body text to its first non-empty line instead of displaying the full paragraph.
|
||||
|
||||
## Requirement
|
||||
|
||||
Do **not** put an entire body paragraph into `title`. `title` should be concise enough for a two-line card/list title.
|
||||
|
||||
Examples:
|
||||
|
||||
| Good title | Bad title |
|
||||
| --- | --- |
|
||||
| `ARK 2026「共识加速计划」邀请王霸榜` | Full event body with links, schedule, rules, and hashtags |
|
||||
| `ARK 主网核心合约地址(BSC链)` | Full contract explainer paragraph |
|
||||
| `ARK灵魂五问完整视频` | Full video caption text |
|
||||
87
docs/search-and-tags-api.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# 搜索与标签接口说明(给后端)
|
||||
|
||||
前端搜索体验依赖后端两件事:①`/api/posts/search` 做**模糊搜索**;②新增 `/api/tags`
|
||||
返回完整标签列表。下面是前端目前的调用方式与期望。
|
||||
|
||||
---
|
||||
|
||||
## 1. 模糊搜索:`GET /api/posts/search`
|
||||
|
||||
### 现状
|
||||
- 前端在用户输入关键字或点击标签时,调用此接口,只负责把 `q` 传过去。
|
||||
- **匹配方式(模糊 / 精确)完全由后端决定**,前端无法控制。
|
||||
- 页面提示写明「支持搜索:标题 · 分类 · 标签 · 简介 · 文件类型 · 正文」,因此期望是
|
||||
**跨这些字段的模糊匹配**。请确认当前实现;若为精确匹配(`= q`),需改为模糊。
|
||||
|
||||
### 查询参数(前端实际会带的)
|
||||
| 参数 | 必填 | 说明 | 示例 |
|
||||
|---|---|---|---|
|
||||
| `q` | 是 | 搜索关键字(已 trim) | `海报` |
|
||||
| `lang` | 是 | 界面语言 | `zh-CN` / `en` |
|
||||
| `limit` | 是 | 每页数量 | `20`(标签预览用 `12`) |
|
||||
| `cursor` | 否 | 分页游标(上一页返回的 `nextCursor`) | |
|
||||
| `category` | 否 | 分类 slug(在分类页内搜索时) | `tutorial` |
|
||||
| `type` | 否 | 资源类型过滤 | `image`/`video`/`music`/`pdf`/`ppt`/`text`/`link`/`archive` |
|
||||
| `sort` | 否 | 排序 | `latest`/`popular`/`recommended` |
|
||||
| `language` | 否 | 资料源语言过滤 | |
|
||||
|
||||
### 期望的匹配规则(模糊)
|
||||
- 对 `q` 做**部分匹配**(`LIKE %q%` 或全文索引),**大小写不敏感**。
|
||||
- 匹配字段:**标题、分类名、标签、简介、文件类型、正文**(与页面提示一致)。
|
||||
- 中文建议用全文索引 / 分词(如 MySQL FULLTEXT、PostgreSQL `pg_trgm`/`tsvector`、或 ES),
|
||||
避免仅按整词精确匹配。
|
||||
- 建议按**相关度排序**(命中标题 > 标签 > 正文…);无 `sort` 时默认相关度,有 `sort`
|
||||
时按指定排序。
|
||||
- (可选增强)错别字容错、拼音匹配。
|
||||
|
||||
### 返回结构(与 `/api/posts` 一致)
|
||||
```jsonc
|
||||
{
|
||||
"items": [ /* Post[] */ ],
|
||||
"nextCursor": "..." // 还有下一页时返回;没有则省略/为空
|
||||
}
|
||||
```
|
||||
`Post` 关键字段:`id, categoryId, categorySlug, language, text?, attachments[],
|
||||
isRecommended, publishedAt, updatedAt?, tags?: string[], postType?`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 标签列表:新增 `GET /api/tags`
|
||||
|
||||
### 现状(痛点)
|
||||
- 「现有标签」目前是前端从**最新 80 条**帖子里现算出来的(取前 12 个高频标签),
|
||||
**不完整、也不稳定**——更早的帖子里的标签不会出现。
|
||||
|
||||
### 期望
|
||||
新增接口直接返回**全部标签 + 计数**,前端不再现算。
|
||||
|
||||
```
|
||||
GET /api/tags?lang=zh-CN
|
||||
```
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `lang` | 是 | 界面语言(用于本地化标签名,若有) |
|
||||
|
||||
返回:
|
||||
```jsonc
|
||||
{
|
||||
"tags": [
|
||||
{ "name": "图片", "count": 128 },
|
||||
{ "name": "教程", "count": 96 }
|
||||
// 按 count 降序
|
||||
]
|
||||
}
|
||||
```
|
||||
- 按 `count` 降序;前端会自行截取展示数量。
|
||||
- 只统计**已发布 / 公开**的帖子。
|
||||
|
||||
---
|
||||
|
||||
## 验收要点
|
||||
- [ ] `/api/posts/search?q=部分词` 能返回包含该词的结果(标题/标签/正文等任一命中),
|
||||
大小写不敏感。
|
||||
- [ ] 同一关键字在「搜索框」和「分类内搜索」表现一致。
|
||||
- [ ] `/api/tags` 返回全量标签(不止最新 80 条里的)。
|
||||
|
||||
> 前端已就绪:搜索框/标签都走上面的参数;标签支持再次点击取消。后端按本文件落地后,
|
||||
> 前端只需把「现有标签」数据源从现算切换到 `/api/tags`(小改动,待接口可用后进行)。
|
||||
206
docs/specs/2026-05-29-popular-resources-section.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# 社群常用资料(热门资料)区块 — 设计与后端接口文档
|
||||
|
||||
- 日期:2026-05-29
|
||||
- 作者:前端
|
||||
- 状态:待评审
|
||||
- 关联需求:1.6 熱門資料區
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
首页(第一页)的「热门资料」区块当前与「最新更新」区块**使用同一个组件**(`LatestUpdateRow`,图标 + 文字行卡),两个区块视觉上几乎一模一样,用户无法区分。同时该区块:
|
||||
|
||||
- **没有封面**,不符合需求「展示封面」;
|
||||
- 与官方推荐(大封面横滑)、最新更新(图标行卡)需要在首页形成**三种不同的版式**。
|
||||
|
||||
需求 1.6 明确:热门资料由后台数据自动排序,但**前端只展示资料、不展示具体数字**。
|
||||
|
||||
本文档定义两部分工作:
|
||||
|
||||
- **前端**:把该区块改造为「榜单」版式(封面 + 名次,无任何数字)。
|
||||
- **后端**:提供/调整支撑该区块的接口(本文档的主要交付物,交付给后端团队实现)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 范围
|
||||
|
||||
> **产品前提:第一版不做登录系统。** 凡是依赖用户身份的功能(收藏)一律顺延到 Phase 2。
|
||||
|
||||
### Phase 1(本次)
|
||||
|
||||
- 前端:首页区块改为榜单版式,卡片含**预览 + 下载**操作(两者都不依赖登录)。
|
||||
- 后端:
|
||||
- `GET /api/posts?sort=popular` 热度排序;
|
||||
- 下载计数;
|
||||
- 管理员推荐权重字段。
|
||||
|
||||
### Phase 2(后续,本文档仅登记,不在本次实现)
|
||||
|
||||
- 收藏功能(依赖登录 / 用户身份系统——第一版无登录,故顺延;当前 Favorites 页仍为 Coming Soon);
|
||||
- 分享计数;
|
||||
- 独立的「热门资料」Tab / 列表页。
|
||||
|
||||
---
|
||||
|
||||
## 3. 命名
|
||||
|
||||
- 区块标题:**热门资料**(zh-CN) / **Popular assets**(en)。
|
||||
- 内部排序标识沿用 `sort=popular`,不改路由与参数。
|
||||
|
||||
> 备注:热度时间窗(累计 vs 本周)见 §7 开放问题。
|
||||
|
||||
---
|
||||
|
||||
## 4. 前端排版设计(前端负责)
|
||||
|
||||
### 4.1 组件
|
||||
|
||||
新增榜单组件(暂名 `PopularRankRow` / `PopularRankList`),替换首页 `popular` 区块当前的 `LatestUpdateRow` 网格(桌面)与 `MessageBubble` 列表(移动)。最多展示 **5 条**。
|
||||
|
||||
数据来源不变:继续调用 `/api/posts?sort=popular`,经现有 `postToResource` 适配为前端资源对象。
|
||||
|
||||
### 4.2 单行结构(从左到右)
|
||||
|
||||
| 元素 | 说明 |
|
||||
|---|---|
|
||||
| 名次徽章 | 第 1–3 名 🥇🥈🥉;第 4–5 名灰色等宽序号(`tabular-nums`) |
|
||||
| 封面缩略图 | 取 `attachments[0]` 的 `thumbnailUrl` / `posterUrl`(复用现有 `coverFor` 逻辑);前 3 名加金色描边;无封面时回退分类图标 |
|
||||
| 标题 | 两行截断(`line-clamp-2`) |
|
||||
| Meta 行 | `类型 · 分类 · 更新时间` |
|
||||
| 操作 | **预览 + 下载**图标按钮(均不依赖登录;收藏依赖登录,见 Phase 2) |
|
||||
|
||||
### 4.3 交互
|
||||
|
||||
- 整行可点击,跳转 `/resource/:id`。
|
||||
- **预览**按钮:打开资料预览(图片 / 视频 / 文档),不触发下载;复用现有预览浮层逻辑。
|
||||
- **下载**按钮:复用现有 `downloadAttachment(postId, attachmentId)` 逻辑。
|
||||
- 预览 / 下载按钮均独立响应(`stopPropagation`),点击不冒泡触发整行跳转。
|
||||
|
||||
### 4.4 响应式
|
||||
|
||||
- 移动端:单列榜单。
|
||||
- 桌面端:居中定宽单列(保留「榜单」语义,避免退化成网格而与其他区块再次撞脸),行内加宽留白。
|
||||
|
||||
### 4.5 铁律与状态
|
||||
|
||||
- **零数字(指数量)**:下载量 / 收藏量 / 分享量 / 热度分等任何**数量型计数一律不显示**(即 1.6 所指「下载 500 次」类数字)。
|
||||
- **名次序号属于「排名」非「数量」**:榜单展示完整名次 1–5(前 3 名 🥇🥈🥉,4–5 灰色等宽序号)。名次表达相对排序、不暴露任何后台计数,与「避免显示数字」的意图不冲突。
|
||||
- 加载态:骨架屏。
|
||||
- 不足 5 条:沿用现有 `ComingSoon` 占位。
|
||||
|
||||
---
|
||||
|
||||
## 5. 后端接口契约(后端负责 · 核心交付)
|
||||
|
||||
### 5.1 热度排序 `GET /api/posts?sort=popular`
|
||||
|
||||
**请求参数**(沿用现有约定,无新增必填项):
|
||||
|
||||
```
|
||||
GET /api/posts?sort=popular&lang=zh-CN&language=<sourceLang>&limit=5
|
||||
```
|
||||
|
||||
**排序逻辑**:后端按热度分降序返回。
|
||||
|
||||
```
|
||||
popularityScore =
|
||||
w_download * downloadCount
|
||||
+ w_favorite * favoriteCount
|
||||
+ w_share * shareCount
|
||||
+ adminWeight
|
||||
```
|
||||
|
||||
- 建议初始权重(可配置):`w_download = 1.0`、`w_favorite = 2.0`、`w_share = 3.0`;`adminWeight` 直接相加。
|
||||
- Phase 1:`favoriteCount` / `shareCount` 暂为 0,不影响公式正确性,功能上线后自然生效。
|
||||
- 同分回退顺序:`adminWeight` 降序 → `updatedAt` 降序。
|
||||
|
||||
**响应结构**:沿用现有 `PostListResponse`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"items": [ /* Post[] */ ],
|
||||
"nextCursor": "..." // 可选
|
||||
}
|
||||
```
|
||||
|
||||
**❗硬性约束**:`items` 中**不得包含**任何计数 / 分值字段(`downloadCount`、`favoriteCount`、`shareCount`、`popularityScore` 等)。这些仅用于后端排序,前端不需要也不允许展示。
|
||||
|
||||
**Post 必含字段**(前端 `postToResource` 依赖,缺失会导致封面 / 类型 / 标题渲染异常):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "string",
|
||||
"postType": "ppt | pdf | image | video | music | link | text | archive",
|
||||
"categoryId": 0,
|
||||
"categorySlug": "string",
|
||||
"language": "string",
|
||||
"attachments": [
|
||||
{
|
||||
"id": "string",
|
||||
"kind": "image | video | document",
|
||||
"url": "string",
|
||||
"mime": "string",
|
||||
"filename": "string",
|
||||
"thumbnailUrl": "string?", // 封面优先取这里
|
||||
"posterUrl": "string?" // 视频封面回退
|
||||
}
|
||||
],
|
||||
"isRecommended": false,
|
||||
"publishedAt": "ISO8601",
|
||||
"updatedAt": "ISO8601",
|
||||
"localizations": { /* 多语言标题文本 */ },
|
||||
"tags": ["string"]
|
||||
}
|
||||
```
|
||||
|
||||
**封面 / 缩略图(重要)**:前端封面取值顺序为 `attachments[0].thumbnailUrl` → `posterUrl`。后端应在 body JSON 中为每条资料提供可用的封面 / 缩略图:
|
||||
|
||||
- 图片:`thumbnailUrl`(压缩图);
|
||||
- 视频:`posterUrl`(首帧 / 封面);
|
||||
- 文档(ppt/pdf 等):尽量提供后端生成的预览缩略图 `thumbnailUrl`;若暂时无法生成,前端会按**资料类型**渲染兜底封面(类型色渐变 + 类型图标),但**仍建议后端长期补齐文档缩略图**以获得最佳效果。
|
||||
|
||||
> 前端兜底仅为优雅降级;最终视觉效果依赖后端在 body 中提供真实封面 / 缩略图。
|
||||
|
||||
### 5.2 下载计数
|
||||
|
||||
下载行为发生时累加 `downloadCount`(排序输入)。
|
||||
|
||||
- 采集点:现有下载接口(前端通过 `downloadAttachment(postId, attachmentId)` 触发)。
|
||||
- 实现建议:在现有下载端点内 `++downloadCount`,或提供 `POST /api/posts/:postId/attachments/:attachmentId/download` 返回文件并计数。
|
||||
- 防刷建议(后端定夺):同一 IP / 设备在 N 分钟内对同一资源只计一次。
|
||||
|
||||
### 5.3 管理员推荐权重
|
||||
|
||||
- `Post` 增加字段 `adminWeight: number`(默认 `0`)。
|
||||
- 后台资料编辑表单(`/api/admin/resources`)可设置该值,用于人工置顶热门。
|
||||
|
||||
### 5.4 Phase 2(登记,暂不实现)
|
||||
|
||||
- **收藏**:`POST` / `DELETE /api/posts/:id/favorite`,依赖登录 / 用户身份;累加 `favoriteCount`;提供收藏状态查询与收藏列表(支撑 Favorites 页)。
|
||||
- **分享计数**:分享行为上报 `POST /api/posts/:id/share`,`++shareCount`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 后端内部字段(不对外暴露)
|
||||
|
||||
`downloadCount`、`favoriteCount`、`shareCount`、`adminWeight`,以及派生的 `popularityScore`。前端响应中**不返回**。
|
||||
|
||||
---
|
||||
|
||||
## 7. 开放问题(需后端 / 产品确认)
|
||||
|
||||
1. **下载接口的确切路径与契约**:前端 `downloadAttachment` 当前对应的后端端点是什么?计数挂在哪里?
|
||||
2. **热度时间窗**:是「累计热门」还是「本周 / 近 30 天热门」?若要「本周精选」语义需按时间窗统计计数。当前命名「社群常用资料」默认**累计**。
|
||||
3. **数量与分页**:首页固定 5 条;Phase 2 独立热门页是否需要分页?
|
||||
4. **防刷计数策略**的具体规则。
|
||||
5. **预览实现**:图片 / 视频可直接用 `attachment.url` / `thumbnailUrl`;文档类(ppt/pdf)在线预览是否已有渲染服务,还是仅展示封面缩略图?(预览预计**不需要后端新接口**,待确认)
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
- `GET /api/posts?sort=popular` 按热度分降序返回,且响应中**无任何计数 / 分值字段**。
|
||||
- 下载行为能累加 `downloadCount` 并反映到排序。
|
||||
- 后台可设置 `adminWeight` 并影响排序。
|
||||
- 前端首页区块呈现为榜单(含封面、名次、无数字),与「最新更新」「官方推荐」区块视觉区分明显。
|
||||
103
docs/superpowers/specs/2026-05-29-website-motion-ux-design.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# 网站动画与 UX 提升 — 设计文件
|
||||
|
||||
**日期**: 2026-05-29
|
||||
**目标**: 在**不改变 UI 布局和颜色**的前提下,为 Arkie Library 前端加入精致动画并提升使用者体验,效果与性能兼顾,并顺手清理冗余代码。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与约束
|
||||
|
||||
- 技术栈:React 18 + TypeScript + Vite + Tailwind CSS。
|
||||
- 主题:深色 (`#141319`),金色重点 (`#eeb726` / `#ffd35c`)。
|
||||
- 现有动画:`ark-page-fade-in`(页面淡入 240ms)、`ark-header-menu-enter`(菜单 clip-path 180ms),皆已支援 `prefers-reduced-motion`。
|
||||
- **硬约束**:
|
||||
- 不改变任何布局、颜色、字体、间距。仅加入「动作 / 时间 / 反馈」。
|
||||
- 全部动画必须尊重 `prefers-reduced-motion: reduce`。
|
||||
- 效果与性能兼顾:库需按需加载、bundle 影响最小化。
|
||||
|
||||
## 2. 决策(已与用户确认)
|
||||
|
||||
| 项目 | 决定 |
|
||||
|------|------|
|
||||
| 动画强度 | 适中活泼(moderate & lively),克制但明显 |
|
||||
| 范围 | 全站(首页、浏览、分类、分类详情、搜索、关于、收藏) |
|
||||
| 实现方式 | **混合方案**:`framer-motion`(LazyMotion 精简模式)+ 纯 CSS/Tailwind |
|
||||
| 冗余清理 | 实作中一并清理(如 `RecommendedCard` 内两边相同的三元运算) |
|
||||
|
||||
### 库分工
|
||||
- **framer-motion (v11, `LazyMotion` + `m`)**:页面切换退场动画(`AnimatePresence`)、弹簧悬停质感、筛选/排序卡片重排(`layout`)、复杂 stagger 编排。
|
||||
- **CSS / Tailwind**:骨架屏 shimmer、图片加载淡入、简单滚动出现(无需 framer 的场景)。
|
||||
|
||||
## 3. 架构与组件
|
||||
|
||||
### 3.1 动画基础设施(一次建好,全站复用)
|
||||
|
||||
- `tailwind.config.js`:新增 `keyframes`(`shimmer`、`fade-in-up`、`scale-in`)、对应 `animation`,统一缓动 `cubic-bezier(0.22, 1, 0.36, 1)`。
|
||||
- `src/index.css`:新增可复用工具类与 reduced-motion 保护。
|
||||
- `src/motion/` 新目录:
|
||||
- `MotionProvider.tsx`:包 `LazyMotion`(`domAnimation` features),全站只引入一次,确保精简 bundle。
|
||||
- `variants.ts`:共享 variants(`fadeInUp`、`staggerContainer`、`cardHover`、`pageTransition`)与统一 transition 设定。
|
||||
- `useRevealOnScroll.ts`:轻量 `IntersectionObserver` hook,元素进入视窗触发一次(CSS 路径用)。
|
||||
- `Reveal.tsx`:薄包装组件,子元素滚动进入视窗时 fade-in-up(内部用 framer 的 `whileInView` 或 CSS hook,择一统一)。
|
||||
|
||||
### 3.2 页面切换退场(framer-motion)
|
||||
|
||||
- 在 `PublicLayout` 的 `<Outlet/>` 外层用 `AnimatePresence mode="wait"`,以 `pathname+search` 为 key。
|
||||
- 退场/进场使用 `pageTransition` variant(淡入 + 轻微位移,~220ms)。
|
||||
- 取代现有 `ark-page-fade-in`(避免重复;保留 CSS 作为 reduced-motion fallback)。
|
||||
|
||||
### 3.3 滚动出现(Scroll Reveal)
|
||||
|
||||
- 列表/区块(首页 carousel/区段、浏览 grid、分类卡片)以 `Reveal` 包装,依序 stagger(~60ms)淡入上浮,只触发一次。
|
||||
- grid 大量项目时限制 stagger 上限,避免长列表整体延迟。
|
||||
|
||||
### 3.4 悬停与微互动(不改样式,仅加动作)
|
||||
|
||||
- 卡片:现有 `hover:scale-[1.02]` 升级为 framer 弹簧整卡轻浮 + 阴影过渡(沿用金色边框)。
|
||||
- 下载按钮:`active:scale` 点击反馈 + 完成时的状态过渡。
|
||||
- 导航连结:金色下划线滑入(`gold-underline` 过渡化)。
|
||||
|
||||
### 3.5 载入与反馈(UX 重点)
|
||||
|
||||
- **骨架屏**:新增 `src/components/Skeleton.tsx`(带 shimmer),用于列表/卡片/详情加载态,取代空白或突兀闪现。
|
||||
- **图片淡入**:图片 `onLoad` 后淡入(卡片封面、详情图),避免硬切。
|
||||
- **Toast**:轻量 `src/components/Toast.tsx` + provider,下载成功/失败提示(沿用现有配色),无障碍 `aria-live`。
|
||||
|
||||
## 4. 冗余代码清理
|
||||
|
||||
- `RecommendedCard.tsx`:
|
||||
- `useFigmaDesign ? "group-hover:scale-[1.02]" : "group-hover:scale-[1.02]"` → 两边相同,简化。
|
||||
- spinner `useFigmaDesign ? "h-5 w-5 animate-spin" : "h-5 w-5 animate-spin"` → 简化。
|
||||
- 实作各档案时移除遇到的同类重复(不做无关重构)。
|
||||
|
||||
## 5. 资料流 / 隔离
|
||||
|
||||
- 动画相关逻辑集中于 `src/motion/`,组件只引用 variants/hook,不内嵌魔术数字。
|
||||
- `MotionProvider` 在 app root 包一次;各页面无需重复设定。
|
||||
- 骨架屏 / Toast 为独立、可单测的展示组件。
|
||||
|
||||
## 6. 无障碍与性能
|
||||
|
||||
- 所有 framer 动画与 CSS 动画在 `prefers-reduced-motion: reduce` 下降级为无动作或纯淡入。
|
||||
- 使用 `LazyMotion` 精简 features,避免引入完整 framer bundle。
|
||||
- 动画用 `transform`/`opacity`(GPU 友好),避免 layout thrash。
|
||||
- build 后检查 bundle 体积变化在可接受范围。
|
||||
|
||||
## 7. 实作顺序
|
||||
|
||||
1. 安装 framer-motion + 建 `src/motion/` 基础设施 + tailwind/CSS keyframes。
|
||||
2. `MotionProvider` 接入 app root。
|
||||
3. 页面切换退场(PublicLayout)。
|
||||
4. Scroll reveal(全站列表/区块)。
|
||||
5. 骨架屏 + 图片淡入。
|
||||
6. 悬停/微互动升级。
|
||||
7. Toast 反馈。
|
||||
8. 清理冗余代码。
|
||||
9. `npm run build` 验证 + reduced-motion 验证。
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
- 视觉布局/颜色与改动前一致(仅多了动作)。
|
||||
- `npm run build` 通过,bundle 增量可控(framer 精简模式)。
|
||||
- 开启「减少动态效果」时网站仍可正常使用、无动画。
|
||||
- 全站列表有 scroll reveal、卡片有弹簧悬停、加载有骨架屏、下载有 toast。
|
||||
@@ -0,0 +1,183 @@
|
||||
# 钱包登录 + 收藏:重设计与 Bug 修复 设计文档
|
||||
|
||||
日期:2026-06-02
|
||||
分支:`terry-wallet-login`
|
||||
范围:登录弹窗、Header/菜单钱包入口、收藏按钮、我的收藏页面、收藏入口
|
||||
关联:
|
||||
- 需求简报 `.unipi/docs/generated/2026-06-01-wallet-favorites-ui-redesign-requirements.md`
|
||||
- 后端核对 `docs/backend-requirements-wallet-favorites.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标
|
||||
|
||||
把已上线的「钱包登录 + 收藏」从「功能能跑但设计未完善、桌面/手机均有 bug」提升到完成度合格:
|
||||
|
||||
1. 修复登录流程中的真实功能 bug(强制切链、桌面误导、手机死路)。
|
||||
2. 按已批准的极简原则重做登录弹窗。
|
||||
3. 补齐完全缺失的「我的收藏」入口。
|
||||
4. 打磨收藏按钮状态与收藏页(移动端筛选、空/错误/不可用状态)。
|
||||
|
||||
**关键事实**:经核对后端,钱包认证、TokenPocket 扫码、收藏接口(筛选/排序/分页/可用性/计数)均已实现并符合契约。**本次为纯前端工作**,后端仅需确认 CORS(见后端文档 §2.1)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 登录架构(决策已定)
|
||||
|
||||
三条路径共存,但 UI 上分主次:
|
||||
|
||||
| 路径 | 用途 | UI 位置 |
|
||||
|---|---|---|
|
||||
| `window.ethereum` 注入登录 | 桌面插件 / 钱包内置浏览器 | **主路径** |
|
||||
| TokenPocket 自写扫码(deep link + 轮询) | 中国稳定扫码 | 「其他方式」折叠区 |
|
||||
| RainbowKit / WalletConnect | MetaMask / imToken 扫码兜底 | 「其他方式」折叠区 |
|
||||
|
||||
**决策**:保留并**真正接上** RainbowKit(当前为未被调用的死代码)。
|
||||
**前置项**:需在环境变量配置有效的 `VITE_WALLETCONNECT_PROJECT_ID`(当前默认 `ark-library-dev-only` 无效)。WalletConnect 兜底在部分中国网络不稳定,UI 需提示。
|
||||
|
||||
签名验证链无关(后端 EIP-191 personal_sign recover,消息不引用任何链)。
|
||||
|
||||
---
|
||||
|
||||
## 3. Bug 修复清单(前端)
|
||||
|
||||
| # | 严重度 | 问题 | 修复 |
|
||||
|---|---|---|---|
|
||||
| B1 | 🟠 | 每次登录强制切 BNB 链(`ensureBnbChain`),多一个换网络弹窗,常见失败点 | 删除强制切链;`personal_sign` 不需要链 |
|
||||
| B2 | 🟠 | 桌面弹窗摆 3 个钱包按钮,点 TP/imToken 误弹「请安装」 | 桌面只留 1 个主操作「使用浏览器钱包登录」 |
|
||||
| B3 | 🟠 | 桌面无扫码(TP 扫码被包在仅手机分支) | 扫码移入桌面「其他方式」 |
|
||||
| B4 | 🟠 | 手机「打开钱包 App」是死路:无反馈、App 未装无兜底 | 加跳转反馈 + 未安装兜底(提示去下载) |
|
||||
| B5 | 🔴 | RainbowKit 整套加载但从未被登录流程调用 | 真正接成「其他方式」扫码兜底 |
|
||||
| B6 | 🔴 | 全站无「我的收藏」入口,页面只能手敲 URL | 加 3 处入口(见 §5) |
|
||||
| B7 | 🔴 | 钱包下拉只有地址 + 断开 | 下拉加「我的收藏」 |
|
||||
| B8 | 🟡 | 收藏 token 过期只弹失败 toast | 401 时自动登出并引导重新登录 |
|
||||
| B9 | 🟡 | `isMobileDevice` 把触屏 Mac/iPad 判为手机 | 收紧检测,避免桌面被推进 App 跳转流 |
|
||||
| B10 | 🟡 | 收藏页加载失败无重试 | 错误态加重试按钮 |
|
||||
| B11 | 🟡 | WalletConnect projectId 默认无效值 | 用 env,缺失时禁用扫码兜底并提示 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 登录弹窗设计
|
||||
|
||||
### 4.1 桌面版
|
||||
|
||||
结构(自上而下):
|
||||
1. 标题「连接钱包」
|
||||
2. 说明「签名仅用于验证钱包地址,不会发起交易,也不需要 Gas」
|
||||
3. **主按钮**「使用浏览器钱包登录」(金色)→ `window.ethereum` 注入流程
|
||||
4. 辅助说明「请使用已安装钱包插件的浏览器,例如 MetaMask」
|
||||
5. 折叠「其他登录方式」(**默认折叠**),展开后:
|
||||
- TokenPocket 扫码(第一项,中国常用)
|
||||
- MetaMask / imToken 扫码(WalletConnect,附不稳定提示)
|
||||
6. 关闭按钮
|
||||
7. 错误区(红色)
|
||||
|
||||
### 4.2 手机版
|
||||
|
||||
结构:
|
||||
1. 标题「连接钱包」+ 说明「请在钱包 App 中打开本站并签名登录,无交易、无 Gas」
|
||||
2. 若检测到注入钱包:**「使用当前钱包登录」**主按钮
|
||||
3. 否则:分组「打开钱包 App」+ 三个按钮(TokenPocket / MetaMask / imToken),带品牌图标
|
||||
- 点按尝试 deep link;未跳转/未安装 → 提示去下载(**不再死路**,修 B4)
|
||||
4. 折叠「其他方式(扫码)」默认折叠
|
||||
5. 关闭按钮 + 错误区
|
||||
|
||||
### 4.3 通用
|
||||
- 钱包按钮配品牌彩色图标。
|
||||
- 多语言预留文字长度(en/zh-CN/zh-TW/ko/ja/vi/id/ms),按钮不溢出。
|
||||
- 弹窗在手机上可滚动、不被遮挡。
|
||||
|
||||
---
|
||||
|
||||
## 5. 钱包入口与收藏入口
|
||||
|
||||
### 5.1 Header 钱包入口
|
||||
- 未登录:`Connect Wallet / 连接钱包` 主按钮(桌面右侧 / 手机菜单内)。
|
||||
- 已登录:短地址 `0x12…ab34` + 绿点;点击展开下拉。
|
||||
|
||||
### 5.2 钱包下拉(已登录,修 B7)
|
||||
顺序:完整地址 → **♥ 我的收藏**(新增)→ 断开连接。
|
||||
|
||||
### 5.3 「我的收藏」入口策略:**始终显示(方案 B)**
|
||||
- 桌面钱包下拉、手机菜单中**始终**显示「我的收藏」入口。
|
||||
- 未登录点击 → 落到 `/favorites` 的「连接钱包查看收藏」引导页(现状已有,保留)。
|
||||
- 手机菜单导航项中加「♥ 我的收藏」。
|
||||
|
||||
### 5.4 收藏按钮触发登录(保留现状逻辑)
|
||||
未登录点 ♥ → 打开登录弹窗 → 登录成功后自动补上本次收藏(`pendingAfterLogin` 已实现)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 收藏按钮(`FavoriteButton`)
|
||||
|
||||
四态,一眼可分:
|
||||
- 未收藏:空心 ♡,低对比底。
|
||||
- 已收藏:实心 ♥,品牌金填充。
|
||||
- 加载中:转圈(`LoaderCircle`)。
|
||||
- 请求中:禁用 + 降透明。
|
||||
|
||||
行为:
|
||||
- 点击 `preventDefault + stopPropagation`,不误触进详情(已实现,保留)。
|
||||
- 增加点击微动效(`active:scale`)。
|
||||
- 乐观更新,失败回滚 + 错误 toast(已实现,保留)。
|
||||
|
||||
摆放:推荐卡 / 最新 / 热门 / 内容流 / 收藏页卡片右上角,不挡主内容、不与下载/预览混淆。
|
||||
|
||||
---
|
||||
|
||||
## 7. 我的收藏页面 `/favorites`
|
||||
|
||||
### 7.1 未登录
|
||||
图标 + 标题 + 说明 + 「连接钱包」CTA(现状已有,保留视觉打磨)。
|
||||
|
||||
### 7.2 已登录
|
||||
- **桌面**筛选一行:搜索 + 排序 + 分类 + 搜索按钮(现状保留)。
|
||||
- **移动端**:搜索框单独一行;排序/分类收进**「筛选抽屉」**,解决现状 4 控件挤压(新增)。
|
||||
- 列表:收藏资源卡(封面/标题/描述/分类/类型/更新时间/收藏数/收藏按钮)。
|
||||
- 分页:上一页/下一页 + 页码(现状保留)。
|
||||
- 「清除筛选」当存在筛选时显示。
|
||||
|
||||
### 7.3 状态
|
||||
- **不可用/下架**:黄边 + 「不可用」标 + 不可点进详情 + 保留移除按钮(后端 `availability` 已支持)。
|
||||
- **空状态**:区分「还没有收藏」与「筛选无结果」,后者给清除筛选入口。
|
||||
- **错误**:加载失败提示 +(新增)**重试按钮**(修 B10)。
|
||||
- **加载**:4 张 skeleton,布局不跳。
|
||||
|
||||
排序选项:最近收藏 / 最近发布 / 热门(后端 `favorited_at`/`published_at`/`hot` 已支持)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 多语言
|
||||
所有新增/改动文案覆盖 8 语言(en、zh-CN、zh-TW、ko、ja、vi、id、ms),key 写入 `src/locales/*`。移动端按钮预留长文本。
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收清单
|
||||
|
||||
登录:
|
||||
- [ ] 桌面弹窗只有 1 个主操作;扫码在折叠区。
|
||||
- [ ] 手机可打开 TP/MetaMask/imToken;未安装有兜底。
|
||||
- [ ] 登录不再强制切链。
|
||||
- [ ] RainbowKit 真正接通(projectId 有效时);无效时扫码兜底禁用并提示。
|
||||
- [ ] 已登录显示短地址,可断开。
|
||||
|
||||
收藏:
|
||||
- [ ] 钱包下拉、手机菜单均有「我的收藏」入口(始终显示)。
|
||||
- [ ] 收藏按钮四态清楚,不与卡片点击冲突。
|
||||
- [ ] 未登录点收藏 → 引导登录 → 自动补收藏。
|
||||
- [ ] token 过期自动登出并引导重登。
|
||||
|
||||
收藏页:
|
||||
- [ ] 桌面一行筛选;移动端筛选抽屉。
|
||||
- [ ] 不可用/空/错误(含重试)/骨架屏 完整。
|
||||
- [ ] Desktop 与 mobile 均验证。
|
||||
|
||||
质量门槛(实现后):`npx tsc --noEmit`、`npm run format:check`、`npm test` 全绿。
|
||||
|
||||
---
|
||||
|
||||
## 10. 不做(YAGNI)
|
||||
- 服务端登出 / token 撤销(保持无状态 JWT)。
|
||||
- ENS、链上读取、交易。
|
||||
- 收藏分组/文件夹、批量操作(本期不做)。
|
||||
- 收藏 `q` 全文索引优化(数据量小,暂不做)。
|
||||
38
index.html
@@ -1,9 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<html lang="zh-CN" translate="no">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ARK 資料庫</title>
|
||||
<!-- The app ships its own 7-language i18n and serves localized content, so
|
||||
browser auto-translation only garbles the UI. Opt out of it. -->
|
||||
<meta name="google" content="notranslate" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#08070c" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="application-name" content="ARK 资料库" />
|
||||
<meta
|
||||
name="description"
|
||||
content="ARK 官方数据库集中整理官方教材、公告、视频、图片与常用文件,帮助社区快速找到可信资料。"
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="ARK 资料库" />
|
||||
<meta property="og:title" content="ARK 官方数据库" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="ARK 官方数据库集中整理官方教材、公告、视频、图片与常用文件,帮助社区快速找到可信资料。"
|
||||
/>
|
||||
<meta property="og:image" content="/assets/ark-mark.png" />
|
||||
<meta property="og:locale" content="zh_CN" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="ARK 官方数据库" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="ARK 官方数据库集中整理官方教材、公告、视频、图片与常用文件,帮助社区快速找到可信资料。"
|
||||
/>
|
||||
<meta name="twitter:image" content="/assets/ark-mark.png" />
|
||||
<link rel="icon" type="image/webp" href="/assets/logo-primary.webp" />
|
||||
<link rel="apple-touch-icon" href="/assets/ark-mark.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<title>ARK 官方数据库</title>
|
||||
</head>
|
||||
<body class="bg-ark-bg text-neutral-100">
|
||||
<div id="root"></div>
|
||||
|
||||
287
package-lock.json
generated
@@ -9,12 +9,14 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@rainbow-me/rainbowkit": "^2.2.11",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"framer-motion": "^11.18.2",
|
||||
"lucide-react": "^0.460.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"viem": "^2.48.11",
|
||||
"viem": "^2.52.0",
|
||||
"wagmi": "^2.19.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -495,16 +497,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@coinbase/cdp-sdk": {
|
||||
"version": "1.48.3",
|
||||
"resolved": "https://registry.npmjs.org/@coinbase/cdp-sdk/-/cdp-sdk-1.48.3.tgz",
|
||||
"integrity": "sha512-1fldOyJw/vjk42GsOCQ2pys/3r3LXHq8wZyhnt6OXkFfKirCjZiw966PT0vFcI/IzIQnhDgSeG7Tlt7kul3osg==",
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@coinbase/cdp-sdk/-/cdp-sdk-1.51.0.tgz",
|
||||
"integrity": "sha512-XK8+OXDER1jirYpuiOct4ij65ODQ31LsmyRrZi/J7zF4GB89qxWZ0KPfAdsqJMP7VvE4no+Q++MKkQtAJUBoyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@solana-program/system": "^0.10.0",
|
||||
"@solana-program/token": "^0.9.0",
|
||||
"@solana/kit": "^5.5.1",
|
||||
"abitype": "1.0.6",
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"axios-retry": "^4.5.0",
|
||||
"bs58": "^6.0.0",
|
||||
"jose": "^6.2.0",
|
||||
@@ -1297,9 +1299,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@lit-labs/ssr-dom-shim": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.5.1.tgz",
|
||||
"integrity": "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz",
|
||||
"integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@lit/reactive-element": {
|
||||
@@ -1408,9 +1410,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@metamask/eth-json-rpc-provider/node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -1501,9 +1503,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@metamask/json-rpc-engine/node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -1562,9 +1564,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@metamask/json-rpc-middleware-stream/node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -1686,9 +1688,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@metamask/providers/node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -1879,9 +1881,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@metamask/utils/node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -1985,7 +1987,7 @@
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@paulmillr/qr/-/qr-0.2.1.tgz",
|
||||
"integrity": "sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ==",
|
||||
"deprecated": "The package is now available as \"qr\": npm install qr",
|
||||
"deprecated": "Switch to \"qr\" (new package name) for security updates: npm install qr",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
@@ -2343,9 +2345,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@reown/appkit-controllers/node_modules/lru-cache": {
|
||||
"version": "11.3.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
|
||||
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
|
||||
"version": "11.5.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
|
||||
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
@@ -2852,9 +2854,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@reown/appkit-utils/node_modules/lru-cache": {
|
||||
"version": "11.3.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
|
||||
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
|
||||
"version": "11.5.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
|
||||
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
@@ -3313,9 +3315,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@reown/appkit/node_modules/lru-cache": {
|
||||
"version": "11.3.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
|
||||
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
|
||||
"version": "11.5.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
|
||||
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
@@ -4603,27 +4605,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@solana/rpc-subscriptions-channel-websocket/node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@solana/rpc-subscriptions-spec": {
|
||||
"version": "5.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-spec/-/rpc-subscriptions-spec-5.5.1.tgz",
|
||||
@@ -4879,9 +4860,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.100.9",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.9.tgz",
|
||||
"integrity": "sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==",
|
||||
"version": "5.100.14",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz",
|
||||
"integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -4889,12 +4870,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.100.9",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.9.tgz",
|
||||
"integrity": "sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A==",
|
||||
"version": "5.100.14",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz",
|
||||
"integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.100.9"
|
||||
"@tanstack/query-core": "5.100.14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -5535,9 +5516,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@walletconnect/core/node_modules/lru-cache": {
|
||||
"version": "11.3.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
|
||||
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
|
||||
"version": "11.5.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
|
||||
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
@@ -5722,9 +5703,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@walletconnect/ethereum-provider/node_modules/lru-cache": {
|
||||
"version": "11.3.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
|
||||
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
|
||||
"version": "11.5.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
|
||||
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
@@ -5938,9 +5919,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/ws": {
|
||||
"version": "7.5.10",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
|
||||
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
|
||||
"version": "7.5.11",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz",
|
||||
"integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.3.0"
|
||||
@@ -6114,9 +6095,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@walletconnect/types/node_modules/lru-cache": {
|
||||
"version": "11.3.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
|
||||
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
|
||||
"version": "11.5.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
|
||||
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
@@ -6287,9 +6268,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@walletconnect/universal-provider/node_modules/lru-cache": {
|
||||
"version": "11.3.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
|
||||
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
|
||||
"version": "11.5.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
|
||||
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
@@ -6566,9 +6547,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@walletconnect/utils/node_modules/lru-cache": {
|
||||
"version": "11.3.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
|
||||
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
|
||||
"version": "11.5.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
|
||||
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
@@ -6963,14 +6944,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios-retry": {
|
||||
@@ -7939,15 +7920,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
|
||||
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
|
||||
"version": "6.6.5",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.5.tgz",
|
||||
"integrity": "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.18.3",
|
||||
"ws": "~8.20.1",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
}
|
||||
},
|
||||
@@ -7986,9 +7967,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
@@ -8123,9 +8104,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eth-block-tracker/node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -8458,6 +8439,33 @@
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "11.18.2",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
|
||||
"integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^11.18.1",
|
||||
"motion-utils": "^11.18.1",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -8650,9 +8658,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.18",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
|
||||
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
|
||||
"version": "4.12.23",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
|
||||
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -9190,9 +9198,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lit-html": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.2.tgz",
|
||||
"integrity": "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==",
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.3.tgz",
|
||||
"integrity": "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "^2.0.2"
|
||||
@@ -9398,6 +9406,21 @@
|
||||
"integrity": "sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "11.18.1",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
|
||||
"integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^11.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "11.18.1",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz",
|
||||
"integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -9615,9 +9638,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ox": {
|
||||
"version": "0.14.20",
|
||||
"resolved": "https://registry.npmjs.org/ox/-/ox-0.14.20.tgz",
|
||||
"integrity": "sha512-rby38C3nDn8eQkf29Zgw4hkCZJ64Qqi0zRPWL8ENUQ7JVuoITqrVtwWQgM/He19SCMUEc7hS/Sjw0jIOSLJhOw==",
|
||||
"version": "0.14.27",
|
||||
"resolved": "https://registry.npmjs.org/ox/-/ox-0.14.27.tgz",
|
||||
"integrity": "sha512-+xhLHo/f+f4BH121/1Pomm/1vgBBda1wYiFpTvjSo8o5OcEj76Pf1hGPJiepoYMTQoTm2SKdSBvWkFWk5l07PA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -10080,10 +10103,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
@@ -10132,6 +10158,15 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/query-string": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
|
||||
@@ -11126,9 +11161,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ua-parser-js": {
|
||||
"version": "2.0.9",
|
||||
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-2.0.9.tgz",
|
||||
"integrity": "sha512-OsqGhxyo/wGdLSXMSJxuMGN6H4gDnKz6Fb3IBm4bxZFMnyy0sdf6MN96Ie8tC6z/btdO+Bsy8guxlvLdwT076w==",
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-2.0.10.tgz",
|
||||
"integrity": "sha512-t+3Ktbq0Ies2vaSezfOaWiolH4OigQIO1dk+1xDpOydB1COVPocVYOrEV5rqZ0kFY9XYG1v9LutCyMgYBpABcw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -11188,9 +11223,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.25.0.tgz",
|
||||
"integrity": "sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.27.0.tgz",
|
||||
"integrity": "sha512-sqqlwW3zm+cE82GwKdGyn3pcze7LXlx/4jUgA0vtAf6Fa81KMrJqc3VfWmmeOTUIElW9IdPsLwMUIpiOZQgK3A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
@@ -11354,9 +11389,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/viem": {
|
||||
"version": "2.48.11",
|
||||
"resolved": "https://registry.npmjs.org/viem/-/viem-2.48.11.tgz",
|
||||
"integrity": "sha512-+WZ5E0dBS6GtKb+1wEk5DeYRRRW42+pFnXCo67Ydodf42sBwO+hu3wnQy66lc4MKmHz+llPVdbyehYr9oTE2iw==",
|
||||
"version": "2.52.0",
|
||||
"resolved": "https://registry.npmjs.org/viem/-/viem-2.52.0.tgz",
|
||||
"integrity": "sha512-py2QPYe9e1f4DmPJCsXF7zHmyZ0PkJrBxdQZ5dvNXvzy3UzWkUn7dNfC0TMeNm6Qv1tKw3b6qXXExpx6L0oMbw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -11371,8 +11406,8 @@
|
||||
"@scure/bip39": "1.6.0",
|
||||
"abitype": "1.2.3",
|
||||
"isows": "1.0.7",
|
||||
"ox": "0.14.20",
|
||||
"ws": "8.18.3"
|
||||
"ox": "0.14.27",
|
||||
"ws": "8.20.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.4"
|
||||
@@ -11599,13 +11634,13 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
"version": "1.1.20",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
||||
"integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz",
|
||||
"integrity": "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"available-typed-arrays": "^1.0.7",
|
||||
"call-bind": "^1.0.8",
|
||||
"call-bind": "^1.0.9",
|
||||
"call-bound": "^1.0.4",
|
||||
"for-each": "^0.3.5",
|
||||
"get-proto": "^1.0.1",
|
||||
@@ -11657,9 +11692,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -14,12 +14,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@rainbow-me/rainbowkit": "^2.2.11",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"framer-motion": "^11.18.2",
|
||||
"lucide-react": "^0.460.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"viem": "^2.48.11",
|
||||
"viem": "^2.52.0",
|
||||
"wagmi": "^2.19.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -2,11 +2,8 @@
|
||||
|
||||
Source: Figma file `uHDZkVHjAp7BXDKQKB0PM4`, responsive reference node `3761:10923`.
|
||||
|
||||
- `banner-desktop.png` — desktop/tablet banner export from node `3621:1225`.
|
||||
- `banner-wide.png` — provided wide banner crop from node `3718:11952`.
|
||||
- `banner-576.png` — mobile/tablet banner crop from node `3726:13099`.
|
||||
- `banner-440.png` — mobile banner crop from node `3726:14199`.
|
||||
- `banner-375.png` — mobile banner crop from node `3726:14238`.
|
||||
- `official-recommendation-1.png` ... `official-recommendation-5.png` — official recommendation cover exports from the 1920px frame card image nodes; used only as fallback/placeholder covers so real resource cards keep accurate API-provided imagery.
|
||||
Currently retained asset:
|
||||
|
||||
These files are visual UI assets only. They do not change backend data or API contracts.
|
||||
- `official-recommendation-cover.png` — fallback/placeholder cover used by `src/components/RecommendedCard.tsx` via `src/components/FigmaBanner.tsx`.
|
||||
|
||||
Old static banner exports and individual recommendation placeholder exports were removed because the frontend now loads home banners from `/api/banners` and only uses the single shared recommendation cover fallback.
|
||||
|
||||
|
Before Width: | Height: | Size: 227 KiB |
|
Before Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 411 KiB |
|
Before Width: | Height: | Size: 589 KiB |
|
Before Width: | Height: | Size: 506 KiB |
|
Before Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 253 KiB |
12
public/assets/ark-library/flags/en.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4220_10205)">
|
||||
<path d="M11.9998 23.9995C18.6268 23.9995 23.9995 18.6264 23.9995 11.9998C23.9995 5.37318 18.6273 0 11.9998 0C5.37224 0 0 5.37364 0 11.9998C0 18.6259 5.37271 23.9995 11.9998 23.9995Z" fill="#F0F0F0"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.2259 5.73314H11.4599V2.59961H19.4594C20.5579 3.47415 21.4943 4.53476 22.2259 5.73314ZM23.9992 11.9988H11.4665V8.86621H23.5862C23.8612 9.88767 24.0001 10.941 23.9992 11.9988ZM11.9994 23.9986C14.7123 24.0048 17.3462 23.0855 19.466 21.3924H4.53332C6.65275 23.0857 9.2866 24.0051 11.9994 23.9986ZM22.2391 18.2659H1.75978C1.16246 17.2918 0.708889 16.2365 0.413086 15.1328H23.5858C23.29 16.2365 22.8365 17.2918 22.2391 18.2659Z" fill="#D80027"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.57099 1.86747H5.5602V1.87497L5.57099 1.86747ZM5.57099 1.86747H6.65612L5.6338 2.60105L6.02051 3.80103L5.00053 3.06745L3.98711 3.80103L4.32038 2.76558C3.42466 3.51526 2.64316 4.39167 2.00059 5.36709H2.35402L1.70715 5.83583L1.41372 6.35145L1.72028 7.30487L1.14045 6.883L0.740611 7.81392L1.07389 8.85921H2.33949L1.33357 9.60029L1.72028 10.8003L0.700299 10.0667L0.100311 10.5064C0.0333572 11.0014 -0.000155581 11.5003 5.42967e-07 11.9998H11.9998V1.01072e-05C9.72265 -0.00295146 7.49213 0.644982 5.57099 1.86747ZM6.01816 10.7942L6.02707 10.8012H6.02051L6.01816 10.7942ZM5.63333 9.60076L6.01816 10.7942L5.00006 10.0676L3.98008 10.8012L4.36679 9.60123L3.34681 8.86765H4.61241L4.99912 7.66767L5.38583 8.86765H6.65143L5.63333 9.60076ZM5.64036 6.1077L6.02707 7.30768L5.00709 6.5741L3.98711 7.30768L4.37382 6.1077L3.35384 5.37412H4.61944L5.00615 4.17415L5.39286 5.37412H6.65846L5.64036 6.1077ZM9.31341 10.0676L10.3334 10.8012L9.94668 9.60123L10.9685 8.86765H9.70294L9.31623 7.66767L8.92951 8.86765H7.66391L8.68389 9.60123L8.29718 10.8012L9.31341 10.0676ZM9.94668 6.1077L10.3334 7.30768L9.31341 6.5741L8.29343 7.30768L8.68014 6.1077L7.66016 5.37412H8.92576L9.31248 4.17415L9.69919 5.37412H10.9648L9.94668 6.1077ZM10.3334 3.81415L9.94668 2.61418L10.9685 1.8806H9.70294L9.31623 0.680622L8.92951 1.8806H7.66391L8.68389 2.61418L8.29718 3.81415L9.31716 3.08057L10.3334 3.81415Z" fill="#0052B4"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4220_10205">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
11
public/assets/ark-library/flags/id.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4220_10204)">
|
||||
<path d="M0 12C0 5.37281 5.37281 0 12 0C18.6272 0 24 5.37281 24 12" fill="#D81F2A"/>
|
||||
<path d="M24 12C24 18.6272 18.6272 24 12 24C5.37281 24 0 18.6272 0 12H24Z" fill="#F1F0F0"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4220_10204">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 428 B |
11
public/assets/ark-library/flags/ja.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4220_10208)">
|
||||
<path d="M12 24C18.6272 24 24 18.6267 24 12C24 5.37328 18.6272 0 12 0C5.37281 0 0 5.37328 0 12C0 18.6267 5.37281 24 12 24Z" fill="#F0F0F0"/>
|
||||
<path d="M11.9975 17.2193C13.03 17.2199 14.0395 16.9142 14.8983 16.341C15.757 15.7677 16.4265 14.9527 16.8221 13.999C17.2176 13.0452 17.3214 11.9956 17.1203 10.9828C16.9193 9.97007 16.4224 9.03969 15.6925 8.30936C14.9627 7.57903 14.0326 7.08156 13.02 6.87986C12.0073 6.67817 10.9576 6.78131 10.0036 7.17624C9.04964 7.57118 8.23419 8.24016 7.66043 9.09859C7.08666 9.95701 6.78037 10.9663 6.78027 11.9988C6.78027 13.3828 7.32984 14.7102 8.30815 15.6891C9.28647 16.668 10.6135 17.2184 11.9975 17.2193Z" fill="#D80027"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4220_10208">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 909 B |
23
public/assets/ark-library/flags/ko.svg
Normal file
@@ -0,0 +1,23 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4220_10206)">
|
||||
<path d="M24 12C24 13.5759 23.6896 15.1363 23.0866 16.5922C22.4835 18.0481 21.5996 19.371 20.4853 20.4853C19.371 21.5996 18.0481 22.4835 16.5922 23.0866C15.1363 23.6896 13.5759 24 12 24C10.4241 24 8.86371 23.6896 7.4078 23.0866C5.95189 22.4835 4.62902 21.5996 3.51472 20.4853C2.40042 19.371 1.5165 18.0481 0.913447 16.5922C0.310391 15.1363 -3.32088e-08 13.5759 0 12C-3.32088e-08 10.4241 0.310391 8.86371 0.913447 7.4078C1.5165 5.95189 2.40042 4.62902 3.51472 3.51472C4.62902 2.40042 5.95189 1.5165 7.4078 0.913446C8.86371 0.310389 10.4241 0 12 0C13.5759 0 15.1363 0.310389 16.5922 0.913446C18.0481 1.5165 19.371 2.40042 20.4853 3.51472C21.5996 4.62902 22.4835 5.95189 23.0866 7.4078C23.6896 8.86371 24 10.4241 24 12Z" fill="white"/>
|
||||
<path d="M3.64258 8.92969L5.94777 5.47398L3.64258 8.92969ZM4.50651 9.50599L6.8117 6.05027L4.50651 9.50599ZM5.37043 10.0823L7.67563 6.62657L5.37043 10.0823Z" fill="black"/>
|
||||
<path d="M16.3135 17.3828L18.6187 13.9271L16.3135 17.3828ZM17.1774 17.9591L19.4826 14.5034L17.1774 17.9591ZM18.0413 18.5354L20.3465 15.0797L18.0413 18.5354Z" fill="black"/>
|
||||
<path d="M16.8906 15.2715L19.7704 17.1925L16.8906 15.2715Z" fill="black"/>
|
||||
<path d="M16.8906 15.2715L19.7704 17.1925" stroke="white" stroke-width="7.38492"/>
|
||||
<path d="M15.451 14.3109C16.7241 12.4024 16.209 9.82314 14.3005 8.55002C12.3919 7.2769 9.81269 7.792 8.53957 9.70054C7.26644 11.6091 7.78155 14.1883 9.69009 15.4614C11.5986 16.7346 14.1779 16.2195 15.451 14.3109Z" fill="#E74C3C"/>
|
||||
<path d="M8.53957 9.70054C8.23388 10.1588 8.12275 10.7197 8.23064 11.2599C8.33852 11.8001 8.65657 12.2753 9.11483 12.581C9.57308 12.8867 10.134 12.9978 10.6742 12.8899C11.2144 12.782 11.6896 12.464 11.9953 12.0057C12.301 11.5475 12.7762 11.2294 13.3164 11.1215C13.8566 11.0137 14.4175 11.1248 14.8757 11.4305C15.334 11.7362 15.652 12.2114 15.7599 12.7516C15.8678 13.2917 15.7567 13.8527 15.451 14.3109C14.8396 15.2274 13.8892 15.8635 12.8088 16.0793C11.7284 16.2951 10.6066 16.0728 9.69009 15.4614C8.77358 14.8501 8.13747 13.8996 7.9217 12.8193C7.70594 11.7389 7.92819 10.617 8.53957 9.70054Z" fill="#356BA0"/>
|
||||
<path d="M5.94824 18.5352L3.64305 15.0794L5.94824 18.5352ZM6.81217 17.9589L4.50698 14.5031L6.81217 17.9589ZM7.6761 17.3826L5.37091 13.9268L7.6761 17.3826Z" fill="black"/>
|
||||
<path d="M18.6191 10.082L16.3139 6.62632L18.6191 10.082ZM19.4831 9.50573L17.1779 6.05002L19.4831 9.50573ZM20.347 8.92944L18.0418 5.47372L20.347 8.92944Z" fill="black"/>
|
||||
<path d="M7.38365 6.43193L7.9685 6.82207L5.66331 10.2778L5.07846 9.88765L7.38365 6.43193ZM6.5197 5.85562L7.10459 6.24578L4.7994 9.7015L4.21451 9.31134L6.5197 5.85562ZM5.65579 5.27934L6.24064 5.66947L3.93545 9.12519L3.3506 8.73505L5.65579 5.27934Z" fill="#222222"/>
|
||||
<path d="M20.0546 14.8843L20.6394 15.2744L18.3343 18.7301L17.7494 18.34L20.0546 14.8843ZM19.1906 14.308L19.7755 14.6981L17.4703 18.1538L16.8855 17.7637L19.1906 14.308ZM18.3267 13.7317L18.9116 14.1218L16.6064 17.5775L16.0215 17.1874L18.3267 13.7317Z" fill="#222222"/>
|
||||
<path d="M5.07844 14.1219L5.66329 13.7317L7.96849 17.1874L7.38364 17.5776L5.07844 14.1219ZM4.2145 14.6982L4.79938 14.308L7.10458 17.7637L6.51969 18.1539L4.2145 14.6982ZM3.35059 15.2745L3.93544 14.8843L6.24063 18.34L5.65578 18.7302L3.35059 15.2745Z" fill="#222222"/>
|
||||
<path d="M17.7494 5.66943L18.3342 5.2793L20.6394 8.73501L20.0546 9.12515L17.7494 5.66943ZM16.8854 6.24574L17.4703 5.85558L19.7755 9.3113L19.1906 9.70146L16.8854 6.24574ZM16.0215 6.82203L16.6064 6.43189L18.9116 9.88761L18.3267 10.2777L16.0215 6.82203Z" fill="#222222"/>
|
||||
<path d="M18.666 7.34743L19.53 6.77112L19.722 7.05909L18.8581 7.6354L18.666 7.34743ZM16.7942 8.59608L17.8021 7.92372L17.9942 8.21169L16.9863 8.88405L16.7942 8.59608ZM5.13115 16.3761L5.99506 15.7998L6.18715 16.0878L5.32324 16.6641L5.13115 16.3761Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4220_10206">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
25
public/assets/ark-library/flags/ms.svg
Normal file
@@ -0,0 +1,25 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4220_10209)">
|
||||
<mask id="mask0_4220_10209" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="-1" width="24" height="25">
|
||||
<path d="M12 23.9785C18.6274 23.9785 24 18.6059 24 11.9785C24 5.3511 18.6274 -0.0214844 12 -0.0214844C5.37258 -0.0214844 0 5.3511 0 11.9785C0 18.6059 5.37258 23.9785 12 23.9785Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_4220_10209)">
|
||||
<path d="M0 0H24V24.048H0V0Z" fill="#CB2026"/>
|
||||
<path d="M0 22.332H24V24.0497H0V22.332Z" fill="white"/>
|
||||
<path d="M0 18.8965H24V20.6142H0V18.8965Z" fill="white"/>
|
||||
<path d="M0 15.4609H24V17.1786H0V15.4609Z" fill="white"/>
|
||||
<path d="M0 12.0254H24V13.7431H0V12.0254Z" fill="white"/>
|
||||
<path d="M0 8.58984H24V10.3076H0V8.58984Z" fill="white"/>
|
||||
<path d="M0 5.1543H24V6.872H0V5.1543Z" fill="white"/>
|
||||
<path d="M0 1.71875H24V3.43648H0V1.71875Z" fill="white"/>
|
||||
<path d="M0 -0.0214844H15.1638V13.742H0V-0.0214844Z" fill="#21205F"/>
|
||||
<path d="M7.13106 4.41406C5.14942 4.41406 3.54102 6.01709 3.54102 7.99217C3.54102 9.96725 5.14942 11.5703 7.13106 11.5703C7.84698 11.5703 8.51401 11.3608 9.07426 11.0004C8.74801 11.1129 8.39787 11.1746 8.03338 11.1746C6.27625 11.1746 4.85014 9.75315 4.85014 8.00185C4.85014 6.25047 6.27625 4.82914 8.03338 4.82914C8.4217 4.82914 8.79368 4.8985 9.13786 5.02553C8.56486 4.63961 7.87417 4.41406 7.13106 4.41406Z" fill="#FFCD05"/>
|
||||
<path d="M13.085 9.85617L11.5728 9.06714L12.0198 10.682L11.0084 9.33121L10.6929 10.9753L10.3825 9.33021L9.36676 10.6779L9.81901 9.06453L8.3043 9.84899L9.42949 8.58669L7.71582 8.65249L9.2912 7.99139L7.71793 7.32549L9.43144 7.39643L8.31018 6.13079L9.82237 6.91981L9.3753 5.30502L10.3868 6.65577L10.7023 5.01172L11.0126 6.65675L12.0284 5.30903L11.5761 6.92243L13.0908 6.13797L11.9656 7.40027L13.6793 7.33446L12.104 7.99559L13.6772 8.66147L11.9637 8.59053L13.085 9.85617Z" fill="#FFCD05"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4220_10209">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
11
public/assets/ark-library/flags/vi.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4220_10207)">
|
||||
<path d="M12 24C18.6274 24 24 18.6274 24 12C24 5.37258 18.6274 0 12 0C5.37258 0 0 5.37258 0 12C0 18.6274 5.37258 24 12 24Z" fill="#D80027"/>
|
||||
<path d="M11.9995 6.7832L13.2946 10.7689H17.4855L14.095 13.2322L15.39 17.218L11.9995 14.7547L8.60908 17.218L9.90414 13.2322L6.51367 10.7689H10.7045L11.9995 6.7832Z" fill="#FFDA44"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4220_10207">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 574 B |
15
public/assets/ark-library/flags/zh-CN.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4220_10210)">
|
||||
<path d="M12 24C18.6274 24 24 18.6274 24 12C24 5.37258 18.6274 0 12 0C5.37258 0 0 5.37258 0 12C0 18.6274 5.37258 24 12 24Z" fill="#EE1C25"/>
|
||||
<path d="M20.0091 13.8945L18.8307 13.8753L18.4475 12.7617L18.0651 13.8753L16.8867 13.8953L17.8283 14.6025L17.4835 15.7281L18.4483 15.0521L19.4131 15.7281L19.0675 14.6025L20.0091 13.8945Z" fill="#FFFF00"/>
|
||||
<path d="M15.6869 8.58218L15.7061 7.40378L16.8197 7.02058L15.7061 6.63738L15.6861 5.45898L14.9789 6.40058L13.8525 6.05658L14.5293 7.02138L13.8525 7.98618L14.9789 7.63978L15.6869 8.58218Z" fill="#FFFF00"/>
|
||||
<path d="M15.6869 18.5412L15.7061 17.3628L16.8197 16.9796L15.7061 16.5964L15.6861 15.418L14.9789 16.3596L13.8525 16.0156L14.5293 16.9804L13.8525 17.9452L14.9789 17.5988L15.6869 18.5412Z" fill="#FFFF00"/>
|
||||
<path d="M18.9224 10.0288L19.4392 8.97037L18.3808 9.48717L17.5336 8.66797L17.6992 9.83357L16.6592 10.3864L17.82 10.5904L18.024 11.7512L18.5752 10.7104L19.7416 10.8752L18.9224 10.0288Z" fill="#FFFF00"/>
|
||||
<path d="M12.4088 10.572L9.23201 10.5192L8.19921 7.51758L7.16721 10.52L3.99121 10.5736L6.52961 12.4816L5.60001 15.5176L8.20081 13.6944L10.8024 15.516L9.87121 12.4808L12.4088 10.572Z" fill="#FFFF00"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4220_10210">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
3
public/assets/ark-library/header-menu.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19.875 16.5C20.5125 16.5 21 16.9875 21 17.625C21 18.2625 20.5125 18.75 19.875 18.75H4.125C3.4875 18.75 3 18.2625 3 17.625C3 16.9875 3.4875 16.5 4.125 16.5H19.875ZM19.875 11.25C20.5125 11.25 21 11.7375 21 12.375C21 13.0125 20.5125 13.5 19.875 13.5H4.125C3.4875 13.5 3 13.0125 3 12.375C3 11.7375 3.4875 11.25 4.125 11.25H19.875ZM19.875 6C20.5125 6 21 6.4875 21 7.125C21 7.7625 20.5125 8.25 19.875 8.25H4.125C3.4875 8.25 3 7.7625 3 7.125C3 6.4875 3.4875 6 4.125 6H19.875Z" fill="#A8A9AE"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 600 B |
3
public/assets/ark-library/header-search.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.7372 18.1928L16.6086 14.0643C16.2582 13.7138 15.6869 13.7138 15.3364 14.0643L14.3427 13.0705C16.191 10.603 15.9989 7.08894 13.757 4.84705C11.2943 2.38432 7.30497 2.38432 4.84705 4.84705C2.38432 7.30977 2.38432 11.2991 4.84705 13.757C7.08894 15.9989 10.603 16.191 13.0705 14.3427L14.0643 15.3364C13.7138 15.6869 13.7138 16.2582 14.0643 16.6086L18.1928 20.7372C18.5433 21.0876 19.1145 21.0876 19.465 20.7372L20.7372 19.465C21.0876 19.1145 21.0876 18.5433 20.7372 18.1928ZM12.9361 11.9328L11.9424 12.9265C11.1791 13.4834 10.267 13.7954 9.30684 13.7954C8.09708 13.8002 6.96893 13.3346 6.11922 12.4801C5.2695 11.6304 4.79904 10.4974 4.79904 9.29724C4.79904 8.09708 5.2695 6.96413 6.11922 6.11442C6.96893 5.2647 8.10188 4.79424 9.30204 4.79424C10.5022 4.79424 11.6352 5.2647 12.4849 6.11442C13.3346 6.96413 13.805 8.09708 13.805 9.29724C13.805 10.2622 13.493 11.1695 12.9361 11.9328Z" fill="#A8A9AE"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1012 B |
BIN
public/assets/ark-library/media/png/academy-materials.png
Normal file
|
After Width: | Height: | Size: 264 KiB |
BIN
public/assets/ark-library/media/png/academy-video.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 343 KiB |
@@ -1,19 +0,0 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M28.2619 19.2138C28.7318 18.9688 29.2941 19.3098 29.2941 19.8397V24.3033C29.2941 26.091 29.2941 28.2271 26.4919 29.8449C24.5294 30.978 21.2896 33.1462 17.4706 33.1462C13.6516 33.1462 10.4118 31.0286 8.43741 29.8449C5.64706 28.1721 5.64706 26.091 5.64706 24.3033V19.8397C5.64706 19.3098 6.20934 18.9688 6.67929 19.2138L14.0536 23.0587C15.1295 23.534 16.2882 23.7716 17.4706 23.7716C18.6529 23.7716 19.8116 23.534 20.8876 23.0587L28.2619 19.2138Z" fill="url(#paint0_linear_3621_1510)"/>
|
||||
<path d="M32.7491 16.5221C33.2168 16.3069 33.75 16.6486 33.75 17.1633V26.9036C33.75 27.5223 33.2438 28.0286 32.625 28.0286C32.0063 28.0286 31.5 27.5223 31.5 26.9036V18.0012C31.5 17.4499 31.8209 16.949 32.3218 16.7186L32.7491 16.5221Z" fill="url(#paint1_linear_3621_1510)"/>
|
||||
<path d="M15.6803 21.1454C16.4171 21.4818 17.208 21.6505 18 21.6505C18.792 21.6505 19.5818 21.4829 20.3198 21.1454L34.6871 13.1659C35.4971 12.7958 36 12.0117 36 11.1195C36 10.2274 35.4971 9.44216 34.6871 9.07203L20.3198 2.50541C18.8449 1.83153 17.1562 1.83153 15.6814 2.50541L1.31288 9.07091C0.502875 9.44216 0 10.2263 0 11.1184C0 12.0105 0.502875 12.7947 1.31288 13.1659L15.6803 21.1454Z" fill="url(#paint2_linear_3621_1510)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_3621_1510" x1="18" y1="2" x2="18" y2="33.1462" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_3621_1510" x1="18" y1="2" x2="18" y2="33.1462" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_3621_1510" x1="18" y1="2" x2="18" y2="33.1462" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,9 +0,0 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M28.5233 3.73047H7.47674C3.34745 3.73047 0 7.07792 0 11.2072L0 24.4091C0 28.5384 3.34745 31.8858 7.47674 31.8858H28.5233C32.6526 31.8858 36 28.5384 36 24.4091V11.2072C36 7.07792 32.6526 3.73047 28.5233 3.73047ZM24.2328 18.5115L14.3886 23.2066C14.1263 23.3317 13.8233 23.1404 13.8233 22.8499V13.1663C13.8233 12.8716 14.1343 12.6806 14.3971 12.8138L24.2413 17.8023C24.5339 17.9506 24.5289 18.3703 24.2328 18.5115Z" fill="url(#paint0_linear_3621_1529)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_3621_1529" x1="18" y1="3.73047" x2="18" y2="31.8858" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 785 B |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 445 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 381 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 489 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 406 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 407 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 414 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 379 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 504 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 405 KiB |
3
public/assets/ark-library/navbar/bookmark-active.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.6453 3H5.373C5.00886 3 4.65963 3.13876 4.40214 3.38576C4.14465 3.63276 4 3.96776 4 4.31707V19.4634C4.00036 19.7517 4.08674 20.0339 4.24894 20.2766C4.41114 20.5193 4.64238 20.7125 4.91533 20.8332C5.1355 20.944 5.38066 21.0012 5.62929 21C5.95995 20.9896 6.27938 20.8824 6.54462 20.6927L11.4416 17.1805C11.6001 17.0665 11.7928 17.0049 11.9908 17.0049C12.1889 17.0049 12.3816 17.0665 12.54 17.1805L17.4371 20.6927C17.6751 20.8639 17.958 20.9681 18.2543 20.9938C18.5506 21.0194 18.8485 20.9654 19.1145 20.8378C19.3806 20.7101 19.6044 20.514 19.7608 20.2712C19.9172 20.0285 20 19.7488 20 19.4634V4.31707C20 3.97078 19.8579 3.63842 19.6044 3.39192C19.3508 3.14542 19.0063 3.00462 18.6453 3Z" fill="#EEB726"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 818 B |
3
public/assets/ark-library/navbar/bookmark-inactive.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.6453 3H5.373C5.00886 3 4.65963 3.13876 4.40214 3.38576C4.14465 3.63276 4 3.96776 4 4.31707V19.4634C4.00036 19.7517 4.08674 20.0339 4.24894 20.2766C4.41114 20.5193 4.64238 20.7125 4.91533 20.8332C5.1355 20.944 5.38066 21.0012 5.62929 21C5.95995 20.9896 6.27938 20.8824 6.54462 20.6927L11.4416 17.1805C11.6001 17.0665 11.7928 17.0049 11.9908 17.0049C12.1889 17.0049 12.3816 17.0665 12.54 17.1805L17.4371 20.6927C17.6751 20.8639 17.958 20.9681 18.2543 20.9938C18.5506 21.0194 18.8485 20.9654 19.1145 20.8378C19.3806 20.7101 19.6044 20.514 19.7608 20.2712C19.9172 20.0285 20 19.7488 20 19.4634V4.31707C20 3.97078 19.8579 3.63842 19.6044 3.39192C19.3508 3.14542 19.0063 3.00462 18.6453 3Z" fill="#908F92"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 818 B |
@@ -1,3 +1,3 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M45.5346 41.8605C45.5346 43.488 44.8872 45.0508 43.7374 46.2028C42.5854 47.3526 41.0226 48 39.3951 48H9.60438C7.97684 48 6.41405 47.3526 5.26205 46.2028C4.11229 45.0508 3.46484 43.488 3.46484 41.8605V6.13953C3.46484 4.512 4.11229 2.94921 5.26205 1.79721C6.41405 0.647442 7.97684 0 9.60438 0L31.7731 0C32.809 0 33.8025 0.410791 34.5348 1.1453L44.3893 10.9998C45.1238 11.7321 45.5346 12.7256 45.5346 13.7615V41.8605ZM15.0695 39.0698H25.116C26.0403 39.0698 26.7904 38.3196 26.7904 37.3953C26.7904 36.4711 26.0403 35.7209 25.116 35.7209H15.0695C14.1452 35.7209 13.3951 36.4711 13.3951 37.3953C13.3951 38.3196 14.1452 39.0698 15.0695 39.0698ZM15.0695 31.2558H32.93C33.8542 31.2558 34.6044 30.5057 34.6044 29.5814C34.6044 28.6571 33.8542 27.907 32.93 27.907H15.0695C14.1452 27.907 13.3951 28.6571 13.3951 29.5814C13.3951 30.5057 14.1452 31.2558 15.0695 31.2558ZM33.3721 4.71875V10.5091C33.3721 11.4223 34.1103 12.1635 35.0199 12.1635H40.7871L33.3721 4.71875ZM15.0695 23.4419H32.93C33.8542 23.4419 34.6044 22.6917 34.6044 21.7674C34.6044 20.8432 33.8542 20.093 32.93 20.093H15.0695C14.1452 20.093 13.3951 20.8432 13.3951 21.7674C13.3951 22.6917 14.1452 23.4419 15.0695 23.4419Z" fill="white"/>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.09766 13.3958C7.57534 13.3958 7.97405 13.3965 8.29981 13.4231C8.63965 13.4509 8.95903 13.5106 9.26074 13.6643C9.72355 13.9002 10.1001 14.2767 10.3359 14.7395C10.4896 15.0412 10.5493 15.3607 10.5771 15.7005C10.6037 16.0262 10.6035 16.4249 10.6035 16.9026V17.4934C10.6035 17.9712 10.6037 18.3708 10.5771 18.6966C10.5493 19.0362 10.4894 19.3551 10.3359 19.6565C10.1001 20.1193 9.72347 20.4959 9.26074 20.7317C8.95903 20.8855 8.63965 20.9462 8.29981 20.9739C7.97408 21.0006 7.57529 21.0004 7.09766 21.0003H6.50586C6.0282 21.0004 5.62947 21.0006 5.30371 20.9739C4.96388 20.9462 4.64448 20.8855 4.34277 20.7317C3.88 20.4959 3.5034 20.1194 3.26758 19.6565C3.11405 19.3551 3.05413 19.0362 3.02637 18.6966C2.99975 18.3708 2.99999 17.9712 3 17.4934V16.9026C2.99999 16.4249 2.99977 16.0262 3.02637 15.7005C3.05413 15.3607 3.11388 15.0412 3.26758 14.7395C3.50343 14.2766 3.87991 13.9002 4.34277 13.6643C4.64448 13.5106 4.96388 13.4509 5.30371 13.4231C5.62947 13.3965 6.02818 13.3958 6.50586 13.3958H7.09766ZM16.9375 13.3958C17.4154 13.3958 17.8147 13.3965 18.1406 13.4231C18.4802 13.4509 18.799 13.5107 19.1006 13.6643C19.5634 13.9001 19.9399 14.2766 20.1758 14.7395C20.3295 15.0412 20.3901 15.3606 20.418 15.7005C20.4445 16.0262 20.4443 16.425 20.4443 16.9026V17.4934C20.4443 17.9712 20.4445 18.3708 20.418 18.6966C20.3901 19.0363 20.3294 19.355 20.1758 19.6565C19.9399 20.1195 19.5634 20.4959 19.1006 20.7317C18.799 20.8854 18.4803 20.9462 18.1406 20.9739C17.8148 21.0006 17.4153 21.0004 16.9375 21.0003H16.3467C15.8689 21.0004 15.4694 21.0006 15.1436 20.9739C14.8039 20.9462 14.4852 20.8854 14.1836 20.7317C13.7207 20.4959 13.3443 20.1195 13.1084 19.6565C12.9549 19.355 12.895 19.0362 12.8672 18.6966C12.8406 18.3708 12.8398 17.9712 12.8398 17.4934V16.9026C12.8398 16.425 12.8406 16.0262 12.8672 15.7005C12.895 15.3606 12.9547 15.0412 13.1084 14.7395C13.3443 14.2766 13.7208 13.9001 14.1836 13.6643C14.4851 13.5107 14.804 13.4509 15.1436 13.4231C15.4695 13.3965 15.8688 13.3958 16.3467 13.3958H16.9375ZM15.8818 3.12039C16.3759 2.95986 16.9083 2.95988 17.4023 3.12039C17.7243 3.22501 17.992 3.40858 18.252 3.62918C18.5011 3.84068 18.7834 4.12251 19.1211 4.46024L19.5205 4.85965L19.54 4.87821C19.8778 5.21599 20.1595 5.49817 20.3711 5.74735C20.5918 6.00728 20.7753 6.27589 20.8799 6.59793C21.0404 7.09195 21.0404 7.62444 20.8799 8.11844C20.7753 8.44037 20.5917 8.70819 20.3711 8.96805C20.1596 9.2172 19.8778 9.49943 19.54 9.83719L19.5205 9.85672L19.1406 10.2356L19.1211 10.2552C18.7834 10.593 18.5011 10.8757 18.252 11.0872C17.9921 11.3077 17.7242 11.4904 17.4023 11.595C16.9083 11.7556 16.3759 11.7556 15.8818 11.595C15.56 11.4904 15.292 11.3077 15.0322 11.0872C14.783 10.8756 14.5009 10.593 14.1631 10.2552L14.1436 10.2356L13.7637 9.85672L13.7451 9.83719C13.4072 9.49931 13.1247 9.21727 12.9131 8.96805C12.6925 8.7082 12.5089 8.44036 12.4043 8.11844C12.2438 7.62445 12.2438 7.09194 12.4043 6.59793C12.5089 6.27589 12.6924 6.00728 12.9131 5.74735C13.1246 5.49815 13.4073 5.21603 13.7451 4.87821L14.1631 4.46024C14.5008 4.12249 14.7831 3.84069 15.0322 3.62918C15.2921 3.40856 15.5598 3.22502 15.8818 3.12039ZM7.09766 3.55594C7.57532 3.55593 7.97406 3.5557 8.29981 3.58231C8.63965 3.61008 8.95903 3.67077 9.26074 3.8245C9.72338 4.06033 10.1001 4.43606 10.3359 4.89871C10.4896 5.20038 10.5493 5.51987 10.5771 5.85965C10.6037 6.18537 10.6035 6.58418 10.6035 7.0618V7.6536C10.6035 8.13125 10.6037 8.53 10.5771 8.85575C10.5493 9.19555 10.4896 9.515 10.3359 9.81668C10.1001 10.2795 9.72356 10.6561 9.26074 10.8919C8.95903 11.0457 8.63965 11.1054 8.29981 11.1331C7.97406 11.1597 7.57534 11.1595 7.09766 11.1595H6.50586C6.02819 11.1595 5.62948 11.1597 5.30371 11.1331C4.96388 11.1054 4.64448 11.0457 4.34277 10.8919C3.87991 10.6561 3.50343 10.2795 3.26758 9.81668C3.11388 9.51499 3.05413 9.19556 3.02637 8.85575C2.99977 8.52999 2.99999 8.13126 3 7.6536V7.06278C2.99999 6.58496 2.99975 6.18548 3.02637 5.85965C3.05414 5.51987 3.11387 5.20038 3.26758 4.89871C3.5034 4.43604 3.8801 4.06032 4.34277 3.8245C4.64448 3.67077 4.96388 3.61008 5.30371 3.58231C5.62946 3.5557 6.0282 3.55593 6.50586 3.55594H7.09766Z" fill="#EEB726"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 4.1 KiB |
@@ -1,7 +1,3 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M45.5346 41.8605C45.5346 43.488 44.8872 45.0508 43.7374 46.2028C42.5854 47.3526 41.0226 48 39.3951 48H9.60438C7.97684 48 6.41405 47.3526 5.26205 46.2028C4.11229 45.0508 3.46484 43.488 3.46484 41.8605V6.13953C3.46484 4.512 4.11229 2.94921 5.26205 1.79721C6.41405 0.647442 7.97684 0 9.60438 0H31.7731C32.809 0 33.8025 0.410791 34.5348 1.1453L44.3893 10.9998C45.1238 11.7321 45.5346 12.7256 45.5346 13.7615V41.8605ZM42.1858 41.8605V13.7615C42.1858 13.6141 42.1277 13.4713 42.0228 13.3663L32.1683 3.51181C32.0633 3.40688 31.9205 3.34884 31.7731 3.34884H9.60438C8.86317 3.34884 8.15545 3.64353 7.6308 4.16595C7.10838 4.6906 6.81368 5.39833 6.81368 6.13953V41.8605C6.81368 42.6017 7.10838 43.3094 7.6308 43.834C8.15545 44.3565 8.86317 44.6512 9.60438 44.6512H39.3951C40.1363 44.6512 40.844 44.3565 41.3687 43.834C41.8911 43.3094 42.1858 42.6017 42.1858 41.8605Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M30.0234 3.79063C30.0234 2.86635 30.7736 2.11621 31.6979 2.11621C32.6221 2.11621 33.3723 2.86635 33.3723 3.79063V11.6046C33.3723 11.9127 33.6223 12.1627 33.9304 12.1627H41.7444C42.6686 12.1627 43.4188 12.9129 43.4188 13.8371C43.4188 14.7614 42.6686 15.5116 41.7444 15.5116H33.9304C31.7715 15.5116 30.0234 13.7635 30.0234 11.6046V3.79063Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.0699 22.3256C14.1456 22.3256 13.3955 21.5755 13.3955 20.6512C13.3955 19.7269 14.1456 18.9768 15.0699 18.9768H32.9304C33.8547 18.9768 34.6048 19.7269 34.6048 20.6512C34.6048 21.5755 33.8547 22.3256 32.9304 22.3256H15.0699Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.0699 30.1396C14.1456 30.1396 13.3955 29.3895 13.3955 28.4652C13.3955 27.5409 14.1456 26.7908 15.0699 26.7908H32.9304C33.8547 26.7908 34.6048 27.5409 34.6048 28.4652C34.6048 29.3895 33.8547 30.1396 32.9304 30.1396H15.0699Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.0699 37.9536C14.1456 37.9536 13.3955 37.2034 13.3955 36.2792C13.3955 35.3549 14.1456 34.6047 15.0699 34.6047H25.1164C26.0407 34.6047 26.7909 35.3549 26.7909 36.2792C26.7909 37.2034 26.0407 37.9536 25.1164 37.9536H15.0699Z" fill="white"/>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.09766 13.3958C7.57534 13.3958 7.97405 13.3965 8.29981 13.4231C8.63965 13.4509 8.95903 13.5106 9.26074 13.6643C9.72355 13.9002 10.1001 14.2767 10.3359 14.7395C10.4896 15.0412 10.5493 15.3607 10.5771 15.7005C10.6037 16.0262 10.6035 16.4249 10.6035 16.9026V17.4934C10.6035 17.9712 10.6037 18.3708 10.5771 18.6966C10.5493 19.0362 10.4894 19.3551 10.3359 19.6565C10.1001 20.1193 9.72347 20.4959 9.26074 20.7317C8.95903 20.8855 8.63965 20.9462 8.29981 20.9739C7.97408 21.0006 7.57529 21.0004 7.09766 21.0003H6.50586C6.0282 21.0004 5.62947 21.0006 5.30371 20.9739C4.96388 20.9462 4.64448 20.8855 4.34277 20.7317C3.88 20.4959 3.5034 20.1194 3.26758 19.6565C3.11405 19.3551 3.05413 19.0362 3.02637 18.6966C2.99975 18.3708 2.99999 17.9712 3 17.4934V16.9026C2.99999 16.4249 2.99977 16.0262 3.02637 15.7005C3.05413 15.3607 3.11388 15.0412 3.26758 14.7395C3.50343 14.2766 3.87991 13.9002 4.34277 13.6643C4.64448 13.5106 4.96388 13.4509 5.30371 13.4231C5.62947 13.3965 6.02818 13.3958 6.50586 13.3958H7.09766ZM16.9375 13.3958C17.4154 13.3958 17.8147 13.3965 18.1406 13.4231C18.4802 13.4509 18.799 13.5107 19.1006 13.6643C19.5634 13.9001 19.9399 14.2766 20.1758 14.7395C20.3295 15.0412 20.3901 15.3606 20.418 15.7005C20.4445 16.0262 20.4443 16.425 20.4443 16.9026V17.4934C20.4443 17.9712 20.4445 18.3708 20.418 18.6966C20.3901 19.0363 20.3294 19.355 20.1758 19.6565C19.9399 20.1195 19.5634 20.4959 19.1006 20.7317C18.799 20.8854 18.4803 20.9462 18.1406 20.9739C17.8148 21.0006 17.4153 21.0004 16.9375 21.0003H16.3467C15.8689 21.0004 15.4694 21.0006 15.1436 20.9739C14.8039 20.9462 14.4852 20.8854 14.1836 20.7317C13.7207 20.4959 13.3443 20.1195 13.1084 19.6565C12.9549 19.355 12.895 19.0362 12.8672 18.6966C12.8406 18.3708 12.8398 17.9712 12.8398 17.4934V16.9026C12.8398 16.425 12.8406 16.0262 12.8672 15.7005C12.895 15.3606 12.9547 15.0412 13.1084 14.7395C13.3443 14.2766 13.7208 13.9001 14.1836 13.6643C14.4851 13.5107 14.804 13.4509 15.1436 13.4231C15.4695 13.3965 15.8688 13.3958 16.3467 13.3958H16.9375ZM15.8818 3.12039C16.3759 2.95986 16.9083 2.95988 17.4023 3.12039C17.7243 3.22501 17.992 3.40858 18.252 3.62918C18.5011 3.84068 18.7834 4.12251 19.1211 4.46024L19.5205 4.85965L19.54 4.87821C19.8778 5.21599 20.1595 5.49817 20.3711 5.74735C20.5918 6.00728 20.7753 6.27589 20.8799 6.59793C21.0404 7.09195 21.0404 7.62444 20.8799 8.11844C20.7753 8.44037 20.5917 8.70819 20.3711 8.96805C20.1596 9.2172 19.8778 9.49943 19.54 9.83719L19.5205 9.85672L19.1406 10.2356L19.1211 10.2552C18.7834 10.593 18.5011 10.8757 18.252 11.0872C17.9921 11.3077 17.7242 11.4904 17.4023 11.595C16.9083 11.7556 16.3759 11.7556 15.8818 11.595C15.56 11.4904 15.292 11.3077 15.0322 11.0872C14.783 10.8756 14.5009 10.593 14.1631 10.2552L14.1436 10.2356L13.7637 9.85672L13.7451 9.83719C13.4072 9.49931 13.1247 9.21727 12.9131 8.96805C12.6925 8.7082 12.5089 8.44036 12.4043 8.11844C12.2438 7.62445 12.2438 7.09194 12.4043 6.59793C12.5089 6.27589 12.6924 6.00728 12.9131 5.74735C13.1246 5.49815 13.4073 5.21603 13.7451 4.87821L14.1631 4.46024C14.5008 4.12249 14.7831 3.84069 15.0322 3.62918C15.2921 3.40856 15.5598 3.22502 15.8818 3.12039ZM7.09766 3.55594C7.57532 3.55593 7.97406 3.5557 8.29981 3.58231C8.63965 3.61008 8.95903 3.67077 9.26074 3.8245C9.72338 4.06033 10.1001 4.43606 10.3359 4.89871C10.4896 5.20038 10.5493 5.51987 10.5771 5.85965C10.6037 6.18537 10.6035 6.58418 10.6035 7.0618V7.6536C10.6035 8.13125 10.6037 8.53 10.5771 8.85575C10.5493 9.19555 10.4896 9.515 10.3359 9.81668C10.1001 10.2795 9.72356 10.6561 9.26074 10.8919C8.95903 11.0457 8.63965 11.1054 8.29981 11.1331C7.97406 11.1597 7.57534 11.1595 7.09766 11.1595H6.50586C6.02819 11.1595 5.62948 11.1597 5.30371 11.1331C4.96388 11.1054 4.64448 11.0457 4.34277 10.8919C3.87991 10.6561 3.50343 10.2795 3.26758 9.81668C3.11388 9.51499 3.05413 9.19556 3.02637 8.85575C2.99977 8.52999 2.99999 8.13126 3 7.6536V7.06278C2.99999 6.58496 2.99975 6.18548 3.02637 5.85965C3.05414 5.51987 3.11387 5.20038 3.26758 4.89871C3.5034 4.43604 3.8801 4.06032 4.34277 3.8245C4.64448 3.67077 4.96388 3.61008 5.30371 3.58231C5.62946 3.5557 6.0282 3.55593 6.50586 3.55594H7.09766Z" fill="#908F92"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 4.1 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.38367 8.28389C7.6745 5.72389 10.8178 4.31396 14.2353 4.31396C16.7899 4.31396 19.1293 5.14608 21.1889 6.78699C22.2281 7.61528 23.1697 8.62863 24 9.8114C24.8299 8.62898 25.7719 7.61528 26.8114 6.78699C28.8706 5.14608 31.2101 4.31396 33.7646 4.31396C37.1821 4.31396 40.3258 5.72389 42.6166 8.28389C44.8801 10.814 46.127 14.2704 46.127 18.017C46.127 21.8732 44.7322 25.4031 41.7378 29.126C39.059 32.4562 35.209 35.8368 30.7506 39.7514C29.2282 41.0883 27.5026 42.6036 25.7107 44.2178C25.2374 44.645 24.63 44.8801 24 44.8801C23.3703 44.8801 22.7625 44.645 22.2899 44.2185C20.4981 42.604 18.7714 41.088 17.2484 39.7504C12.7906 35.8365 8.94061 32.4562 6.26185 29.1257C3.2674 25.4031 1.87298 21.8732 1.87298 18.0167C1.87298 14.2704 3.11985 10.814 5.38367 8.28389Z" fill="white" stroke="white" stroke-width="2.4"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 920 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.38367 8.28389C7.6745 5.72389 10.8178 4.31396 14.2353 4.31396C16.7899 4.31396 19.1293 5.14608 21.1889 6.78699C22.2281 7.61528 23.1697 8.62863 24 9.8114C24.8299 8.62898 25.7719 7.61528 26.8114 6.78699C28.8706 5.14608 31.2101 4.31396 33.7646 4.31396C37.1821 4.31396 40.3258 5.72389 42.6166 8.28389C44.8801 10.814 46.127 14.2704 46.127 18.017C46.127 21.8732 44.7322 25.4031 41.7378 29.126C39.059 32.4562 35.209 35.8368 30.7506 39.7514C29.2282 41.0883 27.5026 42.6036 25.7107 44.2178C25.2374 44.645 24.63 44.8801 24 44.8801C23.3703 44.8801 22.7625 44.645 22.2899 44.2185C20.4981 42.604 18.7714 41.088 17.2484 39.7504C12.7906 35.8365 8.94061 32.4562 6.26185 29.1257C3.2674 25.4031 1.87298 21.8732 1.87298 18.0167C1.87298 14.2704 3.11985 10.814 5.38367 8.28389Z" stroke="white" stroke-width="2.4"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 907 B |
@@ -1,10 +1,3 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3644_75)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M44.3496 13.3879L31.4232 2.70005C27.0689 -0.900018 20.9311 -0.900018 16.5769 2.70005L3.65033 13.3879C1.32142 15.3134 0 18.2329 0 21.2791V37.9462C0 43.3584 4.16196 48 9.6 48H14.4C17.051 48 19.2 45.851 19.2 43.2V35.3947C19.2 32.3527 21.485 30.1409 24 30.1409C26.515 30.1409 28.8 32.3527 28.8 35.3947V43.2C28.8 45.851 30.949 48 33.6 48H38.4C43.8382 48 48 43.3584 48 37.9462V21.2791C48 18.2329 46.6786 15.3134 44.3496 13.3879Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3644_75">
|
||||
<rect width="48" height="48" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.6311 8.02044L14.7837 4.01252C13.1508 2.66249 10.8492 2.66249 9.21633 4.01252L4.36887 8.02044C3.49553 8.74253 3 9.83735 3 10.9797V17.2298C3 19.2594 4.56073 21 6.6 21H8.4C9.39411 21 10.2 20.1941 10.2 19.2V16.273C10.2 15.1323 11.0569 14.3028 12 14.3028C12.9431 14.3028 13.8 15.1323 13.8 16.273V19.2C13.8 20.1941 14.6059 21 15.6 21H17.4C19.4393 21 21 19.2594 21 17.2298V10.9797C21 9.83736 20.5045 8.74253 19.6311 8.02044Z" fill="#EEB726"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 733 B After Width: | Height: | Size: 592 B |
@@ -1,10 +1,3 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3644_72)">
|
||||
<path d="M45.4514 37.2049V21.4231C45.4513 18.902 44.3566 16.504 42.4571 14.9334L30.2173 4.81364C26.5656 1.79467 21.4361 1.79474 17.7845 4.81364L5.54473 14.9334C3.64525 16.5039 2.5505 18.9019 2.55037 21.4231V37.2049C2.55037 41.7058 5.99784 45.4504 10.3656 45.4505H14.9105C16.7167 45.4505 18.1816 43.9864 18.1818 42.1803V34.7893C18.1818 31.2688 20.8533 28.5405 24.0004 28.5403C27.1476 28.5403 29.82 31.2687 29.82 34.7893V42.1803C29.8202 43.9864 31.2851 45.4505 33.0913 45.4505H37.6362C42.0039 45.4503 45.4514 41.7057 45.4514 37.2049ZM48.0008 37.2049C48.0008 42.9533 43.5667 47.9997 37.6362 47.9999H33.0913C29.8773 47.9999 27.2718 45.3942 27.2717 42.1803V34.7893C27.2717 32.5491 25.6158 31.0886 24.0004 31.0886C22.385 31.0889 20.7301 32.5493 20.7301 34.7893V42.1803C20.73 45.3942 18.1244 47.9999 14.9105 47.9999H10.3656C4.43509 47.9998 0.000976562 42.9534 0.000976562 37.2049V21.4231C0.00109717 18.1759 1.40889 15.0449 3.91946 12.969L16.1603 2.84921C20.7545 -0.949061 27.2473 -0.949111 31.8415 2.84921L44.0813 12.969C46.5921 15.0449 48.0007 18.1758 48.0008 21.4231V37.2049Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3644_72">
|
||||
<rect width="48" height="48" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.6311 8.02044L14.7837 4.01252C13.1508 2.66249 10.8492 2.66249 9.21633 4.01252L4.36887 8.02044C3.49553 8.74253 3 9.83735 3 10.9797V17.2298C3 19.2594 4.56073 21 6.6 21H8.4C9.39411 21 10.2 20.1941 10.2 19.2V16.273C10.2 15.1323 11.0569 14.3028 12 14.3028C12.9431 14.3028 13.8 15.1323 13.8 16.273V19.2C13.8 20.1941 14.6059 21 15.6 21H17.4C19.4393 21 21 19.2594 21 17.2298V10.9797C21 9.83736 20.5045 8.74253 19.6311 8.02044Z" fill="#908F92"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 592 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M44.0381 42.4465C44.0381 45.2856 41.7365 47.5879 38.8975 47.5881H9.10254C6.26352 47.5879 3.96191 45.2856 3.96191 42.4465C3.96213 35.2546 9.79247 29.4243 16.9844 29.4241H31.0156C38.2075 29.4243 44.0379 35.2546 44.0381 42.4465Z" fill="white"/>
|
||||
<path d="M36.2197 12.5625C36.2197 19.3111 30.7486 24.7822 24 24.7822C17.2514 24.7822 11.7803 19.3111 11.7803 12.5625C11.7803 5.81387 17.2514 0.342773 24 0.342773C30.7486 0.342806 36.2197 5.81389 36.2197 12.5625Z" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 582 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M41.6377 42.4465C41.6375 36.5801 36.882 31.8247 31.0156 31.8245H16.9844C11.118 31.8247 6.36252 36.5801 6.3623 42.4465C6.3623 43.9601 7.589 45.1875 9.10254 45.1877H38.8975C40.411 45.1875 41.6377 43.9601 41.6377 42.4465ZM44.0381 42.4465C44.0381 45.2856 41.7365 47.5879 38.8975 47.5881H9.10254C6.26352 47.5879 3.96191 45.2856 3.96191 42.4465C3.96213 35.2546 9.79247 29.4243 16.9844 29.4241H31.0156C38.2075 29.4243 44.0379 35.2546 44.0381 42.4465Z" fill="white"/>
|
||||
<path d="M33.8193 12.5625C33.8193 7.13938 29.4231 2.7432 24 2.74316C18.5769 2.74316 14.1807 7.13936 14.1807 12.5625C14.1807 17.9856 18.5769 22.3818 24 22.3818C29.4231 22.3818 33.8193 17.9856 33.8193 12.5625ZM36.2197 12.5625C36.2197 19.3111 30.7486 24.7822 24 24.7822C17.2514 24.7822 11.7803 19.3111 11.7803 12.5625C11.7803 5.81387 17.2514 0.342773 24 0.342773C30.7486 0.342806 36.2197 5.81389 36.2197 12.5625Z" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 998 B |
@@ -1,3 +1,3 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24 0.799805C36.813 0.799805 47.2002 11.187 47.2002 24C47.2002 36.813 36.813 47.2002 24 47.2002C11.187 47.2002 0.799805 36.813 0.799805 24C0.799805 11.187 11.187 0.799805 24 0.799805ZM24 9C23.3373 9 22.7998 9.53745 22.7998 10.2002V24.2246C22.7999 25.0225 23.098 25.7924 23.6357 26.3818L30.4131 33.8096C30.8597 34.2988 31.6189 34.3331 32.1084 33.8867C32.5979 33.4401 32.633 32.681 32.1865 32.1914L25.4092 24.7637C25.2749 24.6164 25.2003 24.424 25.2002 24.2246V10.2002C25.2002 9.53745 24.6627 9 24 9Z" fill="white"/>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.007 17.3962H19.2285C19.6546 17.3962 20 17.0853 20 16.7018V3.69439C20 3.31089 19.6546 3 19.2285 3H16.8936V7.6225C16.8936 7.79995 16.6686 7.90128 16.5108 7.79487L15.0378 6.8014C14.9527 6.74403 14.8358 6.74403 14.7508 6.8014L13.2778 7.79487C13.12 7.90128 12.8949 7.79995 12.8949 7.6225V3H7.00167C5.34888 3 4 4.20928 4 5.70168V18.2983C4 19.7859 5.34355 21 7.00167 21H19.2285C19.6546 21 20 20.6891 20 20.3056V19.8901C20 19.5066 19.6546 19.1957 19.2285 19.1957H7.007C6.45785 19.1957 6.00466 18.7926 6.00466 18.2935C6.00466 17.7993 6.45252 17.3962 7.007 17.3962Z" fill="#EEB726"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 627 B After Width: | Height: | Size: 689 B |
@@ -1,4 +1,3 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M44.7998 24C44.7998 12.5125 35.4875 3.2002 24 3.2002C12.5125 3.2002 3.2002 12.5125 3.2002 24C3.2002 35.4875 12.5125 44.7998 24 44.7998C35.4875 44.7998 44.7998 35.4875 44.7998 24ZM47.2002 24C47.2002 36.813 36.813 47.2002 24 47.2002C11.187 47.2002 0.799805 36.813 0.799805 24C0.799805 11.187 11.187 0.799805 24 0.799805C36.813 0.799805 47.2002 11.187 47.2002 24Z" fill="white"/>
|
||||
<path d="M32.1865 32.1912C32.6331 32.6807 32.5979 33.4398 32.1084 33.8865C31.6189 34.333 30.8598 34.2987 30.4131 33.8093L23.6357 26.3816C23.0979 25.7921 22.7998 25.0224 22.7998 24.2244V10.2C22.7998 9.53721 23.3373 8.99976 24 8.99976C24.6627 8.99976 25.2002 9.53721 25.2002 10.2V24.2244C25.2002 24.4238 25.2748 24.6161 25.4092 24.7634L32.1865 32.1912Z" fill="white"/>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.007 17.3962H19.2285C19.6546 17.3962 20 17.0853 20 16.7018V3.69439C20 3.31089 19.6546 3 19.2285 3H16.8936V7.6225C16.8936 7.79995 16.6686 7.90128 16.5108 7.79487L15.0378 6.8014C14.9527 6.74403 14.8358 6.74403 14.7508 6.8014L13.2778 7.79487C13.12 7.90128 12.8949 7.79995 12.8949 7.6225V3H7.00167C5.34888 3 4 4.20928 4 5.70168V18.2983C4 19.7859 5.34355 21 7.00167 21H19.2285C19.6546 21 20 20.6891 20 20.3056V19.8901C20 19.5066 19.6546 19.1957 19.2285 19.1957H7.007C6.45785 19.1957 6.00466 18.7926 6.00466 18.2935C6.00466 17.7993 6.45252 17.3962 7.007 17.3962Z" fill="#908F92"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 856 B After Width: | Height: | Size: 689 B |
1
public/assets/ark-library/wallets/imtoken.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#a)"><path d="M512 0H0v512h512V0Z" fill="url(#b)"/><path d="M425.708 148.325c11.246 152.315-86.662 224.305-174.433 231.983-81.602 7.136-158.413-43.005-165.152-120.043-5.558-63.646 33.778-90.743 64.684-93.443 31.787-2.787 58.5 19.134 60.818 45.676 2.231 25.518-13.691 37.134-24.765 38.1-8.758.768-19.776-4.549-20.77-15.965-.854-9.809 2.871-11.145 1.961-21.566-1.62-18.553-17.798-20.714-26.655-19.946-10.719.939-30.167 13.449-27.438 44.611 2.744 31.432 32.882 56.269 72.39 52.814 42.634-3.725 72.318-36.92 74.55-83.478-.021-2.466.499-4.907 1.521-7.152l.014-.056c.459-.975.997-1.912 1.606-2.8.911-1.365 2.077-2.872 3.583-4.522.015-.042.015-.042.043-.042 1.094-1.237 2.417-2.573 3.909-4.009 18.624-17.571 85.696-58.012 149.129-44.89a6.42 6.42 0 0 1 4.163 5.728Z" fill="#fff"/></g><defs><linearGradient id="b" x1="459.192" y1="122.156" x2="22.611" y2="297.091" gradientUnits="userSpaceOnUse"><stop stop-color="#0CC5FF"/><stop offset="1" stop-color="#007FFF"/></linearGradient><clipPath id="a"><path fill="#fff" d="M0 0h512v512H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
public/assets/ark-library/wallets/tokenpocket.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="28px" height="28px" viewBox="0 0 28 28" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>tokenpocket</title><defs><linearGradient x1="107.511425%" y1="50.0147427%" x2="0.0459570557%" y2="50.0147427%" id="linearGradient-1"><stop stop-color="#FFFFFF" offset="0%"></stop><stop stop-color="#FFFFFF" stop-opacity="0.3233" offset="96.67%"></stop><stop stop-color="#FFFFFF" stop-opacity="0.3" offset="100%"></stop></linearGradient></defs><g id="p1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"><g id="tokenpocket" fill-rule="nonzero"><polygon id="path" fill="#2980FE" points="27.9874275 0 0 0 0 28 27.9874275 28"></polygon><g id="group" transform="translate(5.107577, 7.574219)"><path d="M6.28678209,4.45186719 L6.29988209,4.45186719 C6.28678209,4.42028824 6.28678209,4.38344613 6.28678209,4.35186719 L6.28678209,4.45186719 Z" id="path" fill="#29AEFF"></path><path d="M13.085927,5.10051172 L9.30493171,5.10051172 L9.30493171,12.2247344 C9.30493171,12.561418 9.56568007,12.8336523 9.88819083,12.8336523 L12.5026417,12.8336523 C12.8251787,12.8336523 13.085927,12.561418 13.085927,12.2247344 L13.085927,5.10051172 Z" id="path" fill="#FFFFFF"></path><path d="M7.47966698,0 L7.35271094,0 L0.583285313,0 C0.260748363,0 0,0.272207031 0,0.608917969 L0,3.08035547 C0,3.41706641 0.260748363,3.68927344 0.583285313,3.68927344 L2.17184659,3.68927344 L2.80316932,3.68927344 L2.80316932,4.41995313 L2.80316932,12.2426445 C2.80316932,12.5793555 3.06391768,12.8515625 3.38642844,12.8515625 L5.87051824,12.8515625 C6.193029,12.8515625 6.45377736,12.5793555 6.45377736,12.2426445 L6.45377736,4.41995313 L6.45377736,4.35192188 L6.45377736,3.68927344 L7.08510009,3.68927344 L7.34241721,3.68927344 L7.46937325,3.68927344 C8.4437942,3.68927344 9.23635921,2.86187891 9.23635921,1.84463672 C9.24665295,0.827394531 8.45408793,0 7.47966698,0 Z" id="path" fill="#FFFFFF"></path><path d="M13.0894107,5.10051172 L13.0894107,10.0720703 C13.2197979,10.1043086 13.3535903,10.1293828 13.49084,10.150875 C13.6829897,10.1795313 13.8819757,10.1974414 14.0809878,10.2010234 C14.0912816,10.2010234 14.1015753,10.2010234 14.1153003,10.2010234 L14.1153003,6.24667969 C13.5423087,6.20727734 13.0894107,5.70940234 13.0894107,5.10051172 Z" id="path" fill="url(#linearGradient-1)"></path><path d="M14.1907091,0 C11.4939345,0 9.30493171,2.28519922 9.30493171,5.10051172 C9.30493171,7.52182812 10.9209429,9.54912109 13.0893583,10.0720703 L13.0893583,5.10051172 C13.0893583,4.46651953 13.5834312,3.95073438 14.1907091,3.95073438 C14.7980131,3.95073438 15.2920861,4.46651953 15.2920861,5.10051172 C15.2920861,5.63420703 14.9455566,6.08193359 14.4720711,6.21085938 C14.3828587,6.23593359 14.2867839,6.25026172 14.1907091,6.25026172 L14.1907091,10.2010234 C14.2867839,10.2010234 14.3794275,10.1974414 14.4720711,10.1938594 C17.0384846,10.039832 19.0765167,7.81910938 19.0765167,5.10051172 C19.0799439,2.28519922 16.8909411,0 14.1907091,0 Z" id="path" fill="#FFFFFF"></path><path d="M14.2117905,10.2010234 L14.2117905,6.25026172 C14.1770295,6.25026172 14.1465846,6.25026172 14.1117905,6.24667969 L14.1117905,10.2010234 C14.1465846,10.2010234 14.1813788,10.2010234 14.2117905,10.2010234 Z" id="path" fill="#FFFFFF"></path></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |