Merge pull request 'terry-wallet-login' (#15) from terry-wallet-login into terry-staging

Reviewed-on: #15
This commit was merged in pull request #15.
This commit is contained in:
2026-06-05 16:32:43 +00:00
76 changed files with 15294 additions and 438 deletions

View File

@@ -13,3 +13,7 @@ VITE_ADMIN_UI_PREFIX=
# Use mock Post data (Telegram-style resource stream) only when explicitly enabled. # Use mock Post data (Telegram-style resource stream) only when explicitly enabled.
# Default production/staging behavior should hit the real /api/posts API. # Default production/staging behavior should hit the real /api/posts API.
VITE_USE_MOCK_POSTS=false 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=

View 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

View 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, RainbowKits own MetaMask wallet definition intentionally uses Wagmis `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 apps 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 MetaMasks 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.

View 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.

View File

@@ -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.

View 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.

View File

@@ -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.

View File

@@ -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.

View 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View 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.

View File

@@ -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.

View File

@@ -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 状态组件

View 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.

View File

@@ -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.

View 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.

View File

@@ -50,13 +50,18 @@ npm test
Create a local `.env` only when needed. Do not commit secrets. See `.env.example` for a template. Create a local `.env` only when needed. Do not commit secrets. See `.env.example` for a template.
| Variable | Purpose | | 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_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_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_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_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_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 ## Project layout

View File

@@ -0,0 +1,161 @@
# 后端需求与核对文档:钱包登录 + 收藏
> 面向后端(`Arkie-Library-Backend`)。
> 本文是在前端审计「登录 + 收藏」bug 与 UI 重设计时,对后端现有实现的逐条核对,以及由此产生的后端待办。
>
> **核心结论:后端目前几乎已经满足前端全部需求。本次重设计与 bug 修复基本是纯前端工作。后端真正需要新增的只有少量「可选项」,外加几处需要确认的契约。请不要把已完成的功能再派一遍。**
日期2026-06-022026-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`)是**多余的**,删除它**不影响后端**。这是一项纯前端修复。
- JWTHS256**有效期 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 全部为前端工作,无需后端介入。

View 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.

View File

@@ -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、mskey 写入 `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` 全文索引优化(数据量小,暂不做)。

7833
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,11 +13,16 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@rainbow-me/rainbowkit": "^2.2.11",
"@tanstack/react-query": "^5.100.14",
"framer-motion": "^11.18.2", "framer-motion": "^11.18.2",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
"qrcode.react": "^4.2.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.28.0" "react-router-dom": "^6.28.0",
"viem": "^2.52.0",
"wagmi": "^2.19.5"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",

View 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

View 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

View File

@@ -10,6 +10,13 @@ import { I18nProvider } from "./i18n";
import { MotionProvider } from "./motion"; import { MotionProvider } from "./motion";
import { ToastProvider } from "./components/Toast"; import { ToastProvider } from "./components/Toast";
import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide"; import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide";
import { InAppDownloadGuideProvider } from "./components/InAppDownloadGuide";
import { FavoritesProvider } from "./favorites/FavoritesProvider";
import { AutoInjectedLogin } from "./wallet/AutoInjectedLogin";
import { RainbowWalletProvider } from "./wallet/RainbowWalletProvider";
import { WalletLoginModal } from "./wallet/WalletLoginModal";
import { WalletProvider } from "./wallet/WalletProvider";
import { WalletStackErrorBoundary } from "./wallet/WalletStackErrorBoundary";
import { PublicLayout } from "./layouts/PublicLayout"; import { PublicLayout } from "./layouts/PublicLayout";
import { LocalizedHomePage } from "./pages/LocalizedHome"; import { LocalizedHomePage } from "./pages/LocalizedHome";
import { Browse } from "./pages/Browse"; import { Browse } from "./pages/Browse";
@@ -48,104 +55,137 @@ export default function App() {
<I18nProvider> <I18nProvider>
<MotionProvider> <MotionProvider>
<ToastProvider> <ToastProvider>
<SaveToAlbumGuideProvider> <WalletProvider>
<AdminRouterModeProvider value="absolute"> <AutoInjectedLogin />
<ImageLightboxProvider> <WalletStackErrorBoundary>
<VideoPlayerProvider> <RainbowWalletProvider>
<PageTitleProvider> <WalletLoginModal />
<BrowserRouter> </RainbowWalletProvider>
<ScrollToTop /> </WalletStackErrorBoundary>
<Routes> <FavoritesProvider>
<Route element={<PublicLayout />}> <InAppDownloadGuideProvider>
{/* English (root, no prefix) */} <SaveToAlbumGuideProvider>
<Route <AdminRouterModeProvider value="absolute">
path="/" <ImageLightboxProvider>
element={<LocalizedHomePage targetLang="en" />} <VideoPlayerProvider>
/> <PageTitleProvider>
<Route path="/browse" element={<Browse />} /> <BrowserRouter>
<Route <ScrollToTop />
path="/categories" <Routes>
element={<CategoriesPage />} <Route element={<PublicLayout />}>
/> <Route
<Route path="/"
path="/official-recommendations" element={
element={<OfficialRecommendationsPage />} <LocalizedHomePage targetLang="en" />
/> }
<Route />
path="/category/:slug" <Route path="/browse" element={<Browse />} />
element={<CategoryPage />} <Route
/> path="/categories"
<Route path="/search" element={<SearchPage />} /> element={<CategoriesPage />}
<Route />
path="/resource/:id" <Route
element={<PostRedirect />} path="/official-recommendations"
/> element={<OfficialRecommendationsPage />}
<Route path="/favorites" element={<Favorites />} /> />
<Route
path="/category/:slug"
element={<CategoryPage />}
/>
<Route
path="/search"
element={<SearchPage />}
/>
<Route
path="/resource/:id"
element={<PostRedirect />}
/>
<Route
path="/favorites"
element={<Favorites />}
/>
{/* Each non-English language gets its own nested tree. */} {localizedHomeRoutes.map((route) => (
{localizedHomeRoutes.map((route) => ( <Route key={route.path} path={route.path}>
<Route key={route.path} path={route.path}> <Route
<Route index
index element={
element={ <LocalizedHomePage
<LocalizedHomePage targetLang={route.lang} /> targetLang={route.lang}
} />
/> }
<Route path="browse" element={<Browse />} /> />
<Route <Route path="browse" element={<Browse />} />
path="categories" <Route
element={<CategoriesPage />} path="categories"
/> element={<CategoriesPage />}
<Route />
path="official-recommendations" <Route
element={<OfficialRecommendationsPage />} path="official-recommendations"
/> element={<OfficialRecommendationsPage />}
<Route />
path="category/:slug" <Route
element={<CategoryPage />} path="category/:slug"
/> element={<CategoryPage />}
<Route path="search" element={<SearchPage />} /> />
<Route <Route
path="resource/:id" path="search"
element={<PostRedirect />} element={<SearchPage />}
/> />
<Route path="favorites" element={<Favorites />} /> <Route
</Route> path="resource/:id"
))} element={<PostRedirect />}
</Route> />
<Route
path="favorites"
element={<Favorites />}
/>
</Route>
))}
</Route>
{/* Legacy long-form language URLs → short-code {/* Legacy long-form language URLs → short-code
redirects. Shared links (e.g. WeChat) keep working. */} redirects. Shared links (e.g. WeChat) keep working. */}
{legacyLanguageRedirects.map((redirect) => ( {legacyLanguageRedirects.map((redirect) => (
<Route key={redirect.from}> <Route key={redirect.from}>
<Route <Route
path={redirect.from} path={redirect.from}
element={<LegacyLangRedirect to={redirect.to} />} element={
/> <LegacyLangRedirect to={redirect.to} />
<Route }
path={`${redirect.from}/*`} />
element={<LegacyLangRedirect to={redirect.to} />} <Route
/> path={`${redirect.from}/*`}
</Route> element={
))} <LegacyLangRedirect to={redirect.to} />
}
/>
</Route>
))}
{adminEnabled ? ( {adminEnabled ? (
AdminRouteTree() AdminRouteTree()
) : ( ) : (
<Route <Route
path={`${adminUiPrefix}/*`} path={`${adminUiPrefix}/*`}
element={<Navigate to="/" replace />} element={<Navigate to="/" replace />}
/> />
)} )}
<Route path="*" element={<Navigate to="/" replace />} /> <Route
</Routes> path="*"
</BrowserRouter> element={<Navigate to="/" replace />}
</PageTitleProvider> />
</VideoPlayerProvider> </Routes>
</ImageLightboxProvider> </BrowserRouter>
</AdminRouterModeProvider> </PageTitleProvider>
</SaveToAlbumGuideProvider> </VideoPlayerProvider>
</ImageLightboxProvider>
</AdminRouterModeProvider>
</SaveToAlbumGuideProvider>
</InAppDownloadGuideProvider>
</FavoritesProvider>
</WalletProvider>
</ToastProvider> </ToastProvider>
</MotionProvider> </MotionProvider>
</I18nProvider> </I18nProvider>

View File

@@ -261,6 +261,8 @@ export type Resource = {
isRecommended: boolean; isRecommended: boolean;
publishedAt?: string; publishedAt?: string;
updatedAt: string; updatedAt: string;
favoriteCount?: number;
availability?: "available" | "unavailable";
tags?: string[]; tags?: string[];
}; };

View File

@@ -0,0 +1,173 @@
import { Copy, X } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useI18n } from "../i18n";
import { useToast } from "./Toast";
import {
IN_APP_DOWNLOAD_GUIDE_EVENT,
type InAppDownloadGuideDetail,
} from "./messageStream/utils/downloadFile";
import { inAppBrowserName } from "../utils/inAppBrowser";
async function copyTextToClipboard(text: string): Promise<boolean> {
try {
if (
typeof navigator !== "undefined" &&
navigator.clipboard &&
typeof navigator.clipboard.writeText === "function"
) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// fall through to legacy path
}
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.top = "0";
ta.style.left = "0";
ta.style.opacity = "0";
document.body.append(ta);
ta.select();
const ok = document.execCommand("copy");
ta.remove();
return ok;
} catch {
return false;
}
}
export function InAppDownloadGuideProvider({
children,
}: {
children: ReactNode;
}) {
const { t } = useI18n();
const { showToast } = useToast();
const [detail, setDetail] = useState<InAppDownloadGuideDetail | null>(null);
useEffect(() => {
const onShow = (event: Event) => {
const ce = event as CustomEvent<InAppDownloadGuideDetail>;
if (!ce.detail) return;
setDetail(ce.detail);
};
window.addEventListener(IN_APP_DOWNLOAD_GUIDE_EVENT, onShow);
return () =>
window.removeEventListener(IN_APP_DOWNLOAD_GUIDE_EVENT, onShow);
}, []);
useEffect(() => {
if (!detail) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setDetail(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [detail]);
const close = () => setDetail(null);
const handleCopy = async () => {
if (!detail) return;
const ok = await copyTextToClipboard(detail.url);
if (ok) {
showToast(t("inAppDownloadCopied"));
} else {
showToast(t("inAppDownloadCopyFail"), "error");
}
};
const browser = inAppBrowserName();
const intro = browser
? t("inAppDownloadIntroNamed").replace("{browser}", browser)
: t("inAppDownloadIntro");
return (
<>
{children}
{detail
? createPortal(
<div
role="dialog"
aria-modal="true"
aria-labelledby="in-app-download-guide-title"
className="fixed inset-0 z-[140] flex items-center justify-center bg-black/70 px-4 backdrop-blur-sm"
onClick={close}
>
<div
className="w-full max-w-md overflow-hidden rounded-3xl border border-white/10 bg-[#1c1c21] text-neutral-100 shadow-2xl shadow-black/70"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4 border-b border-white/10 px-5 py-4">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ark-gold/80">
{t("download")}
</p>
<h2
id="in-app-download-guide-title"
className="mt-1 text-lg font-semibold text-white"
>
{t("inAppDownloadTitle")}
</h2>
</div>
<button
type="button"
onClick={close}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80"
aria-label={t("cancel")}
>
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-4 px-5 py-5">
<p className="text-sm leading-6 text-neutral-300">{intro}</p>
<ol className="space-y-3 text-sm leading-6 text-neutral-100">
<li className="flex gap-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-ark-gold text-sm font-bold text-black">
1
</span>
<span className="pt-0.5">
{t("inAppDownloadStepCopy")}
</span>
</li>
<li className="flex gap-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-ark-gold text-sm font-bold text-black">
2
</span>
<span className="pt-0.5">
{t("inAppDownloadStepOpen")}
</span>
</li>
<li className="flex gap-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-ark-gold text-sm font-bold text-black">
3
</span>
<span className="pt-0.5">
{t("inAppDownloadStepDownload")}
</span>
</li>
</ol>
<button
type="button"
onClick={handleCopy}
className="flex h-11 w-full items-center justify-center gap-2 rounded-full bg-ark-gold px-4 text-sm font-semibold text-black transition hover:bg-ark-gold2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#1c1c21]"
>
<Copy className="h-4 w-4" />
{t("copyLink")}
</button>
</div>
</div>
</div>,
document.body,
)
: null}
</>
);
}

View File

@@ -0,0 +1,307 @@
import { LoaderCircle, Play } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { FavoriteButton } from "../favorites/FavoriteButton";
import { useI18n } from "../i18n";
import { useLocalizedPath } from "../useLocalizedPath";
import type { Attachment, Post } from "../types/post";
import { DownloadCloudIcon } from "./icons/DownloadCloudIcon";
import {
mediaSaveKindFromAttachment,
useSaveToAlbumGuide,
} from "./SaveToAlbumGuide";
import { useToast } from "./Toast";
import { downloadAttachment } from "./messageStream/utils/downloadFile";
import { fileIcon } from "./messageStream/utils/fileIcon";
import {
filenameWithExtension,
splitFilename,
} from "./messageStream/utils/filenameDisplay";
import { formatBytes } from "./messageStream/utils/formatBytes";
import { formatDateTime } from "./messageStream/utils/formatTime";
import { postDisplayText } from "./messageStream/utils/postText";
import { autolink } from "./messageStream/utils/autolink";
function LatestActions({
post,
attachment,
}: {
post: Post;
attachment?: Attachment;
}) {
const { t } = useI18n();
const { showToast } = useToast();
const { showSaveToAlbumGuide } = useSaveToAlbumGuide();
const [isDownloading, setIsDownloading] = useState(false);
const handleDownload = async () => {
if (!attachment || isDownloading) return;
setIsDownloading(true);
try {
await downloadAttachment(post.id, attachment.id, attachment.filename, {
sizeBytes: attachment.sizeBytes,
});
const mediaKind = mediaSaveKindFromAttachment(attachment);
if (mediaKind) showSaveToAlbumGuide(mediaKind);
} catch {
showToast(t("downloadFail"), "error");
} finally {
setIsDownloading(false);
}
};
return (
<div className="relative z-20 flex shrink-0 items-center gap-2">
<FavoriteButton resourceId={post.id} size="sm" />
{attachment ? (
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleDownload();
}}
disabled={isDownloading}
aria-label={
isDownloading ? t("downloading") : `Download ${attachment.filename}`
}
aria-busy={isDownloading}
className="relative z-20 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#191921] text-white outline-none transition hover:bg-[#22232D] focus-visible:ring-2 focus-visible:ring-ark-gold/70 disabled:cursor-wait"
>
{isDownloading ? (
<LoaderCircle
className="h-4 w-4 animate-spin text-[#A8A9AE]"
strokeWidth={2.3}
/>
) : (
<DownloadCloudIcon className="h-5 w-5" />
)}
</button>
) : null}
</div>
);
}
function Footer({ post, attachment }: { post: Post; attachment?: Attachment }) {
return (
<div className="flex min-h-16 items-center justify-between gap-3 px-4 py-3">
<time
dateTime={post.publishedAt}
className="shrink-0 text-[12px] font-medium leading-[19.2px] text-[#A8A9AE]"
>
{formatDateTime(post.publishedAt)}
</time>
<LatestActions post={post} attachment={attachment} />
</div>
);
}
function attachmentPreview(att: Attachment | undefined): string {
if (!att) return "";
return att.thumbnailUrl ?? att.posterUrl ?? att.thumbUrl ?? att.url ?? "";
}
function FileCard({ post, att }: { post: Post; att: Attachment }) {
const { lang } = useI18n();
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
const displayFilename = filenameWithExtension(att.filename, att.mime);
const text = postDisplayText(post, lang);
return (
<article className="relative flex flex-col overflow-hidden rounded-2xl bg-[#272632] text-left">
<CardLink postId={post.id} />
<div className="flex min-h-[52px] min-w-0 items-center gap-4 px-4 pt-3">
<div
className="flex h-[52px] w-[52px] shrink-0 items-center justify-center rounded-full"
style={{ backgroundColor: color }}
aria-hidden
>
<Icon className="h-8 w-8 text-white" strokeWidth={2.1} />
</div>
<div className="min-w-0 flex-1">
<div
className="flex min-w-0 items-baseline text-[15px] font-medium leading-6 text-ark-gold"
title={displayFilename}
>
{(() => {
const { base, ext } = splitFilename(displayFilename);
const tailChars = Math.min(8, base.length);
const head = base.slice(0, base.length - tailChars);
const tail = base.slice(base.length - tailChars) + ext;
return (
<>
<span className="min-w-0 truncate">{head}</span>
<span className="shrink-0 whitespace-pre">{tail}</span>
</>
);
})()}
</div>
<div className="text-[12px] font-medium leading-[19.2px] text-[#A8A9AE]">
{formatBytes(att.sizeBytes)}
</div>
</div>
</div>
<div className="whitespace-pre-wrap break-words px-4 pt-3 text-[15px] font-medium leading-6 text-white">
{text || post.title || displayFilename}
</div>
<Footer post={post} attachment={att} />
</article>
);
}
function PillDownloadIcon() {
return (
<svg
width="12"
height="10"
viewBox="0 0 12 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M9.84528 3.61663C9.68928 1.5958 8.02426 0 6.00008 0C4.26863 0 2.78232 1.14503 2.30275 2.81502C0.934727 3.29852 0 4.6181 0 6.10918C0 8.034 1.53816 9.60013 3.42862 9.60013H9.00013C10.6544 9.60013 12.0002 8.22993 12.0002 6.54555C12.0002 5.17142 11.113 3.99191 9.84528 3.61663ZM8.0174 5.98132L6.30309 7.7268C6.21952 7.81189 6.1098 7.85465 6.00008 7.85465C5.89037 7.85465 5.78065 7.81189 5.69708 7.7268L3.98277 5.98132C3.8602 5.85652 3.82334 5.66888 3.88977 5.50568C3.9562 5.34291 4.11263 5.23644 4.28577 5.23644H5.14293V3.49096C5.14293 3.00921 5.52693 2.61822 6.00008 2.61822C6.47323 2.61822 6.85724 3.00921 6.85724 3.49096V5.23644H7.71439C7.88754 5.23644 8.04397 5.34291 8.1104 5.50568C8.17683 5.66888 8.13997 5.85652 8.0174 5.98132Z"
fill="#A8A9AE"
/>
</svg>
);
}
function MediaSizeChip({ att }: { att: Attachment }) {
return (
<div className="absolute left-3 top-2.5 z-20 flex h-6 w-[72px] items-center overflow-hidden rounded-full bg-black text-white">
<span className="grid h-6 w-6 shrink-0 place-items-center bg-[#545454]">
<PillDownloadIcon />
</span>
<span className="flex h-4 w-12 items-center justify-center text-[10px] font-medium leading-4">
{formatBytes(att.sizeBytes)}
</span>
</div>
);
}
function MediaTile({
att,
showExtra,
}: {
att: Attachment;
showExtra?: number;
}) {
const src = attachmentPreview(att);
const isVideo = att.kind === "video" || att.mime.startsWith("video/");
return (
<div className="relative h-full w-full overflow-hidden bg-black">
{src ? (
<img
src={src}
alt=""
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
) : null}
<MediaSizeChip att={att} />
{isVideo ? (
<div className="absolute inset-0 grid place-items-center">
<span className="grid h-16 w-16 place-items-center rounded-full bg-black/55 text-white backdrop-blur-sm">
<Play className="ml-1 h-8 w-8 fill-current" />
</span>
</div>
) : null}
{showExtra ? (
<div className="absolute inset-0 grid place-items-center bg-black/55 text-[24px] font-bold text-white backdrop-blur-sm">
+{showExtra}
</div>
) : null}
</div>
);
}
function MediaGrid({ attachments }: { attachments: Attachment[] }) {
const visible = attachments.slice(0, 4);
const extra = Math.max(0, attachments.length - visible.length);
if (visible.length === 0) return <div className="h-full w-full bg-black" />;
if (visible.length === 1) return <MediaTile att={visible[0]} />;
if (visible.length === 2) {
return (
<div className="grid h-full w-full grid-cols-2 gap-0">
{visible.map((att) => (
<MediaTile key={att.id} att={att} />
))}
</div>
);
}
if (visible.length === 3) {
return (
<div className="grid h-full w-full grid-cols-2 gap-0">
<MediaTile att={visible[0]} />
<div className="grid h-full grid-rows-2 gap-0">
<MediaTile att={visible[1]} />
<MediaTile att={visible[2]} />
</div>
</div>
);
}
return (
<div className="grid h-full w-full grid-cols-2 grid-rows-2 gap-0">
{visible.map((att, index) => (
<MediaTile
key={att.id}
att={att}
showExtra={extra > 0 && index === visible.length - 1 ? extra : 0}
/>
))}
</div>
);
}
function VisualCard({ post }: { post: Post }) {
const { lang } = useI18n();
const att = post.attachments[0];
const isVideo = att?.kind === "video" || att?.mime.startsWith("video/");
const text = postDisplayText(post, lang);
return (
<article className="relative flex flex-col overflow-hidden rounded-2xl bg-[#272632] text-left">
<CardLink postId={post.id} />
<div
className={`relative overflow-hidden bg-black ${
isVideo ? "aspect-[416/180]" : "aspect-[416/230]"
}`}
>
<MediaGrid attachments={post.attachments} />
</div>
{text || post.title ? (
<div className="message-stream-copyable-text whitespace-pre-wrap break-words px-4 pt-3 text-[15px] font-medium leading-6 text-white">
{autolink(text || post.title || "")}
</div>
) : null}
<Footer post={post} />
</article>
);
}
function CardLink({ postId }: { postId: string }) {
const lp = useLocalizedPath();
return (
<Link
to={lp(`/browse?post=${encodeURIComponent(postId)}`)}
aria-label="Open post"
className="absolute inset-0 z-10 rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
/>
);
}
export function LatestUpdateCard({ post }: { post: Post }) {
const first = post.attachments[0];
const isFile =
!!first &&
!(
first.kind === "image" ||
first.kind === "video" ||
first.mime.startsWith("image/") ||
first.mime.startsWith("video/")
);
if (isFile) return <FileCard post={post} att={first} />;
return <VisualCard post={post} />;
}

View File

@@ -5,9 +5,10 @@ import { useI18n } from "../i18n";
import { useLocalizedPath } from "../useLocalizedPath"; import { useLocalizedPath } from "../useLocalizedPath";
import { resourceTypeLabel } from "../resourceTypeLabels"; import { resourceTypeLabel } from "../resourceTypeLabels";
import { formatDateYmd } from "../utils/format"; import { formatDateYmd } from "../utils/format";
import { FavoriteButton } from "../favorites/FavoriteButton";
const LATEST_CARD_CLASS = const LATEST_CARD_CLASS =
"flex min-h-[106px] items-start gap-4 overflow-hidden rounded-xl border border-ark-line bg-ark-panel p-4 outline-none transition hover:border-ark-gold/45 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg md:min-h-[138px] md:p-5"; "relative flex min-h-[106px] items-start gap-4 overflow-hidden rounded-xl border border-ark-line bg-ark-panel p-4 outline-none transition hover:border-ark-gold/45 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg md:min-h-[138px] md:p-5";
export function LatestUpdateRow({ export function LatestUpdateRow({
r, r,
@@ -21,15 +22,20 @@ export function LatestUpdateRow({
const dateStr = formatDateYmd(r.updatedAt); const dateStr = formatDateYmd(r.updatedAt);
return ( return (
<Link to={lp(`/resource/${r.id}`)} className={LATEST_CARD_CLASS}> <article className={LATEST_CARD_CLASS}>
<div className="flex shrink-0 items-center justify-center pt-0.5"> <Link
to={lp(`/resource/${r.id}`)}
aria-label={r.title}
className="absolute inset-0 z-0 rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80"
/>
<div className="relative z-10 flex shrink-0 items-center justify-center pt-0.5">
<CategoryIcon <CategoryIcon
iconKey={iconKey} iconKey={iconKey}
categorySlug={r.categorySlug} categorySlug={r.categorySlug}
className="h-10 w-10 text-ark-gold" className="h-10 w-10 text-ark-gold"
/> />
</div> </div>
<div className="flex min-w-0 flex-1 self-stretch py-0.5 flex-col"> <div className="pointer-events-none relative z-10 flex min-w-0 flex-1 self-stretch py-0.5 flex-col pr-11">
<div className="text-base font-bold leading-snug text-white line-clamp-2 md:text-lg"> <div className="text-base font-bold leading-snug text-white line-clamp-2 md:text-lg">
{r.title} {r.title}
</div> </div>
@@ -43,7 +49,12 @@ export function LatestUpdateRow({
</span> </span>
</div> </div>
</div> </div>
</Link> <FavoriteButton
resourceId={r.id}
size="sm"
className="absolute right-3 top-3 z-20"
/>
</article>
); );
} }

View File

@@ -24,6 +24,7 @@ import type { Post } from "../types/post";
import { downloadAttachment } from "./messageStream/utils/downloadFile"; import { downloadAttachment } from "./messageStream/utils/downloadFile";
import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide"; import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
import { FavoriteButton } from "../favorites/FavoriteButton";
const MEDALS = ["🥇", "🥈", "🥉"]; const MEDALS = ["🥇", "🥈", "🥉"];
const MAX_ITEMS = 5; const MAX_ITEMS = 5;
@@ -65,7 +66,7 @@ function RankBadge({ index }: { index: number }) {
if (index < MEDALS.length) { if (index < MEDALS.length) {
return ( return (
<span <span
className="flex w-7 shrink-0 justify-center text-[22px] leading-none" className="mt-[15px] flex w-7 shrink-0 justify-center text-[22px] leading-none md:mt-0 md:w-[76px] md:text-[28px]"
aria-label={`No.${index + 1}`} aria-label={`No.${index + 1}`}
> >
{MEDALS[index]} {MEDALS[index]}
@@ -74,7 +75,7 @@ function RankBadge({ index }: { index: number }) {
} }
return ( return (
<span <span
className="flex w-7 shrink-0 justify-center text-lg font-extrabold tabular-nums text-[#7d7e87]" className="mt-[15px] flex w-7 shrink-0 justify-center text-lg font-extrabold tabular-nums text-[#7d7e87] md:mt-0 md:w-[76px]"
aria-label={`No.${index + 1}`} aria-label={`No.${index + 1}`}
> >
{index + 1} {index + 1}
@@ -82,14 +83,30 @@ function RankBadge({ index }: { index: number }) {
); );
} }
function PopularRankRow({ export function PopularRankRow({
post, post,
index, index,
categories, categories,
browseSort = "popular",
showRank = true,
showDownload = true,
linkToResource = false,
onFavoriteChange,
}: { }: {
post: Post; post: Post;
index: number; index: number;
categories: Category[]; categories: Category[];
browseSort?: string;
showRank?: boolean;
showDownload?: boolean;
/**
* When true, the card and download button route to `/resource/:id` so the
* `PostRedirect` page can fall back to the post's source language if the
* current UI language has no translation. Otherwise navigate inside the
* `/browse` stream which assumes the post exists in the current language.
*/
linkToResource?: boolean;
onFavoriteChange?: (postId: string, favorited: boolean) => void;
}) { }) {
const { t, lang } = useI18n(); const { t, lang } = useI18n();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -101,7 +118,7 @@ function PopularRankRow({
const r = postToResource(post, lang, categories); const r = postToResource(post, lang, categories);
const cover = r.coverImage && !coverFailed ? assetUrl(r.coverImage) : ""; const cover = r.coverImage && !coverFailed ? assetUrl(r.coverImage) : "";
const isTop3 = index < MEDALS.length; const isTop3 = showRank && index < MEDALS.length;
const handleDownload = async () => { const handleDownload = async () => {
if (isDownloading || !r.downloadPostId || !r.downloadAttachmentId) return; if (isDownloading || !r.downloadPostId || !r.downloadAttachmentId) return;
@@ -122,74 +139,105 @@ function PopularRankRow({
}; };
return ( return (
<article className="relative flex items-center gap-3 rounded-2xl border border-[#27292E] bg-[#272632] p-3 transition hover:border-ark-gold/55 md:gap-4 md:p-4"> <article
className={`relative grid items-center gap-x-3 overflow-hidden rounded-2xl bg-[#272632] p-3 transition hover:ring-1 hover:ring-inset hover:ring-ark-gold/55 md:flex md:h-[90px] md:gap-0 md:p-0 ${
showRank
? "grid-cols-[92px_minmax(0,1fr)_88px]"
: "grid-cols-[64px_minmax(0,1fr)_88px]"
}`}
>
<button <button
type="button" type="button"
onClick={() => onClick={() => {
navigate( if (linkToResource) {
lp(`/browse?sort=popular&post=${encodeURIComponent(post.id)}`), navigate(lp(`/resource/${encodeURIComponent(post.id)}?single=1`));
) return;
} }
const params = new URLSearchParams();
if (browseSort) params.set("sort", browseSort);
params.set("post", post.id);
navigate(lp(`/browse?${params.toString()}`));
}}
aria-label={r.title} aria-label={r.title}
className="absolute inset-0 z-0 rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70" className="absolute inset-0 z-0 rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
/> />
<RankBadge index={index} /> <div className="relative z-10 flex items-start gap-2 md:contents">
{showRank ? <RankBadge index={index} /> : null}
<div <div className="flex w-[64px] shrink-0 flex-col items-center gap-1 md:block md:h-[90px] md:w-[246px]">
className={`relative z-10 flex h-[52px] w-[64px] shrink-0 items-center justify-center overflow-hidden rounded-lg bg-[#111116] md:h-[58px] md:w-[72px] ${ <div
isTop3 ? "ring-1 ring-ark-gold/45" : "" className={`flex h-[52px] w-[64px] items-center justify-center overflow-hidden rounded-lg bg-[#111116] md:h-full md:w-full md:rounded-none ${
}`} isTop3 ? "ring-1 ring-ark-gold/45 md:ring-0" : ""
> }`}
{cover ? ( >
<img {cover ? (
src={cover} <img
alt="" src={cover}
loading="lazy" alt=""
decoding="async" loading="lazy"
className="h-full w-full object-fill" decoding="async"
onError={() => setCoverFailed(true)} className="h-full w-full object-cover"
/> onError={() => setCoverFailed(true)}
) : ( />
<FallbackCover type={r.type} /> ) : (
)} <FallbackCover type={r.type} />
</div> )}
</div>
<div className="pointer-events-none relative z-10 flex min-w-0 flex-1 flex-col gap-1"> <time
<div className="line-clamp-2 text-sm font-bold leading-snug text-white md:text-base"> dateTime={post.publishedAt}
{r.title} className="whitespace-nowrap text-[10px] leading-4 text-ark-muted md:hidden"
</div> >
<div className="flex items-center gap-2 text-xs text-[#9b9ca6]">
<span className="rounded-full bg-[#2a2b33] px-2 py-0.5 text-[#b9bac3]">
{resourceTypeLabel(t, r.type)}
</span>
<span className="truncate">
{cleanCategoryDisplayName(r.categoryName)}
</span>
<span className="text-[#55565e]">·</span>
<time dateTime={post.publishedAt} className="shrink-0 text-ark-muted">
{formatDateYmd(post.publishedAt)} {formatDateYmd(post.publishedAt)}
</time> </time>
</div> </div>
</div> </div>
<div className="relative z-10 flex shrink-0 items-center gap-1"> <div className="pointer-events-none relative z-10 col-start-2 flex min-w-0 flex-1 flex-col gap-1 md:col-auto md:gap-3 md:pl-6 md:pr-4">
{r.isDownloadable ? ( <div className="line-clamp-2 text-sm font-bold leading-snug text-white md:text-base md:font-semibold md:leading-[23px]">
{r.title}
</div>
<div className="flex items-center gap-2 text-xs text-[#9b9ca6] md:gap-3 md:text-[#9FA0A8]">
<span className="rounded-full bg-[#2a2b33] px-2 py-0.5 text-[#b9bac3] md:px-3 md:py-1">
{resourceTypeLabel(t, r.type)}
</span>
<span className="truncate">
{cleanCategoryDisplayName(r.categoryName)}
</span>
<span className="hidden text-[#55565e] md:inline">·</span>
<time
dateTime={post.publishedAt}
className="hidden shrink-0 text-ark-muted md:inline"
>
{formatDateYmd(post.publishedAt)}
</time>
</div>
</div>
<div className="relative z-10 col-start-3 flex shrink-0 items-center justify-end gap-2 md:col-auto md:pr-6">
<FavoriteButton
resourceId={r.id}
size="sm"
onFavoriteChange={(favorited) =>
onFavoriteChange?.(post.id, favorited)
}
/>
{showDownload && r.isDownloadable ? (
<button <button
type="button" type="button"
onClick={handleDownload} onClick={handleDownload}
disabled={isDownloading} disabled={isDownloading}
aria-label={t("download")} aria-label={t("download")}
title={t("download")} title={t("download")}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#191921] text-white outline-none transition hover:bg-[#22232D] focus-visible:ring-2 focus-visible:ring-ark-gold/70 disabled:cursor-wait" className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#191921] text-white outline-none transition hover:bg-[#22232D] focus-visible:ring-2 focus-visible:ring-ark-gold/70 disabled:cursor-wait"
> >
{isDownloading ? ( {isDownloading ? (
<LoaderCircle <LoaderCircle
className="h-5 w-5 animate-spin" className="h-4 w-4 animate-spin"
strokeWidth={2.3} strokeWidth={2.3}
/> />
) : ( ) : (
<DownloadCloudIcon className="h-6 w-6" /> <DownloadCloudIcon className="h-5 w-5" />
)} )}
</button> </button>
) : null} ) : null}
@@ -203,7 +251,7 @@ function ComingSoonRankRow({ index }: { index: number }) {
const label = lang === "zh-CN" ? "即将到来" : "Coming soon"; const label = lang === "zh-CN" ? "即将到来" : "Coming soon";
return ( return (
<article <article
className="flex items-center gap-3 rounded-2xl border border-[#27292E] bg-[#272632] p-3 opacity-70 md:gap-4 md:p-4" className="flex items-center gap-3 rounded-2xl bg-[#272632] p-3 opacity-70 md:gap-4 md:p-4"
aria-hidden="true" aria-hidden="true"
> >
<RankBadge index={index} /> <RankBadge index={index} />

View File

@@ -15,13 +15,14 @@ import {
} from "./messageStream/utils/downloadFile"; } from "./messageStream/utils/downloadFile";
import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide"; import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
import { FavoriteButton } from "../favorites/FavoriteButton";
function isPlaceholderAsset(path: string | undefined | null) { function isPlaceholderAsset(path: string | undefined | null) {
return !path || path.includes("placeholder-cover"); return !path || path.includes("placeholder-cover");
} }
const CARD_BASE_CLASS = const CARD_BASE_CLASS =
"group flex shrink-0 flex-col overflow-hidden rounded-xl border bg-[#272632] transition hover:border-ark-gold/55 hover:shadow-lg hover:shadow-black/30"; "group flex shrink-0 flex-col overflow-hidden rounded-xl border bg-[#1D1E23] transition hover:border-ark-gold/55 hover:shadow-lg hover:shadow-black/30";
const CARD_HOVER_SPRING = { const CARD_HOVER_SPRING = {
type: "spring", type: "spring",
@@ -106,7 +107,7 @@ export function RecommendedCard({
layout === "grid" ? CARD_GRID_SIZE_CLASS : CARD_CAROUSEL_SIZE_CLASS layout === "grid" ? CARD_GRID_SIZE_CLASS : CARD_CAROUSEL_SIZE_CLASS
} ${ } ${
useFigmaDesign useFigmaDesign
? "border-[#27292E]" ? "border-[#27292E] bg-[#1D1E23]"
: "border-transparent md:border-ark-line md:bg-ark-panel" : "border-transparent md:border-ark-line md:bg-ark-panel"
}`} }`}
> >
@@ -115,7 +116,13 @@ export function RecommendedCard({
aria-label={displayTitle} aria-label={displayTitle}
className="absolute inset-0 z-10 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70" className="absolute inset-0 z-10 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
/> />
<div className="relative block aspect-[208/108] overflow-hidden bg-[#111116]"> <div
className={
useFigmaDesign
? "relative block h-[131px] overflow-hidden bg-[#1D1E23]"
: "relative block aspect-[208/108] overflow-hidden bg-[#111116]"
}
>
{cover ? ( {cover ? (
<img <img
src={cover} src={cover}
@@ -137,12 +144,18 @@ export function RecommendedCard({
<div <div
className={ className={
useFigmaDesign useFigmaDesign
? "flex h-[131px] flex-col px-4 py-3" ? "flex h-[143px] flex-col px-4 py-4"
: "flex min-h-[131px] flex-1 flex-col p-4 pt-3 md:min-h-[121px]" : "flex min-h-[131px] flex-1 flex-col p-4 pt-3 md:min-h-[121px]"
} }
> >
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<h3 className="text-[15px] font-semibold leading-[21.72px] text-white line-clamp-2 transition-colors group-hover:text-ark-gold2 md:text-base md:font-bold md:leading-snug"> <h3
className={
useFigmaDesign
? "text-base font-semibold leading-[23px] text-white line-clamp-2 transition-colors group-hover:text-ark-gold2"
: "text-[15px] font-semibold leading-[21.72px] text-white line-clamp-2 transition-colors group-hover:text-ark-gold2 md:text-base md:font-bold md:leading-snug"
}
>
{displayTitle} {displayTitle}
</h3> </h3>
{useFigmaDesign ? ( {useFigmaDesign ? (
@@ -173,36 +186,39 @@ export function RecommendedCard({
)} )}
<time dateTime={dateTime}>{dateStr}</time> <time dateTime={dateTime}>{dateStr}</time>
</div> </div>
{dl ? ( <div className="relative z-20 flex shrink-0 items-center gap-2">
<button <FavoriteButton resourceId={r.id} size="sm" />
type="button" {dl ? (
className={ <button
useFigmaDesign type="button"
? "relative z-20 flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#191921] text-white outline-none transition hover:bg-[#22232D] active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait" className={
: "relative z-20 shrink-0 rounded-lg p-1 text-white outline-none transition hover:bg-ark-gold/10 active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait" useFigmaDesign
} ? "relative z-20 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#191921] text-white outline-none transition hover:bg-[#22232D] active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait"
title={isDownloading ? t("downloading") : t("download")} : "relative z-20 shrink-0 rounded-lg p-1 text-white outline-none transition hover:bg-ark-gold/10 active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait"
aria-label={isDownloading ? t("downloading") : t("download")} }
aria-busy={isDownloading} title={isDownloading ? t("downloading") : t("download")}
disabled={isDownloading} aria-label={isDownloading ? t("downloading") : t("download")}
onClick={(e) => { aria-busy={isDownloading}
e.preventDefault(); disabled={isDownloading}
e.stopPropagation(); onClick={(e) => {
void handleDownload(); e.preventDefault();
}} e.stopPropagation();
> void handleDownload();
{isDownloading ? ( }}
<LoaderCircle >
className="h-5 w-5 animate-spin" {isDownloading ? (
strokeWidth={2.2} <LoaderCircle
/> className="h-4 w-4 animate-spin"
) : useFigmaDesign ? ( strokeWidth={2.2}
<DownloadCloudIcon className="h-6 w-6" /> />
) : ( ) : useFigmaDesign ? (
<Download className="h-5 w-5" strokeWidth={2.2} /> <DownloadCloudIcon className="h-5 w-5" />
)} ) : (
</button> <Download className="h-5 w-5" strokeWidth={2.2} />
) : null} )}
</button>
) : null}
</div>
</div> </div>
</div> </div>
</m.article> </m.article>

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react"; import { useEffect, useLayoutEffect, useRef } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
/** /**
@@ -23,6 +23,15 @@ export function ScrollToTop() {
const prevPathname = useRef(pathname); const prevPathname = useRef(pathname);
useEffect(() => { useEffect(() => {
if (!("scrollRestoration" in window.history)) return;
const previous = window.history.scrollRestoration;
window.history.scrollRestoration = "manual";
return () => {
window.history.scrollRestoration = previous;
};
}, []);
useLayoutEffect(() => {
const pathnameChanged = prevPathname.current !== pathname; const pathnameChanged = prevPathname.current !== pathname;
prevPathname.current = pathname; prevPathname.current = pathname;

View File

@@ -32,6 +32,7 @@ export function useToast(): ToastContextValue {
} }
const AUTO_DISMISS_MS = 3000; const AUTO_DISMISS_MS = 3000;
const MAX_VISIBLE_TOASTS = 2;
/** /**
* App-level toast host. Renders an aria-live region so screen readers announce * App-level toast host. Renders an aria-live region so screen readers announce
@@ -49,7 +50,9 @@ export function ToastProvider({ children }: { children: ReactNode }) {
const showToast = useCallback( const showToast = useCallback(
(message: string, variant: ToastVariant = "success") => { (message: string, variant: ToastVariant = "success") => {
const id = (idRef.current += 1); const id = (idRef.current += 1);
setToasts((prev) => [...prev, { id, message, variant }]); setToasts((prev) =>
[...prev, { id, message, variant }].slice(-MAX_VISIBLE_TOASTS),
);
window.setTimeout(() => dismiss(id), AUTO_DISMISS_MS); window.setTimeout(() => dismiss(id), AUTO_DISMISS_MS);
}, },
[dismiss], [dismiss],
@@ -61,7 +64,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
<ToastContext.Provider value={value}> <ToastContext.Provider value={value}>
{children} {children}
<div <div
className="pointer-events-none fixed inset-x-0 bottom-[92px] z-[100] flex flex-col items-center gap-2 px-4 md:bottom-6" className="pointer-events-none fixed inset-x-0 bottom-[92px] z-[200] flex flex-col items-center gap-2 px-4 md:bottom-6"
aria-live="polite" aria-live="polite"
aria-atomic="true" aria-atomic="true"
> >

View File

@@ -3,13 +3,18 @@ import type { SVGProps } from "react";
export function DownloadCloudIcon(props: SVGProps<SVGSVGElement>) { export function DownloadCloudIcon(props: SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg
viewBox="0 0 14 14" width="24"
fill="currentColor" height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
aria-hidden aria-hidden
{...props} {...props}
> >
<path d="M10.7387 5.85011C10.587 3.88544 8.96824 2.33398 7.00033 2.33398C5.31699 2.33398 3.87199 3.4472 3.40574 5.07077C2.07574 5.54083 1.16699 6.82374 1.16699 8.27338C1.16699 10.1447 2.66241 11.6673 4.50033 11.6673H9.91699C11.5253 11.6673 12.8337 10.3352 12.8337 8.69762C12.8337 7.36168 11.9712 6.21495 10.7387 5.85011ZM8.96158 8.14908L7.29491 9.84605C7.21366 9.92877 7.10699 9.97035 7.00033 9.97035C6.89366 9.97035 6.78699 9.92877 6.70574 9.84605L5.03908 8.14908C4.91991 8.02774 4.88408 7.84532 4.94866 7.68665C5.01324 7.52841 5.16533 7.42489 5.33366 7.42489H6.16699V5.72792C6.16699 5.25956 6.54033 4.87944 7.00033 4.87944C7.46033 4.87944 7.83366 5.25956 7.83366 5.72792V7.42489H8.66699C8.83533 7.42489 8.98741 7.52841 9.05199 7.68665C9.11658 7.84532 9.08074 8.02774 8.96158 8.14908Z" /> <path
d="M18.4086 10.0276C18.1486 6.65964 15.3736 4 12 4C9.11429 4 6.63714 5.90836 5.83786 8.69164C3.55786 9.49746 2 11.6967 2 14.1818C2 17.3898 4.56357 20 7.71429 20H17C19.7571 20 22 17.7164 22 14.9091C22 12.6189 20.5214 10.6531 18.4086 10.0276ZM15.3621 13.9687L12.505 16.8778C12.3657 17.0196 12.1829 17.0909 12 17.0909C11.8171 17.0909 11.6343 17.0196 11.495 16.8778L8.63786 13.9687C8.43357 13.7607 8.37214 13.448 8.48286 13.176C8.59357 12.9047 8.85429 12.7273 9.14286 12.7273H10.5714V9.81818C10.5714 9.01527 11.2114 8.36364 12 8.36364C12.7886 8.36364 13.4286 9.01527 13.4286 9.81818V12.7273H14.8571C15.1457 12.7273 15.4064 12.9047 15.5171 13.176C15.6279 13.448 15.5664 13.7607 15.3621 13.9687Z"
fill="#A8A9AE"
/>
</svg> </svg>
); );
} }

View File

@@ -0,0 +1,27 @@
import type { SVGProps } from "react";
/**
* Figma wallet glyph (`4414:12829`). Filled body so it reads as a solid mark
* on the yellow CTA button. Uses `currentColor` so callers control the
* paint via `text-…` utilities.
*/
export function WalletIcon({
className = "h-4 w-4",
...rest
}: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
className={className}
{...rest}
>
<path
d="M20.0725 7.93967H6.22476C5.86926 7.93967 5.58191 7.64444 5.58191 7.28069C5.58191 6.91693 5.86926 6.62171 6.22476 6.62171H17.521V4.15053C17.521 3.79929 17.3674 3.47178 17.1 3.25234C16.8313 3.03158 16.4835 2.95185 16.1518 3.02829L4.06991 5.80918C3.55498 5.9278 3.19434 6.38975 3.19434 6.93143V19.8494C3.19434 20.4834 3.69769 21 4.31612 21H20.0725C20.691 21 21.1943 20.484 21.1943 19.8494V9.08959C21.1943 8.45565 20.691 7.93967 20.0725 7.93967ZM18.0482 15.6445C17.4471 15.6445 16.9585 15.143 16.9585 14.5268C16.9585 13.9107 17.4478 13.4092 18.0482 13.4092C18.6499 13.4092 19.1385 13.9107 19.1385 14.5268C19.1385 15.143 18.6499 15.6445 18.0482 15.6445Z"
fill="currentColor"
/>
</svg>
);
}

View File

@@ -53,7 +53,9 @@ export function AttachmentDownloadPill({
pauseActiveVideos(); pauseActiveVideos();
setIsDownloading(true); setIsDownloading(true);
try { try {
await downloadAttachment(postId, attachment.id, attachment.filename); await downloadAttachment(postId, attachment.id, attachment.filename, {
sizeBytes: attachment.sizeBytes,
});
const mediaKind = mediaSaveKindFromAttachment(attachment); const mediaKind = mediaSaveKindFromAttachment(attachment);
if (mediaKind) showSaveToAlbumGuide(mediaKind); if (mediaKind) showSaveToAlbumGuide(mediaKind);
} catch { } catch {

View File

@@ -0,0 +1,69 @@
import { LoaderCircle } from "lucide-react";
import { useState } from "react";
import { DownloadCloudIcon } from "../icons/DownloadCloudIcon";
import { useI18n } from "../../i18n";
import {
mediaSaveKindFromAttachment,
useSaveToAlbumGuide,
} from "../SaveToAlbumGuide";
import { useToast } from "../Toast";
import type { Attachment } from "../../types/post";
import { downloadAttachment, pauseActiveVideos } from "./utils/downloadFile";
import { filenameWithExtension } from "./utils/filenameDisplay";
export function BubbleAttachmentDownloadButton({
postId,
attachment,
}: {
postId: string;
attachment: Attachment;
}) {
const { t } = useI18n();
const { showToast } = useToast();
const { showSaveToAlbumGuide } = useSaveToAlbumGuide();
const [isDownloading, setIsDownloading] = useState(false);
const displayFilename = filenameWithExtension(
attachment.filename,
attachment.mime,
);
const handleDownload = async () => {
if (isDownloading) return;
pauseActiveVideos();
setIsDownloading(true);
try {
await downloadAttachment(postId, attachment.id, displayFilename, {
sizeBytes: attachment.sizeBytes,
});
const mediaKind = mediaSaveKindFromAttachment(attachment);
if (mediaKind) showSaveToAlbumGuide(mediaKind);
} catch {
showToast(t("downloadFail"), "error");
} finally {
setIsDownloading(false);
}
};
return (
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleDownload();
}}
disabled={isDownloading}
aria-label={
isDownloading ? t("downloading") : `Download ${attachment.filename}`
}
aria-busy={isDownloading}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-white/10 bg-[#191921]/90 text-[#A8A9AE] outline-none transition active:scale-95 hover:border-ark-gold hover:bg-[#191921] hover:text-ark-gold focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait disabled:opacity-70"
>
{isDownloading ? (
<LoaderCircle className="h-4 w-4 animate-spin" strokeWidth={2.2} />
) : (
<DownloadCloudIcon className="h-5 w-5" />
)}
</button>
);
}

View File

@@ -8,8 +8,15 @@ import { AlbumBubble } from "./bubbles/AlbumBubble";
import { VideoBubble } from "./bubbles/VideoBubble"; import { VideoBubble } from "./bubbles/VideoBubble";
import { LinkPreviewCard } from "./LinkPreviewCard"; import { LinkPreviewCard } from "./LinkPreviewCard";
import { formatDateTime } from "./utils/formatTime"; import { formatDateTime } from "./utils/formatTime";
import { FavoriteButton } from "../../favorites/FavoriteButton";
import { BubbleAttachmentDownloadButton } from "./BubbleAttachmentDownloadButton";
type BubbleComponent = ComponentType<{ post: Post }>; export type MessageBubbleVariant = "default" | "latest";
type BubbleComponent = ComponentType<{
post: Post;
variant?: MessageBubbleVariant;
}>;
export function pickBubble(post: Post): BubbleComponent { export function pickBubble(post: Post): BubbleComponent {
const a = post.attachments; const a = post.attachments;
@@ -26,11 +33,16 @@ export function pickBubble(post: Post): BubbleComponent {
export function MessageBubble({ export function MessageBubble({
post, post,
fluid = false, fluid = false,
variant = "default",
onFavoriteChange,
}: { }: {
post: Post; post: Post;
/** When true, fill the parent container instead of applying the standalone /** When true, fill the parent container instead of applying the standalone
* feed max-widths. Used by the desktop 3-column masonry on the home page. */ * feed max-widths. Used by the desktop 3-column masonry on the home page. */
fluid?: boolean; fluid?: boolean;
/** Desktop latest-updates cards follow the dedicated Figma masonry design. */
variant?: MessageBubbleVariant;
onFavoriteChange?: (postId: string, favorited: boolean) => void;
}) { }) {
const Bubble = pickBubble(post); const Bubble = pickBubble(post);
const isVisual = const isVisual =
@@ -38,6 +50,9 @@ export function MessageBubble({
Bubble === VideoBubble || Bubble === VideoBubble ||
Bubble === ImageBubble || Bubble === ImageBubble ||
Bubble === ImageWithTextBubble; Bubble === ImageWithTextBubble;
const isFileBubble = Bubble === FileDocBubble;
const isLatestVariant = variant === "latest";
const isLatestFileCard = isLatestVariant && isFileBubble;
return ( return (
<div <div
@@ -50,23 +65,68 @@ export function MessageBubble({
> >
<article <article
className={`relative w-full overflow-hidden rounded-2xl bg-[#272632] text-left shadow-sm ${ className={`relative w-full overflow-hidden rounded-2xl bg-[#272632] text-left shadow-sm ${
isVisual ? "p-0" : "px-4 py-3" isVisual || isLatestFileCard ? "p-0" : "px-4 py-3"
}`} }`}
> >
<Bubble post={post} /> {isLatestVariant && !isFileBubble ? (
<FavoriteButton
resourceId={post.id}
size="sm"
className="absolute z-20 bottom-4 right-4 shadow-lg shadow-black/30"
onFavoriteChange={(favorited) =>
onFavoriteChange?.(post.id, favorited)
}
/>
) : null}
<Bubble post={post} variant={variant} />
{post.linkPreview ? ( {post.linkPreview ? (
<div className={isVisual ? "px-4 pt-3" : "mt-3"}> <div className={isVisual ? "px-4 pt-3" : "mt-3"}>
<LinkPreviewCard preview={post.linkPreview} /> <LinkPreviewCard preview={post.linkPreview} />
</div> </div>
) : null} ) : null}
<time
dateTime={post.publishedAt} {!isLatestVariant ? (
className={`block text-right text-[12px] leading-[19px] text-[#A8A9AE] ${ <div
isVisual ? "px-4 pb-3 pt-0.5" : "mt-3" className={`flex items-center justify-between gap-3 ${
}`} isVisual ? "px-4 pb-3 pt-3" : "mt-3"
> }`}
{formatDateTime(post.publishedAt)} >
</time> <time
dateTime={post.publishedAt}
className="min-w-0 truncate text-[12px] leading-[19px] text-[#A8A9AE]"
>
{formatDateTime(post.publishedAt)}
</time>
<div className="flex shrink-0 items-center gap-2">
<FavoriteButton
resourceId={post.id}
size="sm"
onFavoriteChange={(favorited) =>
onFavoriteChange?.(post.id, favorited)
}
/>
{isFileBubble && post.attachments.length === 1 ? (
<BubbleAttachmentDownloadButton
postId={post.id}
attachment={post.attachments[0]}
/>
) : null}
</div>
</div>
) : null}
{isLatestVariant && !isFileBubble ? (
<time
dateTime={post.publishedAt}
className={`block text-right text-[12px] leading-[19px] text-[#A8A9AE] ${
isVisual ? "px-4 pb-3 pt-0.5" : "mt-3"
}`}
>
{formatDateTime(post.publishedAt)}
</time>
) : null}
</article> </article>
</div> </div>
); );

View File

@@ -10,6 +10,7 @@ import { FilterChips } from "./FilterChips";
import { MessageBubble } from "./MessageBubble"; import { MessageBubble } from "./MessageBubble";
import { useGroupedByDay } from "./hooks/useGroupedByDay"; import { useGroupedByDay } from "./hooks/useGroupedByDay";
import { usePostStream } from "./hooks/usePostStream"; import { usePostStream } from "./hooks/usePostStream";
import { useFavorites } from "../../favorites/FavoritesProvider";
export type MessageStreamProps = { export type MessageStreamProps = {
scope: PostScope; scope: PostScope;
@@ -23,6 +24,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
const type = sp.get("type") || "all"; const type = sp.get("type") || "all";
const q = (sp.get("q") || "").trim(); const q = (sp.get("q") || "").trim();
const sort = sp.get("sort") || ""; const sort = sp.get("sort") || "";
const singlePostMode = sp.get("single") === "1" && !!sp.get("post");
const params = useMemo( const params = useMemo(
() => ({ scope, type, q, sort, lang }), () => ({ scope, type, q, sort, lang }),
@@ -31,8 +33,13 @@ export function MessageStream({ scope }: MessageStreamProps) {
const { items, isLoading, error, hasMore, loadMore, reset } = const { items, isLoading, error, hasMore, loadMore, reset } =
usePostStream(params); usePostStream(params);
const { ensureFavoriteIds } = useFavorites();
const retryLabel = t("retry"); const retryLabel = t("retry");
useEffect(() => {
void ensureFavoriteIds(items.map((item) => item.id)).catch(() => undefined);
}, [ensureFavoriteIds, items]);
const sentinelRef = useRef<HTMLDivElement>(null); const sentinelRef = useRef<HTMLDivElement>(null);
const filterBarRef = useRef<HTMLDivElement>(null); const filterBarRef = useRef<HTMLDivElement>(null);
const hasMoreRef = useRef(hasMore); const hasMoreRef = useRef(hasMore);
@@ -49,6 +56,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
}, [q]); }, [q]);
useEffect(() => { useEffect(() => {
if (singlePostMode) return;
const el = sentinelRef.current; const el = sentinelRef.current;
if (!el) return; if (!el) return;
const io = new IntersectionObserver( const io = new IntersectionObserver(
@@ -69,7 +77,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
); );
io.observe(el); io.observe(el);
return () => io.disconnect(); return () => io.disconnect();
}, [loadMore]); }, [loadMore, singlePostMode]);
// When arriving with a `?post=<id>` query (or legacy `#post-<id>` hash), // When arriving with a `?post=<id>` query (or legacy `#post-<id>` hash),
// scroll to that bubble — loading more pages until it shows up — then give // scroll to that bubble — loading more pages until it shows up — then give
@@ -84,13 +92,19 @@ export function MessageStream({ scope }: MessageStreamProps) {
); );
const [isFetchingTargetPost, setIsFetchingTargetPost] = useState(false); const [isFetchingTargetPost, setIsFetchingTargetPost] = useState(false);
const [targetPostFetchFailed, setTargetPostFetchFailed] = useState(false); const [targetPostFetchFailed, setTargetPostFetchFailed] = useState(false);
const targetAlreadyInBaseItems = useMemo( const baseTargetPost = useMemo(
() => () =>
!!queryTargetPostId && queryTargetPostId
items.some((post) => post.id === queryTargetPostId), ? (items.find((post) => post.id === queryTargetPostId) ?? null)
: null,
[items, queryTargetPostId], [items, queryTargetPostId],
); );
const targetAlreadyInBaseItems = !!baseTargetPost;
const streamItems = useMemo(() => { const streamItems = useMemo(() => {
if (singlePostMode) {
if (baseTargetPost) return [baseTargetPost];
return resolvedTargetPost ? [resolvedTargetPost] : [];
}
if ( if (
resolvedTargetPost && resolvedTargetPost &&
!items.some((post) => post.id === resolvedTargetPost.id) !items.some((post) => post.id === resolvedTargetPost.id)
@@ -98,7 +112,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
return [resolvedTargetPost, ...items]; return [resolvedTargetPost, ...items];
} }
return items; return items;
}, [items, resolvedTargetPost]); }, [baseTargetPost, items, resolvedTargetPost, singlePostMode]);
const groups = useGroupedByDay(streamItems, lang); const groups = useGroupedByDay(streamItems, lang);
// Lock only engages while we are actively running the smooth-scroll animation // Lock only engages while we are actively running the smooth-scroll animation
// — not during the wait/pagination phase — so the page never feels frozen // — not during the wait/pagination phase — so the page never feels frozen
@@ -349,30 +363,40 @@ export function MessageStream({ scope }: MessageStreamProps) {
// their specific post, not just lazily loading the feed. // their specific post, not just lazily loading the feed.
const targetInLoadedItems = const targetInLoadedItems =
!!queryTargetPostId && streamItems.some((p) => p.id === queryTargetPostId); !!queryTargetPostId && streamItems.some((p) => p.id === queryTargetPostId);
const isSearchingDeepTarget = const isSearchingDeepTarget = singlePostMode
!!queryTargetPostId && ? !!queryTargetPostId && !targetInLoadedItems && isFetchingTargetPost
!targetInLoadedItems && : !!queryTargetPostId &&
!error && !targetInLoadedItems &&
(isFetchingTargetPost || hasMore || isLoading); !error &&
const targetNotFoundInStream = (isFetchingTargetPost || hasMore || isLoading);
!!queryTargetPostId && const targetNotFoundInStream = singlePostMode
!targetInLoadedItems && ? !!queryTargetPostId &&
!error && !targetInLoadedItems &&
targetPostFetchFailed && targetPostFetchFailed &&
!hasMore && !isFetchingTargetPost
!isLoading && : !!queryTargetPostId &&
streamItems.length > 0; !targetInLoadedItems &&
!error &&
targetPostFetchFailed &&
!hasMore &&
!isLoading &&
streamItems.length > 0;
return ( return (
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]"> <div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
{/* Filters stay pinned below the global header (which shows the page {/* Filters stay pinned below the global header (which shows the page
name) so users can switch filters while scrolling. */} name) so users can switch filters while scrolling. */}
<div {!singlePostMode ? (
ref={filterBarRef} <div
className="sticky top-[64px] z-30 bg-ark-bg md:top-[70px]" ref={filterBarRef}
> className="sticky top-[64px] z-30 bg-ark-bg md:top-[70px]"
<FilterChips type={type} onTypeChange={(v) => updateParam("type", v)} /> >
</div> <FilterChips
type={type}
onTypeChange={(v) => updateParam("type", v)}
/>
</div>
) : null}
<div className="flex flex-col gap-3 px-4 pt-4 md:px-0 md:pt-2"> <div className="flex flex-col gap-3 px-4 pt-4 md:px-0 md:pt-2">
{isSearchingDeepTarget ? ( {isSearchingDeepTarget ? (
@@ -426,7 +450,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
</p> </p>
) : null} ) : null}
{error ? ( {!singlePostMode && error ? (
<div <div
role="alert" role="alert"
className="my-4 flex flex-col gap-3 rounded-xl border border-red-900 bg-red-950/40 px-4 py-3 text-sm text-red-200 sm:flex-row sm:items-center sm:justify-between" className="my-4 flex flex-col gap-3 rounded-xl border border-red-900 bg-red-950/40 px-4 py-3 text-sm text-red-200 sm:flex-row sm:items-center sm:justify-between"
@@ -444,7 +468,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
</div> </div>
) : null} ) : null}
{isLoading && !error ? ( {!singlePostMode && isLoading && !error ? (
<div <div
aria-live="polite" aria-live="polite"
aria-label={t("loading")} aria-label={t("loading")}
@@ -456,7 +480,9 @@ export function MessageStream({ scope }: MessageStreamProps) {
</> </>
)} )}
<div ref={sentinelRef} aria-hidden className="h-1" /> {!singlePostMode ? (
<div ref={sentinelRef} aria-hidden className="h-1" />
) : null}
</div> </div>
</div> </div>
); );

View File

@@ -7,6 +7,7 @@ import { downloadAttachment } from "../utils/downloadFile";
import { fileIcon } from "../utils/fileIcon"; import { fileIcon } from "../utils/fileIcon";
import { filenameWithExtension, splitFilename } from "../utils/filenameDisplay"; import { filenameWithExtension, splitFilename } from "../utils/filenameDisplay";
import { formatBytes } from "../utils/formatBytes"; import { formatBytes } from "../utils/formatBytes";
import { formatDateTime } from "../utils/formatTime";
import { postDisplayText } from "../utils/postText"; import { postDisplayText } from "../utils/postText";
import { CollapsibleText } from "../CollapsibleText"; import { CollapsibleText } from "../CollapsibleText";
import { import {
@@ -14,30 +15,23 @@ import {
useSaveToAlbumGuide, useSaveToAlbumGuide,
} from "../../SaveToAlbumGuide"; } from "../../SaveToAlbumGuide";
import { useToast } from "../../Toast"; import { useToast } from "../../Toast";
import { FavoriteButton } from "../../../favorites/FavoriteButton";
import { BubbleAttachmentDownloadButton } from "../BubbleAttachmentDownloadButton";
import type { MessageBubbleVariant } from "../MessageBubble";
function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) { function AttachmentRow({
const { t } = useI18n(); postId,
const { showToast } = useToast(); att,
const { showSaveToAlbumGuide } = useSaveToAlbumGuide(); showDownload,
}: {
postId: string;
att: Attachment;
showDownload: boolean;
}) {
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename }); const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
const displayFilename = filenameWithExtension(att.filename, att.mime); const displayFilename = filenameWithExtension(att.filename, att.mime);
const [isDownloading, setIsDownloading] = useState(false);
const [previewFailed, setPreviewFailed] = useState(false); const [previewFailed, setPreviewFailed] = useState(false);
const handleDownload = async () => {
if (isDownloading) return;
setIsDownloading(true);
try {
await downloadAttachment(postId, att.id, displayFilename);
const mediaKind = mediaSaveKindFromAttachment(att);
if (mediaKind) showSaveToAlbumGuide(mediaKind);
} catch {
showToast(t("downloadFail"), "error");
} finally {
setIsDownloading(false);
}
};
const isImage = att.kind === "image" || att.mime.startsWith("image/"); const isImage = att.kind === "image" || att.mime.startsWith("image/");
const previewUrl = const previewUrl =
att.thumbnailUrl ?? att.posterUrl ?? (isImage ? att.url : undefined); att.thumbnailUrl ?? att.posterUrl ?? (isImage ? att.url : undefined);
@@ -81,36 +75,144 @@ function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
})()} })()}
</div> </div>
<div className="text-[12px] font-medium leading-[19.2px] text-[#A8A9AE]"> <div className="text-[12px] font-medium leading-[19.2px] text-[#A8A9AE]">
{isDownloading ? t("downloading") : formatBytes(att.sizeBytes)} {formatBytes(att.sizeBytes)}
</div> </div>
</div> </div>
<button {showDownload ? (
type="button" <BubbleAttachmentDownloadButton postId={postId} attachment={att} />
onClick={handleDownload} ) : null}
disabled={isDownloading}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#191921] text-white transition hover:bg-[#22232D] disabled:cursor-wait"
aria-label={
isDownloading ? t("downloading") : `Download ${att.filename}`
}
aria-busy={isDownloading}
>
{isDownloading ? (
<LoaderCircle className="h-5 w-5 animate-spin" strokeWidth={2.3} />
) : (
<DownloadCloudIcon className="h-6 w-6" />
)}
</button>
</div> </div>
); );
} }
export function FileDocBubble({ post }: { post: Post }) { function LatestFileCard({ post }: { post: Post }) {
const { t, lang } = useI18n();
const { showToast } = useToast();
const { showSaveToAlbumGuide } = useSaveToAlbumGuide();
const [isDownloading, setIsDownloading] = useState(false);
const att = post.attachments[0];
const text = postDisplayText(post, lang);
if (!att) return null;
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
const displayFilename = filenameWithExtension(att.filename, att.mime);
const handleDownload = async () => {
if (isDownloading) return;
setIsDownloading(true);
try {
await downloadAttachment(post.id, att.id, displayFilename, {
sizeBytes: att.sizeBytes,
});
const mediaKind = mediaSaveKindFromAttachment(att);
if (mediaKind) showSaveToAlbumGuide(mediaKind);
} catch {
showToast(t("downloadFail"), "error");
} finally {
setIsDownloading(false);
}
};
return (
<div className="flex h-[188px] flex-col gap-6 px-4 py-4">
<div className="flex h-[52px] min-w-0 items-center gap-4">
<div
className="flex h-[52px] w-[52px] shrink-0 items-center justify-center rounded-full"
style={{ backgroundColor: color }}
aria-hidden="true"
>
<Icon className="h-8 w-8 text-white" strokeWidth={2.1} />
</div>
<div className="min-w-0 flex-1">
<div
className="flex min-w-0 items-baseline text-[15px] font-medium leading-6 text-ark-gold"
title={displayFilename}
>
{(() => {
const { base, ext } = splitFilename(displayFilename);
const tailChars = Math.min(8, base.length);
const head = base.slice(0, base.length - tailChars);
const tail = base.slice(base.length - tailChars) + ext;
return (
<>
<span className="min-w-0 truncate">{head}</span>
<span className="shrink-0 whitespace-pre">{tail}</span>
</>
);
})()}
</div>
<div className="text-[12px] font-medium leading-[19.2px] text-[#A8A9AE]">
{isDownloading ? t("downloading") : formatBytes(att.sizeBytes)}
</div>
</div>
</div>
{text ? (
<div className="message-stream-copyable-text line-clamp-2 min-h-[48px] select-text whitespace-pre-wrap break-words text-[15px] font-medium leading-6 text-white">
{text}
</div>
) : (
<div className="min-h-[48px]" />
)}
<div className="mt-auto flex h-10 items-end justify-between gap-3">
<time
dateTime={post.publishedAt}
className="text-[12px] font-medium leading-[19.2px] text-[#A8A9AE]"
>
{formatDateTime(post.publishedAt)}
</time>
<div className="flex shrink-0 items-center gap-2">
<FavoriteButton resourceId={post.id} size="sm" />
<button
type="button"
onClick={handleDownload}
disabled={isDownloading}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#191921] transition hover:bg-[#22232D] disabled:cursor-wait"
aria-label={
isDownloading ? t("downloading") : `Download ${att.filename}`
}
aria-busy={isDownloading}
>
{isDownloading ? (
<LoaderCircle
className="h-4 w-4 animate-spin text-[#A8A9AE]"
strokeWidth={2.3}
/>
) : (
<DownloadCloudIcon className="h-5 w-5" />
)}
</button>
</div>
</div>
</div>
);
}
export function FileDocBubble({
post,
variant = "default",
}: {
post: Post;
variant?: MessageBubbleVariant;
}) {
const { lang } = useI18n(); const { lang } = useI18n();
const text = postDisplayText(post, lang); const text = postDisplayText(post, lang);
if (variant === "latest") {
return <LatestFileCard post={post} />;
}
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{post.attachments.map((att) => ( {post.attachments.map((att) => (
<AttachmentRow key={att.id} postId={post.id} att={att} /> <AttachmentRow
key={att.id}
postId={post.id}
att={att}
showDownload={post.attachments.length >= 2}
/>
))} ))}
{text ? ( {text ? (
<CollapsibleText className="message-stream-copyable-text select-text whitespace-pre-wrap break-words text-[15px] font-medium leading-6 text-neutral-100"> <CollapsibleText className="message-stream-copyable-text select-text whitespace-pre-wrap break-words text-[15px] font-medium leading-6 text-neutral-100">

View File

@@ -183,7 +183,9 @@ function AttachmentListDownloadButton({
pauseActiveVideos(); pauseActiveVideos();
setIsDownloading(true); setIsDownloading(true);
try { try {
await downloadAttachment(postId, attachment.id, attachment.filename); await downloadAttachment(postId, attachment.id, attachment.filename, {
sizeBytes: attachment.sizeBytes,
});
showSaveToAlbumGuide("video"); showSaveToAlbumGuide("video");
} catch { } catch {
showToast(t("downloadFail"), "error"); showToast(t("downloadFail"), "error");

View File

@@ -1,4 +1,18 @@
import { assetUrl } from "../../../api"; import { assetUrl } from "../../../api";
import { isInAppBrowser } from "../../../utils/inAppBrowser";
export const IN_APP_DOWNLOAD_GUIDE_EVENT = "ark:in-app-download-guide";
export type InAppDownloadGuideDetail = { url: string; filename: string };
/**
* Files larger than this skip the fetch→Blob path so mobile WebViews and
* memory-constrained devices do not OOM. They fall back to the anchor
* download (relies on the backend's `Content-Disposition: attachment`).
*/
const MAX_BLOB_DOWNLOAD_BYTES = 50 * 1024 * 1024;
export type DownloadOptions = { sizeBytes?: number };
export function pauseActiveVideos() { export function pauseActiveVideos() {
document.querySelectorAll("video").forEach((video) => { document.querySelectorAll("video").forEach((video) => {
@@ -18,12 +32,72 @@ export async function downloadAttachment(
postId: string, postId: string,
attachmentId: string, attachmentId: string,
filename: string, filename: string,
options?: DownloadOptions,
) { ) {
return downloadFile(attachmentDownloadUrl(postId, attachmentId), filename); return downloadFile(
attachmentDownloadUrl(postId, attachmentId),
filename,
options,
);
} }
export async function downloadFile(url: string, filename: string) { export async function downloadFile(
triggerDownload(url, filename || "download"); url: string,
filename: string,
options?: DownloadOptions,
) {
const safeFilename = filename || "download";
// In-app WebViews (WeChat / TokenPocket / Telegram / iOS WKWebView / …)
// ignore `Content-Disposition: attachment` and have no system download
// manager, so an anchor click would just open the file inline. Surface an
// "open in external browser" guide instead of silently failing the user.
if (typeof window !== "undefined" && isInAppBrowser()) {
window.dispatchEvent(
new CustomEvent<InAppDownloadGuideDetail>(IN_APP_DOWNLOAD_GUIDE_EVENT, {
detail: { url: toAbsoluteUrl(url), filename: safeFilename },
}),
);
return;
}
// Normal browsers: prefer fetch → Blob → object URL so the file always lands
// in the Downloads folder with the original filename, even when the browser
// would otherwise inline-preview the response (Chrome and Safari do this for
// PDFs / images regardless of Content-Disposition). Fall back to the anchor
// download for large files (avoid loading them entirely into memory) or when
// fetch fails for any reason.
const sizeBytes = options?.sizeBytes ?? 0;
if (sizeBytes <= MAX_BLOB_DOWNLOAD_BYTES) {
try {
const res = await fetch(url, { credentials: "omit" });
if (res.ok) {
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
try {
triggerDownload(objectUrl, safeFilename);
} finally {
// Give the browser a moment to start the download before revoking.
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 4000);
}
return;
}
} catch {
// fall through to anchor fallback
}
}
triggerDownload(url, safeFilename);
}
function toAbsoluteUrl(url: string): string {
if (/^https?:\/\//i.test(url)) return url;
if (typeof window === "undefined") return url;
try {
return new URL(url, window.location.origin).toString();
} catch {
return url;
}
} }
function triggerDownload(url: string, filename: string) { function triggerDownload(url: string, filename: string) {

View File

@@ -0,0 +1,91 @@
import { LoaderCircle } from "lucide-react";
import { useEffect } from "react";
import { useI18n } from "../i18n";
import { useFavorites } from "./FavoritesProvider";
type FavoriteButtonProps = {
resourceId: string;
className?: string;
size?: "sm" | "md";
onFavoriteChange?: (favorited: boolean) => void;
};
function FigmaBookmarkIcon() {
return (
<svg
width="14"
height="16"
viewBox="0 0 14 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M12.8146 0H1.20137C0.882749 0 0.577175 0.123344 0.351874 0.342899C0.126573 0.562454 0 0.860233 0 1.17073V14.6341C0.000313518 14.8904 0.0758979 15.1412 0.217822 15.357C0.359747 15.5728 0.56208 15.7444 0.800915 15.8517C0.993567 15.9502 1.20808 16.0011 1.42563 16C1.71496 15.9908 1.99446 15.8954 2.22654 15.7268L6.51144 12.6049C6.65008 12.5035 6.8187 12.4488 6.99199 12.4488C7.16529 12.4488 7.3339 12.5035 7.47254 12.6049L11.7574 15.7268C11.9657 15.879 12.2133 15.9717 12.4725 15.9945C12.7318 16.0172 12.9924 15.9692 13.2252 15.8558C13.458 15.7423 13.6538 15.568 13.7907 15.3522C13.9275 15.1364 14 14.8878 14 14.6341V1.17073C14 0.862919 13.8757 0.567481 13.6538 0.348369C13.432 0.129258 13.1305 0.00410415 12.8146 0Z"
fill="currentColor"
/>
</svg>
);
}
export function FavoriteButton({
resourceId,
className = "",
size = "md",
onFavoriteChange,
}: FavoriteButtonProps) {
const { t } = useI18n();
const favorites = useFavorites();
const status = favorites.statusFor(resourceId);
const pending = favorites.pendingIds.has(resourceId);
const isFavorite = status === "favorited";
useEffect(() => {
void favorites.ensureFavoriteIds([resourceId]).catch(() => undefined);
}, [favorites, resourceId]);
const dimension = size === "sm" ? "h-9 w-9" : "h-10 w-10";
return (
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void favorites
.toggleFavorite(resourceId)
.then((favorited) => {
if (favorited !== null) onFavoriteChange?.(favorited);
})
.catch(() => undefined);
}}
disabled={pending}
aria-pressed={isFavorite}
aria-label={isFavorite ? t("favoriteRemove") : t("favoriteAdd")}
title={isFavorite ? t("favoriteRemove") : t("favoriteAdd")}
className={[
"inline-flex shrink-0 items-center justify-center rounded-full border outline-none transition active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait md:disabled:opacity-70",
dimension,
isFavorite
? "border-ark-gold/60 bg-ark-gold text-black md:hover:bg-ark-gold2"
: "border-white/10 bg-[#191921]/90 text-[#A8A9AE] md:hover:border-ark-gold md:hover:bg-[#191921] md:hover:text-ark-gold",
className,
].join(" ")}
aria-busy={pending}
>
{pending ? (
<>
<span className="md:hidden">
<FigmaBookmarkIcon />
</span>
<LoaderCircle
className="hidden h-5 w-5 animate-spin md:block"
strokeWidth={2.2}
/>
</>
) : (
<FigmaBookmarkIcon />
)}
</button>
);
}

View File

@@ -0,0 +1,256 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useToast } from "../components/Toast";
import { useI18n } from "../i18n";
import { useWallet } from "../wallet/WalletProvider";
import {
addFavorite,
getFavoriteIds,
isFavoritesAuthError,
removeFavorite,
} from "./api";
type FavoriteStatus = "unknown" | "favorited" | "notFavorited";
type FavoritesContextValue = {
favoriteIds: Set<string>;
pendingIds: Set<string>;
mutationVersion: number;
statusFor: (resourceId: string) => FavoriteStatus;
ensureFavoriteIds: (resourceIds: string[]) => Promise<void>;
toggleFavorite: (resourceId: string) => Promise<boolean | null>;
markFavorite: (resourceId: string, favorited: boolean) => void;
};
const FavoritesContext = createContext<FavoritesContextValue | null>(null);
export function FavoritesProvider({ children }: { children: ReactNode }) {
const { t } = useI18n();
const { showToast } = useToast();
const wallet = useWallet();
const { address, logout, openLoginModal, status, token } = wallet;
const handleAuthError = useCallback(() => {
logout();
openLoginModal();
showToast(t("favoriteSessionExpired"));
}, [logout, openLoginModal, showToast, t]);
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(() => new Set());
const [knownIds, setKnownIds] = useState<Set<string>>(() => new Set());
const [pendingIds, setPendingIds] = useState<Set<string>>(() => new Set());
const [mutationVersion, setMutationVersion] = useState(0);
const pendingAfterLoginRef = useRef<string | null>(null);
const lastAddressRef = useRef<string | null>(null);
const knownIdsRef = useRef<Set<string>>(new Set());
const inFlightIdsRef = useRef<Set<string>>(new Set());
const queuedIdsRef = useRef<Set<string>>(new Set());
const batchTimerRef = useRef<number | null>(null);
const tokenRef = useRef<string | null>(null);
useEffect(() => {
tokenRef.current = status === "loggedIn" ? token : null;
}, [status, token]);
const clearFavoriteStatus = useCallback(() => {
knownIdsRef.current = new Set();
inFlightIdsRef.current = new Set();
queuedIdsRef.current = new Set();
if (batchTimerRef.current !== null) {
window.clearTimeout(batchTimerRef.current);
batchTimerRef.current = null;
}
setFavoriteIds(new Set());
setKnownIds(new Set());
setPendingIds(new Set());
}, []);
useEffect(() => {
const nextAddress = status === "loggedIn" ? address : null;
if (lastAddressRef.current === nextAddress) return;
lastAddressRef.current = nextAddress;
clearFavoriteStatus();
if (!nextAddress) pendingAfterLoginRef.current = null;
}, [address, clearFavoriteStatus, status]);
useEffect(
() => () => {
if (batchTimerRef.current !== null) {
window.clearTimeout(batchTimerRef.current);
}
},
[],
);
const markFavorite = useCallback((resourceId: string, favorited: boolean) => {
setKnownIds((prev) => {
const next = new Set(prev).add(resourceId);
knownIdsRef.current = next;
return next;
});
setFavoriteIds((prev) => {
const next = new Set(prev);
if (favorited) next.add(resourceId);
else next.delete(resourceId);
return next;
});
}, []);
const flushFavoriteIdBatch = useCallback(async () => {
const requestToken = tokenRef.current;
const requestIds = Array.from(queuedIdsRef.current);
queuedIdsRef.current.clear();
if (!requestToken || requestIds.length === 0) {
requestIds.forEach((id) => inFlightIdsRef.current.delete(id));
return;
}
try {
const ids = await getFavoriteIds(requestToken, requestIds);
if (tokenRef.current !== requestToken) return;
setKnownIds((prev) => {
const next = new Set(prev);
requestIds.forEach((id) => next.add(id));
knownIdsRef.current = next;
return next;
});
setFavoriteIds((prev) => {
const next = new Set(prev);
ids.forEach((id) => next.add(id));
return next;
});
} catch (error) {
if (isFavoritesAuthError(error)) handleAuthError();
} finally {
requestIds.forEach((id) => inFlightIdsRef.current.delete(id));
if (queuedIdsRef.current.size > 0 && batchTimerRef.current === null) {
batchTimerRef.current = window.setTimeout(() => {
batchTimerRef.current = null;
void flushFavoriteIdBatch();
}, 0);
}
}
}, [handleAuthError]);
const ensureFavoriteIds = useCallback(
async (resourceIds: string[]) => {
if (!token || status !== "loggedIn") return;
const missing = [...new Set(resourceIds)].filter(
(id) => !knownIdsRef.current.has(id) && !inFlightIdsRef.current.has(id),
);
if (missing.length === 0) return;
missing.forEach((id) => {
queuedIdsRef.current.add(id);
inFlightIdsRef.current.add(id);
});
if (batchTimerRef.current !== null) return;
batchTimerRef.current = window.setTimeout(() => {
batchTimerRef.current = null;
void flushFavoriteIdBatch();
}, 0);
},
[flushFavoriteIdBatch, status, token],
);
const runFavoriteMutation = useCallback(
async (resourceId: string): Promise<boolean | null> => {
if (!token) return null;
const currentlyFavorite = favoriteIds.has(resourceId);
const nextFavorited = !currentlyFavorite;
setPendingIds((prev) => new Set(prev).add(resourceId));
markFavorite(resourceId, nextFavorited);
try {
if (currentlyFavorite) await removeFavorite(token, resourceId);
else await addFavorite(token, resourceId);
showToast(
currentlyFavorite ? t("favoriteRemoved") : t("favoriteAdded"),
);
setMutationVersion((value) => value + 1);
return nextFavorited;
} catch (error) {
markFavorite(resourceId, currentlyFavorite);
if (isFavoritesAuthError(error)) handleAuthError();
else showToast(t("favoriteFailed"), "error");
throw error;
} finally {
setPendingIds((prev) => {
const next = new Set(prev);
next.delete(resourceId);
return next;
});
}
},
[favoriteIds, handleAuthError, markFavorite, showToast, t, token],
);
const toggleFavorite = useCallback(
async (resourceId: string) => {
if (!token || status !== "loggedIn") {
pendingAfterLoginRef.current = resourceId;
openLoginModal();
showToast(t("favoriteLoginRequired"));
return null;
}
return runFavoriteMutation(resourceId);
},
[openLoginModal, runFavoriteMutation, showToast, status, t, token],
);
useEffect(() => {
if (status !== "loggedIn" || !token) return;
const pending = pendingAfterLoginRef.current;
if (!pending) return;
pendingAfterLoginRef.current = null;
void runFavoriteMutation(pending).catch(() => undefined);
}, [runFavoriteMutation, status, token]);
const statusFor = useCallback(
(resourceId: string): FavoriteStatus => {
if (favoriteIds.has(resourceId)) return "favorited";
if (knownIds.has(resourceId)) return "notFavorited";
return "unknown";
},
[favoriteIds, knownIds],
);
const value = useMemo<FavoritesContextValue>(
() => ({
favoriteIds,
pendingIds,
mutationVersion,
statusFor,
ensureFavoriteIds,
toggleFavorite,
markFavorite,
}),
[
ensureFavoriteIds,
favoriteIds,
markFavorite,
mutationVersion,
pendingIds,
statusFor,
toggleFavorite,
],
);
return (
<FavoritesContext.Provider value={value}>
{children}
</FavoritesContext.Provider>
);
}
export function useFavorites() {
const ctx = useContext(FavoritesContext);
if (!ctx)
throw new Error("useFavorites must be used within FavoritesProvider");
return ctx;
}

119
src/favorites/api.ts Normal file
View File

@@ -0,0 +1,119 @@
import { apiBase, itemsOrEmpty } from "../api";
import type { Post } from "../types/post";
export type FavoriteSort = "favorited_at" | "published_at" | "hot";
export type FavoriteListResponse = {
items: Post[];
page?: number;
limit?: number;
total?: number;
};
export type FavoriteIdsResponse = {
ids: string[];
};
export type FavoriteMutationResponse = {
ok: boolean;
changed?: boolean;
resourceId?: string;
favorited?: boolean;
favoritedAt?: string;
favoriteCount?: number;
};
function authHeaders(token: string): HeadersInit {
return { Authorization: `Bearer ${token}` };
}
function authJSONHeaders(token: string): HeadersInit {
return {
...authHeaders(token),
"Content-Type": "application/json",
};
}
/** HTTP error that preserves the status code so callers can react to 401s. */
export class FavoriteHttpError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message || `Request failed (${status})`);
this.name = "FavoriteHttpError";
this.status = status;
}
}
/** True when an error means the wallet session is no longer authorized. */
export function isFavoritesAuthError(error: unknown): boolean {
return error instanceof FavoriteHttpError && error.status === 401;
}
async function parseJSON<T>(res: Response): Promise<T> {
if (!res.ok) throw new FavoriteHttpError(res.status, await res.text());
return res.json() as Promise<T>;
}
export async function listFavorites(
token: string,
params: {
sort?: FavoriteSort;
page?: number;
limit?: number;
category?: string;
q?: string;
includeUnavailable?: boolean;
lang?: string;
} = {},
): Promise<FavoriteListResponse> {
const sp = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === "") return;
sp.set(key, String(value));
});
const suffix = sp.toString() ? `?${sp}` : "";
const res = await fetch(`${apiBase}/api/favorites${suffix}`, {
headers: authHeaders(token),
});
return parseJSON<FavoriteListResponse>(res);
}
export async function getFavoriteIds(
token: string,
resourceIds: string[],
): Promise<string[]> {
if (resourceIds.length === 0) return [];
const uniqueIds = [...new Set(resourceIds)].slice(0, 100);
const res = await fetch(
`${apiBase}/api/favorites?ids=${encodeURIComponent(uniqueIds.join(","))}`,
{ headers: authHeaders(token) },
);
const data = await parseJSON<FavoriteIdsResponse | FavoriteListResponse>(res);
if ("ids" in data && Array.isArray(data.ids)) return data.ids;
if ("items" in data) return itemsOrEmpty(data.items).map((item) => item.id);
return [];
}
export async function addFavorite(
token: string,
resourceId: string,
): Promise<FavoriteMutationResponse> {
const res = await fetch(`${apiBase}/api/posts/${resourceId}/favorite`, {
method: "POST",
headers: authJSONHeaders(token),
body: JSON.stringify({ add: true }),
});
return parseJSON<FavoriteMutationResponse>(res);
}
export async function removeFavorite(
token: string,
resourceId: string,
): Promise<FavoriteMutationResponse> {
const res = await fetch(`${apiBase}/api/posts/${resourceId}/favorite`, {
method: "POST",
headers: authJSONHeaders(token),
body: JSON.stringify({ add: false }),
});
return parseJSON<FavoriteMutationResponse>(res);
}

View File

@@ -50,20 +50,12 @@ header button {
} }
} }
/* Desktop header nav: thin scrollbar when links overflow (still 單列) */
.header-nav-scroll { .header-nav-scroll {
scrollbar-width: thin; -ms-overflow-style: none;
scrollbar-color: rgba(238, 183, 38, 0.45) transparent; scrollbar-width: none;
} }
.header-nav-scroll::-webkit-scrollbar { .header-nav-scroll::-webkit-scrollbar {
height: 4px; display: none;
}
.header-nav-scroll::-webkit-scrollbar-thumb {
background-color: rgba(238, 183, 38, 0.45);
border-radius: 9999px;
}
.header-nav-scroll::-webkit-scrollbar-track {
background: transparent;
} }
.gold-underline { .gold-underline {

View File

@@ -1,4 +1,10 @@
import { ChevronDown, Menu, Search as SearchIcon, X } from "lucide-react"; import {
ChevronDown,
Heart,
Menu,
Search as SearchIcon,
X,
} from "lucide-react";
import { AnimatePresence, m } from "framer-motion"; import { AnimatePresence, m } from "framer-motion";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Link, useLocation, useNavigate, useOutlet } from "react-router-dom"; import { Link, useLocation, useNavigate, useOutlet } from "react-router-dom";
@@ -19,6 +25,7 @@ import {
stripLangPrefix, stripLangPrefix,
} from "../languageRoutes"; } from "../languageRoutes";
import { useLocalizedPath } from "../useLocalizedPath"; import { useLocalizedPath } from "../useLocalizedPath";
import { WalletButton } from "../wallet/WalletButton";
type PublicNavWhich = type PublicNavWhich =
| "home" | "home"
@@ -75,10 +82,6 @@ function navClassName(active: boolean) {
].join(" "); ].join(" ");
} }
function mobileMenuNavClassName(active: boolean) {
return `${navClassName(active)} w-fit justify-self-start`;
}
const dropdownAnimationClass = "ark-header-popover-enter"; const dropdownAnimationClass = "ark-header-popover-enter";
const headerMenuAnimationClass = "ark-header-menu-enter"; const headerMenuAnimationClass = "ark-header-menu-enter";
@@ -149,13 +152,13 @@ function LanguageDropdown({
<button <button
type="button" type="button"
onClick={() => setOpen((value) => !value)} onClick={() => setOpen((value) => !value)}
className="flex h-full w-full items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-3 py-2 text-sm text-neutral-200 shadow-inner outline-none transition hover:border-ark-gold/50 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg" className="flex h-full w-auto items-center gap-2 whitespace-nowrap rounded-full border border-ark-line bg-[#1a1b20] px-3 py-2 text-sm text-neutral-200 shadow-inner outline-none transition hover:border-ark-gold/50 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
aria-label={ariaLabel} aria-label={ariaLabel}
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded={open} aria-expanded={open}
> >
<FlagIcon code={lang} className="h-5 w-5" /> <FlagIcon code={lang} className="h-5 w-5" />
<span className="min-w-0 flex-1 truncate text-left"> <span className="whitespace-nowrap text-left">
{selected?.label ?? lang} {selected?.label ?? lang}
</span> </span>
<ChevronDown <ChevronDown
@@ -169,7 +172,7 @@ function LanguageDropdown({
{open ? ( {open ? (
<div <div
className={`${dropdownAnimationClass} absolute left-0 right-0 top-[calc(100%+0.5rem)] z-50 overflow-hidden rounded-2xl border border-white/10 bg-[#1c1c21]/95 p-1.5 shadow-2xl shadow-black/70 ring-1 ring-ark-line/80 backdrop-blur-xl`} className={`${dropdownAnimationClass} absolute left-0 top-[calc(100%+0.5rem)] z-50 min-w-full overflow-hidden rounded-2xl border border-white/10 bg-[#1c1c21]/95 p-1.5 shadow-2xl shadow-black/70 ring-1 ring-ark-line/80 backdrop-blur-xl`}
role="listbox" role="listbox"
aria-label={ariaLabel} aria-label={ariaLabel}
> >
@@ -192,7 +195,9 @@ function LanguageDropdown({
}`} }`}
> >
<FlagIcon code={option.code} className="h-5 w-5" /> <FlagIcon code={option.code} className="h-5 w-5" />
<span className="truncate font-medium">{option.label}</span> <span className="whitespace-nowrap font-medium">
{option.label}
</span>
</button> </button>
); );
})} })}
@@ -256,7 +261,7 @@ function MobileLanguageButton({
{open ? ( {open ? (
<div <div
className={`${dropdownAnimationClass} absolute right-0 top-[calc(100%+0.5rem)] z-50 w-44 overflow-hidden rounded-2xl border border-white/10 bg-[#1c1c21]/95 p-1.5 shadow-2xl shadow-black/70 ring-1 ring-ark-line/80 backdrop-blur-xl`} className={`${dropdownAnimationClass} absolute right-0 top-[calc(100%+0.5rem)] z-50 w-max min-w-[12rem] overflow-hidden rounded-2xl border border-white/10 bg-[#1c1c21]/95 p-1.5 shadow-2xl shadow-black/70 ring-1 ring-ark-line/80 backdrop-blur-xl`}
role="listbox" role="listbox"
aria-label={ariaLabel} aria-label={ariaLabel}
> >
@@ -279,7 +284,9 @@ function MobileLanguageButton({
}`} }`}
> >
<FlagIcon code={option.code} className="h-5 w-5" /> <FlagIcon code={option.code} className="h-5 w-5" />
<span className="truncate font-medium">{option.label}</span> <span className="whitespace-nowrap font-medium">
{option.label}
</span>
</button> </button>
); );
})} })}
@@ -486,6 +493,33 @@ export function PublicLayout() {
}; };
}, [mobileSearchOpen]); }, [mobileSearchOpen]);
// Lock background scroll while the full-screen mobile menu drawer is open.
useEffect(() => {
if (!open) return;
const scrollY = window.scrollY;
const body = document.body;
const prev = {
position: body.style.position,
top: body.style.top,
left: body.style.left,
right: body.style.right,
width: body.style.width,
};
body.style.position = "fixed";
body.style.top = `-${scrollY}px`;
body.style.left = "0";
body.style.right = "0";
body.style.width = "100%";
return () => {
body.style.position = prev.position;
body.style.top = prev.top;
body.style.left = prev.left;
body.style.right = prev.right;
body.style.width = prev.width;
window.scrollTo(0, scrollY);
};
}, [open]);
return ( return (
<div className="flex min-h-[100dvh] flex-col bg-ark-bg"> <div className="flex min-h-[100dvh] flex-col bg-ark-bg">
<DocumentMeta /> <DocumentMeta />
@@ -635,6 +669,13 @@ export function PublicLayout() {
> >
{t("latest")} {t("latest")}
</Link> </Link>
<Link
to={lp("/favorites")}
className={navClassName(na("favorites"))}
aria-current={na("favorites") ? "page" : undefined}
>
{t("favorites")}
</Link>
<Link <Link
to={popularHref} to={popularHref}
className={navClassName(na("browsePopular"))} className={navClassName(na("browsePopular"))}
@@ -664,8 +705,24 @@ export function PublicLayout() {
lang={lang} lang={lang}
setLang={changeLang} setLang={changeLang}
ariaLabel={t("langLabel")} ariaLabel={t("langLabel")}
className="hidden h-10 w-36 md:block lg:w-40" className="hidden h-10 shrink-0 md:block"
/> />
<Link
to={lp("/favorites")}
onClick={() => window.scrollTo({ top: 0, left: 0 })}
className={`hidden h-10 shrink-0 items-center justify-center gap-2 rounded-full border px-3 text-sm font-bold outline-none transition focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg md:inline-flex ${
na("favorites")
? "border-ark-gold bg-ark-gold text-black"
: "border-ark-line bg-[#1a1b20] text-neutral-200 hover:border-ark-gold/50 hover:text-ark-gold"
}`}
aria-current={na("favorites") ? "page" : undefined}
>
<Heart className="h-[18px] w-[18px]" strokeWidth={2.2} />
<span className="hidden xl:inline">{t("favorites")}</span>
</Link>
<div className="hidden md:block">
<WalletButton />
</div>
<button <button
ref={desktopMenuButtonRef} ref={desktopMenuButtonRef}
type="button" type="button"
@@ -681,56 +738,69 @@ export function PublicLayout() {
</div> </div>
</div> </div>
</div> </div>
{open ? (
<div
ref={menuRef}
className={`${headerMenuAnimationClass} fixed inset-x-0 top-[64px] z-50 grid gap-2 bg-[#08070c] px-4 py-3 shadow-2xl shadow-black/50 min-[440px]:px-5 sm:px-6 md:top-[70px] md:px-9 min-[1000px]:hidden`}
>
<Link
to={lp("/browse")}
className={mobileMenuNavClassName(na("browseAll"))}
aria-current={na("browseAll") ? "page" : undefined}
onClick={() => setOpen(false)}
>
{t("all")}
</Link>
<Link
to={lp("/categories")}
className={mobileMenuNavClassName(na("categories"))}
aria-current={na("categories") ? "page" : undefined}
onClick={() => setOpen(false)}
>
{t("categories")}
</Link>
<Link
to={lp("/official-recommendations")}
className={mobileMenuNavClassName(na("browseRecommended"))}
aria-current={na("browseRecommended") ? "page" : undefined}
onClick={() => setOpen(false)}
>
{t("official")}
</Link>
<Link
to={lp("/browse?sort=latest")}
className={mobileMenuNavClassName(na("browseLatest"))}
aria-current={na("browseLatest") ? "page" : undefined}
onClick={() => setOpen(false)}
>
{t("latest")}
</Link>
<Link
to={popularHref}
className={mobileMenuNavClassName(na("browsePopular"))}
aria-current={na("browsePopular") ? "page" : undefined}
onClick={() => setOpen(false)}
>
{t("popular")}
</Link>
</div>
) : null}
</header> </header>
{open ? (
<div
ref={menuRef}
className={`${headerMenuAnimationClass} fixed inset-x-0 bottom-0 top-[64px] z-50 flex flex-col bg-ark-bg/90 backdrop-blur-xl md:top-[70px] min-[1000px]:hidden`}
>
<nav className="flex-1 overflow-y-auto px-5">
{(
[
{
to: lp("/browse"),
label: t("all"),
active: na("browseAll"),
},
{
to: lp("/categories"),
label: t("categories"),
active: na("categories"),
},
{
to: lp("/official-recommendations"),
label: t("official"),
active: na("browseRecommended"),
},
{
to: lp("/browse?sort=latest"),
label: t("latest"),
active: na("browseLatest"),
},
{
to: lp("/favorites"),
label: t("favorites"),
active: na("favorites"),
},
{
to: popularHref,
label: t("popular"),
active: na("browsePopular"),
},
] as const
).map((item) => (
<Link
key={item.to}
to={item.to}
aria-current={item.active ? "page" : undefined}
onClick={() => setOpen(false)}
className={`flex h-[68px] items-center border-b border-[#2B2B37] text-[15px] font-medium leading-[20px] outline-none transition-colors focus-visible:text-ark-gold ${
item.active
? "text-ark-gold"
: "text-[#A8A9AE] [@media(hover:hover)]:hover:text-ark-gold"
}`}
>
{item.label}
</Link>
))}
</nav>
<div className="px-5 pb-[max(env(safe-area-inset-bottom),34px)] pt-4">
<WalletButton compact onOpenLogin={() => setOpen(false)} />
</div>
</div>
) : null}
{mobileSearchOpen ? ( {mobileSearchOpen ? (
<SearchPanel <SearchPanel
lang={lang} lang={lang}
@@ -778,7 +848,7 @@ export function PublicLayout() {
</main> </main>
<nav className="fixed inset-x-0 bottom-0 z-40 select-none bg-[#0C0D0F]/95 pb-[max(env(safe-area-inset-bottom),0px)] backdrop-blur md:hidden"> <nav className="fixed inset-x-0 bottom-0 z-40 select-none bg-[#0C0D0F]/95 pb-[max(env(safe-area-inset-bottom),0px)] backdrop-blur md:hidden">
<div className="grid h-[68px] grid-cols-3 gap-3 px-5 py-[10px] text-center text-[11px] leading-[17.6px]"> <div className="grid h-[68px] grid-cols-4 gap-2 px-4 py-[10px] text-center text-[11px] leading-[17.6px]">
<BottomNavIcon <BottomNavIcon
to={homePath} to={homePath}
label={t("home")} label={t("home")}
@@ -794,6 +864,12 @@ export function PublicLayout() {
!new URLSearchParams(search).get("sort") !new URLSearchParams(search).get("sort")
} }
/> />
<BottomNavIcon
to={lp("/favorites")}
label={t("favorites")}
icon="bookmark"
active={na("favorites")}
/>
<BottomNavIcon <BottomNavIcon
to={popularHref} to={popularHref}
label={t("popular")} label={t("popular")}

View File

@@ -145,12 +145,136 @@ export const enDict: Dict = {
adminSearchQuery: "Query", adminSearchQuery: "Query",
adminSearchTime: "Time", adminSearchTime: "Time",
adminSearchId: "ID", adminSearchId: "ID",
favoriteAdd: "Add to favorites",
favoriteRemove: "Remove from favorites",
favoriteAdded: "Added to favorites",
favoriteRemoved: "Removed from favorites",
favoriteFailed: "Could not update favorites",
favoriteLoginRequired: "Connect your wallet to save favorites",
favoritesLoginDesc:
"Connect your wallet to view and manage your saved ARK resources.",
favoritesLibraryTitle: "Saved resources",
favoritesEmptyTitle: "No favorites yet",
favoritesEmptyDesc:
"Browse resources and tap the heart icon to save them here.",
favoritesNoFilteredTitle: "No matching favorites",
favoritesNoFilteredDesc:
"Try changing your search, category, or sort filters.",
favoritesFilterAllCategories: "All categories",
favoritesSortFavoritedAt: "Recently saved",
favoritesSortPublishedAt: "Newest published",
favoritesSortHot: "Hot resources",
favoritesSearchPlaceholder: "Search your favorites",
favoritesUnavailable: "Unavailable",
postShownInOriginalLanguage:
"This post is not available in your selected language. Showing the original.",
favoritesClearFilters: "Clear filters",
favorites: "My Favorites", favorites: "My Favorites",
favoritesComingSoon: "Coming Soon", favoritesComingSoon: "Coming Soon",
favoritesComingSoonDesc: favoritesComingSoonDesc:
"Sign-in and favorites are in development. Stay tuned.", "Sign-in and favorites are in development. Stay tuned.",
close: "Close",
walletConnect: "Connect Wallet",
walletConnectedAs: "Connected wallet",
walletLoginAddress: "Login address",
walletDisconnect: "Disconnect",
walletLoginTitle: "Connect wallet",
walletLoginDesc:
"Sign a message to verify your wallet address. No transaction or gas fee.",
walletInjected: "Use browser wallet",
walletInjectedDesc: "Sign with the wallet available in this browser.",
walletNoBrowserWallet: "No browser wallet detected",
walletNoBrowserWalletDesc:
"Install or enable a browser wallet extension, such as MetaMask.",
walletOpenWalletApp: "Open wallet app",
walletOpenWalletAppDesc:
"Open this site in your wallet app, then sign to log in.",
walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask",
walletImToken: "imToken",
walletBack: "Back",
walletChooseMethod: "Choose how to log in",
walletTokenPocketLogin: "TokenPocket login",
walletTpMobileDesc:
"Open TokenPocket to sign, then come back here to finish. You stay in this browser instead of the wallet's in-app browser.",
walletTpLoginBtn: "Log in with TokenPocket",
walletTpWaiting: "Waiting for your signature in TokenPocket…",
walletTpReopen: "Reopen TokenPocket",
favoritesFilters: "Filters",
favoriteSessionExpired: "Your session expired. Please sign in again.",
loadFailed: "Could not load your favorites.",
walletChooseDesktop:
"Choose the wallet you want to use. On desktop, install the matching browser extension.",
walletChooseMobile: "Choose a wallet app to open this site.",
walletDesktopHint:
"If no wallet opens after clicking, make sure the matching browser extension is installed and enabled.",
walletInstallSelected:
"No {wallet} browser extension detected. Install or enable it, then try again.",
walletDesktopHelpTitle: "We tried to open {wallet}. Next steps:",
walletDesktopHelpUnlock:
"Open the wallet extension in the browser toolbar and unlock it.",
walletDesktopHelpSelect:
"Make sure the wallet has an account, then select that account.",
walletDesktopHelpRetry: "Come back here and click “Reconnect {wallet}”.",
walletReconnectWallet: "Reconnect {wallet}",
walletInstallWallet: "Install {wallet} extension",
walletConfirmAddressTitle: "Log in with this {wallet} address?",
walletConfirmAddressDesc:
"Confirm this is the {wallet} address you want to use.",
walletConfirmLogin: "Confirm login",
walletCancelLogin: "Cancel",
walletDesktopImTokenTitle: "Open this site in the mobile {wallet} app",
walletDesktopImTokenDesc:
"{wallet} does not provide a desktop browser extension. Open https://arkie-library-stag.com inside the mobile {wallet} app to log in.",
walletOpen: "Open",
walletQrLogin: "QR login",
walletMobileQrDesc:
"Use another device to scan this QR code and log in to this browser.",
walletTokenPocketQr: "TokenPocket QR login",
walletTokenPocketQrDesc:
"Recommended for China users. Scan with TokenPocket and sign to return login to this browser.",
walletGenerateQr: "Generate QR",
walletQrUseAnotherDevice: "Scan with TokenPocket on another device.",
walletOpenTokenPocket: "Open TokenPocket",
walletOpenMetaMask: "Open MetaMask",
walletOpenImToken: "Open imToken",
walletRainbowFallback: "MetaMask / imToken QR fallback",
walletRainbowFallbackDesc:
"Use RainbowKit/Reown scan login if you need QR for MetaMask or imToken.",
walletOpenRainbow: "Open QR login",
walletNetworkWarning:
"This fallback uses WalletConnect/Reown and may be unstable on some China networks. If it fails, open this site inside your wallet app.",
walletSigning: "Signing…",
walletTpExpired: "TokenPocket QR expired. Please generate a new one.",
walletTpQrFailed: "Could not generate TokenPocket QR.",
walletRainbowUnavailable: "QR login is not available yet.",
walletLoginSuccess: "Wallet connected",
walletLoginFailed: "Wallet login failed",
walletNoAccount:
"No wallet account was returned. Unlock your wallet and select an account, then try again.",
walletDisconnected: "Wallet disconnected",
walletOtherMethods: "Other login methods",
walletUseCurrent: "Use current wallet",
walletOpening: "Opening {wallet}…",
walletAppNotInstalled: "If nothing opened, the app may not be installed.",
walletDownloadApp: "Download {wallet}",
walletRetry: "Try again",
walletConnecting: "Connecting…",
featureUnavailable: "Not available yet", featureUnavailable: "Not available yet",
featureUnavailableDesc: "This feature is not available yet.", featureUnavailableDesc: "This feature is not available yet.",
confirm: "Got it", confirm: "Got it",
backToHome: "Back to Home", backToHome: "Back to Home",
inAppDownloadTitle: "Please open in your system browser to download",
inAppDownloadIntro:
"Your current in-app browser cannot download files. Copy the link below and open it in your system browser — the file will save directly.",
inAppDownloadIntroNamed:
"{browser} cannot download files directly. Copy the link below and open it in your system browser — the file will save directly.",
inAppDownloadStepCopy:
'Tap "Copy link" below — that is the direct file download URL.',
inAppDownloadStepOpen:
"Open your system browser (Safari, Chrome, etc.) and paste the link into the address bar.",
inAppDownloadStepDownload:
"The file will start downloading to your downloads folder automatically.",
inAppDownloadCopied: "Link copied",
inAppDownloadCopyFail: "Could not copy the link, please copy it manually",
}; };

View File

@@ -146,12 +146,137 @@ export const idDict: Dict = {
adminSearchQuery: "Kueri", adminSearchQuery: "Kueri",
adminSearchTime: "Waktu", adminSearchTime: "Waktu",
adminSearchId: "ID", adminSearchId: "ID",
favoriteAdd: "Tambah ke favorit",
favoriteRemove: "Hapus dari favorit",
favoriteAdded: "Ditambahkan ke favorit",
favoriteRemoved: "Dihapus dari favorit",
favoriteFailed: "Tidak dapat memperbarui favorit",
favoriteLoginRequired: "Hubungkan dompet untuk menyimpan favorit",
favoritesLoginDesc:
"Hubungkan dompet untuk melihat dan mengelola sumber ARK yang disimpan.",
favoritesLibraryTitle: "Sumber tersimpan",
favoritesEmptyTitle: "Belum ada favorit",
favoritesEmptyDesc:
"Jelajahi sumber dan ketuk ikon hati untuk menyimpannya di sini.",
favoritesNoFilteredTitle: "Tidak ada favorit yang cocok",
favoritesNoFilteredDesc: "Coba ubah pencarian, kategori, atau urutan.",
favoritesFilterAllCategories: "Semua kategori",
favoritesSortFavoritedAt: "Baru disimpan",
favoritesSortPublishedAt: "Terbaru diterbitkan",
favoritesSortHot: "Sumber populer",
favoritesSearchPlaceholder: "Cari favorit Anda",
favoritesUnavailable: "Tidak tersedia",
postShownInOriginalLanguage:
"Postingan ini tidak tersedia dalam bahasa yang Anda pilih. Menampilkan bahasa aslinya.",
favoritesClearFilters: "Hapus filter",
favorites: "Favorit Saya", favorites: "Favorit Saya",
favoritesComingSoon: "Segera Hadir", favoritesComingSoon: "Segera Hadir",
favoritesComingSoonDesc: favoritesComingSoonDesc:
"Fitur masuk dan favorit sedang dikembangkan. Nantikan.", "Fitur masuk dan favorit sedang dikembangkan. Nantikan.",
close: "Tutup",
walletConnect: "Hubungkan Dompet",
walletConnectedAs: "Dompet terhubung",
walletLoginAddress: "Alamat login",
walletDisconnect: "Putuskan",
walletLoginTitle: "Hubungkan dompet",
walletLoginDesc:
"Tanda tangani pesan untuk memverifikasi alamat dompet. Tidak ada transaksi atau gas.",
walletInjected: "Gunakan dompet browser",
walletInjectedDesc:
"Tanda tangani dengan dompet yang tersedia di browser ini.",
walletNoBrowserWallet: "Tidak ada dompet browser terdeteksi",
walletNoBrowserWalletDesc:
"Pasang atau aktifkan ekstensi dompet browser, seperti MetaMask.",
walletOpenWalletApp: "Buka aplikasi dompet",
walletOpenWalletAppDesc:
"Buka situs ini di aplikasi dompet Anda, lalu tanda tangani untuk masuk.",
walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask",
walletImToken: "imToken",
walletBack: "Kembali",
walletChooseMethod: "Pilih cara masuk",
walletTokenPocketLogin: "Masuk TokenPocket",
walletTpMobileDesc:
"Buka TokenPocket untuk menandatangani, lalu kembali ke sini untuk menyelesaikan. Anda tetap di browser ini, bukan browser dalam aplikasi dompet.",
walletTpLoginBtn: "Masuk dengan TokenPocket",
walletTpWaiting: "Menunggu tanda tangan Anda di TokenPocket…",
walletTpReopen: "Buka kembali TokenPocket",
favoritesFilters: "Filter",
favoriteSessionExpired: "Sesi Anda telah berakhir. Silakan masuk lagi.",
loadFailed: "Gagal memuat favorit Anda.",
walletChooseDesktop:
"Pilih dompet yang ingin digunakan. Di desktop, pasang ekstensi browser yang sesuai.",
walletChooseMobile: "Pilih aplikasi dompet untuk membuka situs ini.",
walletDesktopHint:
"Jika tidak ada dompet terbuka setelah diklik, pastikan ekstensi browser yang sesuai sudah terpasang dan diaktifkan.",
walletInstallSelected:
"Ekstensi browser {wallet} tidak terdeteksi. Pasang atau aktifkan, lalu coba lagi.",
walletDesktopHelpTitle: "Kami mencoba membuka {wallet}. Langkah berikutnya:",
walletDesktopHelpUnlock:
"Buka ekstensi dompet di toolbar browser dan buka kuncinya.",
walletDesktopHelpSelect:
"Pastikan dompet memiliki akun, lalu pilih akun tersebut.",
walletDesktopHelpRetry:
"Kembali ke sini dan klik “Hubungkan ulang {wallet}”.",
walletReconnectWallet: "Hubungkan ulang {wallet}",
walletInstallWallet: "Pasang ekstensi {wallet}",
walletConfirmAddressTitle: "Masuk dengan alamat {wallet} ini?",
walletConfirmAddressDesc:
"Pastikan ini adalah alamat dompet {wallet} yang ingin Anda gunakan.",
walletConfirmLogin: "Konfirmasi masuk",
walletCancelLogin: "Batal",
walletDesktopImTokenTitle: "Buka situs ini di aplikasi {wallet} seluler",
walletDesktopImTokenDesc:
"{wallet} tidak menyediakan ekstensi browser desktop. Buka https://arkie-library-stag.com di dalam aplikasi {wallet} seluler untuk masuk.",
walletOpen: "Buka",
walletQrLogin: "Login QR",
walletMobileQrDesc:
"Gunakan perangkat lain untuk memindai kode QR ini dan masuk di browser ini.",
walletTokenPocketQr: "Login QR TokenPocket",
walletTokenPocketQrDesc:
"Direkomendasikan untuk pengguna Tiongkok. Pindai dengan TokenPocket dan tanda tangani untuk login di browser ini.",
walletGenerateQr: "Buat QR",
walletQrUseAnotherDevice: "Pindai dengan TokenPocket di perangkat lain.",
walletOpenTokenPocket: "Buka TokenPocket",
walletOpenMetaMask: "Buka MetaMask",
walletOpenImToken: "Buka imToken",
walletRainbowFallback: "Fallback QR MetaMask / imToken",
walletRainbowFallbackDesc:
"Gunakan RainbowKit/Reown jika butuh QR untuk MetaMask atau imToken.",
walletOpenRainbow: "Buka login QR",
walletNetworkWarning:
"Fallback ini memakai WalletConnect/Reown dan mungkin tidak stabil di beberapa jaringan Tiongkok. Jika gagal, buka situs ini di browser DApp dompet.",
walletSigning: "Menandatangani…",
walletTpExpired: "QR TokenPocket kedaluwarsa. Buat yang baru.",
walletTpQrFailed: "Tidak dapat membuat QR TokenPocket.",
walletRainbowUnavailable: "Login QR belum tersedia.",
walletLoginSuccess: "Dompet terhubung",
walletLoginFailed: "Login dompet gagal",
walletNoAccount:
"Dompet tidak mengembalikan akun apa pun. Buka kunci dompet, pilih akun, lalu coba lagi.",
walletDisconnected: "Dompet terputus",
walletOtherMethods: "Metode login lainnya",
walletUseCurrent: "Gunakan dompet saat ini",
walletOpening: "Membuka {wallet}…",
walletAppNotInstalled:
"Jika tidak ada yang terbuka, aplikasi mungkin belum terpasang.",
walletDownloadApp: "Unduh {wallet}",
walletRetry: "Coba lagi",
walletConnecting: "Menghubungkan…",
featureUnavailable: "Belum tersedia", featureUnavailable: "Belum tersedia",
featureUnavailableDesc: "Fitur ini belum tersedia.", featureUnavailableDesc: "Fitur ini belum tersedia.",
confirm: "Mengerti", confirm: "Mengerti",
backToHome: "Kembali ke Beranda", backToHome: "Kembali ke Beranda",
inAppDownloadTitle: "Silakan buka di peramban sistem untuk mengunduh",
inAppDownloadIntro:
"Peramban dalam aplikasi saat ini tidak dapat mengunduh berkas. Salin tautan di bawah dan buka di peramban sistem — berkas akan langsung tersimpan.",
inAppDownloadIntroNamed:
"{browser} tidak dapat mengunduh berkas secara langsung. Salin tautan di bawah dan buka di peramban sistem — berkas akan langsung tersimpan.",
inAppDownloadStepCopy:
'Ketuk "Salin tautan" di bawah (ini adalah URL unduhan langsung berkas).',
inAppDownloadStepOpen:
"Buka peramban sistem (Safari, Chrome, dll.) dan tempel tautan ke bilah alamat.",
inAppDownloadStepDownload: "Berkas akan otomatis terunduh ke folder Unduhan.",
inAppDownloadCopied: "Tautan disalin",
inAppDownloadCopyFail: "Tidak dapat menyalin, silakan salin secara manual",
}; };

View File

@@ -147,11 +147,137 @@ export const jaDict: Dict = {
adminSearchQuery: "検索キーワード", adminSearchQuery: "検索キーワード",
adminSearchTime: "時刻", adminSearchTime: "時刻",
adminSearchId: "ID", adminSearchId: "ID",
favoriteAdd: "お気に入りに追加",
favoriteRemove: "お気に入りから削除",
favoriteAdded: "お気に入りに追加しました",
favoriteRemoved: "お気に入りから削除しました",
favoriteFailed: "お気に入りを更新できませんでした",
favoriteLoginRequired: "お気に入り保存にはウォレット接続が必要です",
favoritesLoginDesc:
"ウォレットを接続すると、保存した ARK 資料を表示・管理できます。",
favoritesLibraryTitle: "保存した資料",
favoritesEmptyTitle: "お気に入りはまだありません",
favoritesEmptyDesc: "資料を閲覧してハートを押すと、ここに保存されます。",
favoritesNoFilteredTitle: "一致するお気に入りがありません",
favoritesNoFilteredDesc: "検索、カテゴリ、並び順を変更してみてください。",
favoritesFilterAllCategories: "すべてのカテゴリ",
favoritesSortFavoritedAt: "最近保存",
favoritesSortPublishedAt: "新しい公開順",
favoritesSortHot: "人気資料",
favoritesSearchPlaceholder: "お気に入りを検索",
favoritesUnavailable: "利用不可",
postShownInOriginalLanguage:
"この投稿は選択した言語で提供されていないため、原語で表示します。",
favoritesClearFilters: "フィルターをクリア",
favorites: "お気に入り", favorites: "お気に入り",
favoritesComingSoon: "近日公開", favoritesComingSoon: "近日公開",
favoritesComingSoonDesc: "ログインとお気に入り機能は開発中です。お楽しみに。", favoritesComingSoonDesc: "ログインとお気に入り機能は開発中です。お楽しみに。",
close: "閉じる",
walletConnect: "ウォレット接続",
walletConnectedAs: "接続中のウォレット",
walletLoginAddress: "ログインアドレス",
walletDisconnect: "切断",
walletLoginTitle: "ウォレットを接続",
walletLoginDesc:
"メッセージ署名でウォレットアドレスを確認します。取引やガス代は発生しません。",
walletInjected: "ブラウザウォレット / DApp ブラウザ",
walletInjectedDesc: "このブラウザで利用可能なウォレットを使います。",
walletTokenPocketQr: "TokenPocket QR ログイン",
walletTokenPocketQrDesc:
"中国ユーザーに推奨。TokenPocket でスキャンして署名すると、このブラウザでログインが完了します。",
walletGenerateQr: "QR を生成",
walletQrUseAnotherDevice: "別の端末の TokenPocket でスキャンしてください。",
walletOpenTokenPocket: "TokenPocket を開く",
walletOpenMetaMask: "MetaMask を開く",
walletOpenImToken: "imToken を開く",
walletRainbowFallback: "MetaMask / imToken QR 予備",
walletRainbowFallbackDesc:
"MetaMask または imToken の QR が必要な場合は RainbowKit/Reown 接続を使います。",
walletOpenRainbow: "QR ログインを開く",
walletNetworkWarning:
"この予備方式は WalletConnect/Reown に依存するため、中国の一部ネットワークでは不安定な場合があります。失敗した場合はウォレット内蔵ブラウザで開いてください。",
walletSigning: "署名中…",
walletTpExpired:
"TokenPocket QR の有効期限が切れました。再生成してください。",
walletTpQrFailed: "TokenPocket QR を生成できませんでした。",
walletRainbowUnavailable: "QR ログインは現在利用できません。",
walletLoginSuccess: "ウォレットを接続しました",
walletLoginFailed: "ウォレットログインに失敗しました",
walletNoAccount:
"ウォレットからアカウントが返されませんでした。ウォレットのロックを解除してアカウントを選択し、もう一度お試しください。",
walletDisconnected: "ウォレットを切断しました",
walletNoBrowserWallet: "ブラウザウォレットが見つかりません",
walletNoBrowserWalletDesc:
"MetaMask などのブラウザウォレット拡張機能をインストールまたは有効にしてください。",
walletOpenWalletApp: "ウォレットアプリで開く",
walletOpenWalletAppDesc:
"このサイトをウォレットアプリで開き、署名してログインしてください。",
walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask",
walletImToken: "imToken",
walletBack: "戻る",
walletChooseMethod: "ログイン方法を選択",
walletTokenPocketLogin: "TokenPocket ログイン",
walletTpMobileDesc:
"TokenPocket で署名するとこのページに戻ってログインが完了します。ウォレット内ブラウザには移動せず、現在のブラウザのままです。",
walletTpLoginBtn: "TokenPocket でログイン",
walletTpWaiting: "TokenPocket での署名を待っています…",
walletTpReopen: "TokenPocket を再度開く",
favoritesFilters: "フィルター",
favoriteSessionExpired:
"セッションの有効期限が切れました。もう一度ログインしてください。",
loadFailed: "お気に入りを読み込めませんでした。",
walletChooseDesktop:
"使用するウォレットを選択してください。デスクトップの場合は対応するブラウザ拡張機能をインストールしてください。",
walletChooseMobile: "このサイトを開くウォレットアプリを選択してください。",
walletDesktopHint:
"クリックしてもウォレットが開かない場合は、対応するブラウザ拡張機能がインストールされ有効になっているか確認してください。",
walletInstallSelected:
"{wallet} のブラウザ拡張機能が検出されません。インストールまたは有効にしてから再試行してください。",
walletDesktopHelpTitle:
"{wallet} を開こうとしました。次の手順を行ってください:",
walletDesktopHelpUnlock:
"ブラウザのツールバーからウォレット拡張機能を開き、ロックを解除します。",
walletDesktopHelpSelect:
"ウォレットにアカウントがあることを確認し、そのアカウントを選択します。",
walletDesktopHelpRetry: "ここに戻って「{wallet} に再接続」をクリックします。",
walletReconnectWallet: "{wallet} に再接続",
walletInstallWallet: "{wallet} 拡張機能をインストール",
walletConfirmAddressTitle: "この {wallet} アドレスでログインしますか?",
walletConfirmAddressDesc:
"使用する {wallet} ウォレットアドレスであることを確認してください。",
walletConfirmLogin: "ログインを確認",
walletCancelLogin: "キャンセル",
walletDesktopImTokenTitle:
"モバイル {wallet} アプリでこのサイトを開いてください",
walletDesktopImTokenDesc:
"{wallet} にはデスクトップ用ブラウザ拡張機能がありません。モバイル {wallet} アプリ内で https://arkie-library-stag.com を開いてログインしてください。",
walletOpen: "開く",
walletQrLogin: "QR ログイン",
walletMobileQrDesc:
"別のデバイスでこの QR コードをスキャンして、このブラウザにログインしてください。",
walletOtherMethods: "他のログイン方法",
walletUseCurrent: "現在のウォレットを使用",
walletOpening: "{wallet} を起動中…",
walletAppNotInstalled:
"何も起動しない場合は、アプリがインストールされていない可能性があります。",
walletDownloadApp: "{wallet} をダウンロード",
walletRetry: "再試行",
walletConnecting: "接続中…",
featureUnavailable: "未公開", featureUnavailable: "未公開",
featureUnavailableDesc: "この機能はまだご利用いただけません。", featureUnavailableDesc: "この機能はまだご利用いただけません。",
confirm: "了解", confirm: "了解",
backToHome: "ホームへ戻る", backToHome: "ホームへ戻る",
inAppDownloadTitle: "システムブラウザで開いてダウンロードしてください",
inAppDownloadIntro:
"現在のアプリ内ブラウザではファイルをダウンロードできません。下のリンクをコピーし、システムブラウザで開けば自動的にダウンロードされます。",
inAppDownloadIntroNamed:
"{browser} のアプリ内ブラウザではファイルをダウンロードできません。下のリンクをコピーし、システムブラウザで開けば自動的にダウンロードされます。",
inAppDownloadStepCopy:
"下の「リンクをコピー」をタップします(ファイルの直接ダウンロード URL です)。",
inAppDownloadStepOpen:
"システムブラウザSafari、Chrome など)を開き、アドレスバーにリンクを貼り付けます。",
inAppDownloadStepDownload: "ファイルは自動的にダウンロード先に保存されます。",
inAppDownloadCopied: "リンクをコピーしました",
inAppDownloadCopyFail: "コピーに失敗しました。手動でコピーしてください",
}; };

View File

@@ -145,12 +145,134 @@ export const koDict: Dict = {
adminSearchQuery: "검색어", adminSearchQuery: "검색어",
adminSearchTime: "시간", adminSearchTime: "시간",
adminSearchId: "ID", adminSearchId: "ID",
favoriteAdd: "즐겨찾기에 추가",
favoriteRemove: "즐겨찾기에서 제거",
favoriteAdded: "즐겨찾기에 추가되었습니다",
favoriteRemoved: "즐겨찾기에서 제거되었습니다",
favoriteFailed: "즐겨찾기를 업데이트할 수 없습니다",
favoriteLoginRequired: "즐겨찾기를 저장하려면 지갑을 연결하세요",
favoritesLoginDesc:
"지갑을 연결하면 저장한 ARK 자료를 보고 관리할 수 있습니다.",
favoritesLibraryTitle: "저장한 자료",
favoritesEmptyTitle: "아직 즐겨찾기가 없습니다",
favoritesEmptyDesc: "자료를 둘러보다가 하트 아이콘을 눌러 여기에 저장하세요.",
favoritesNoFilteredTitle: "일치하는 즐겨찾기가 없습니다",
favoritesNoFilteredDesc: "검색어, 카테고리 또는 정렬을 변경해 보세요.",
favoritesFilterAllCategories: "모든 카테고리",
favoritesSortFavoritedAt: "최근 저장",
favoritesSortPublishedAt: "최신 게시",
favoritesSortHot: "인기 자료",
favoritesSearchPlaceholder: "내 즐겨찾기 검색",
favoritesUnavailable: "사용 불가",
postShownInOriginalLanguage:
"이 게시물은 선택하신 언어로 제공되지 않아 원본 언어로 표시됩니다.",
favoritesClearFilters: "필터 지우기",
favorites: "내 즐겨찾기", favorites: "내 즐겨찾기",
favoritesComingSoon: "출시 예정", favoritesComingSoon: "출시 예정",
favoritesComingSoonDesc: favoritesComingSoonDesc:
"로그인과 즐겨찾기 기능을 개발 중입니다. 많은 기대 부탁드립니다.", "로그인과 즐겨찾기 기능을 개발 중입니다. 많은 기대 부탁드립니다.",
close: "닫기",
walletConnect: "지갑 연결",
walletConnectedAs: "연결된 지갑",
walletLoginAddress: "로그인 주소",
walletDisconnect: "연결 해제",
walletLoginTitle: "지갑 연결",
walletLoginDesc:
"메시지 서명으로 지갑 주소를 확인합니다. 트랜잭션이나 가스 수수료는 없습니다.",
walletInjected: "브라우저 지갑 사용",
walletInjectedDesc: "이 브라우저에서 사용 가능한 지갑으로 서명합니다.",
walletNoBrowserWallet: "브라우저 지갑을 찾을 수 없습니다",
walletNoBrowserWalletDesc:
"MetaMask 등의 브라우저 지갑 확장 프로그램을 설치하거나 활성화하세요.",
walletOpenWalletApp: "지갑 앱으로 열기",
walletOpenWalletAppDesc:
"지갑 앱에서 이 사이트를 열고 서명하여 로그인하세요.",
walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask",
walletImToken: "imToken",
walletBack: "뒤로",
walletChooseMethod: "로그인 방법 선택",
walletTokenPocketLogin: "TokenPocket 로그인",
walletTpMobileDesc:
"TokenPocket에서 서명하면 이 페이지로 돌아와 로그인이 완료됩니다. 지갑 내장 브라우저로 이동하지 않고 현재 브라우저에 머무릅니다.",
walletTpLoginBtn: "TokenPocket으로 로그인",
walletTpWaiting: "TokenPocket에서 서명을 기다리는 중…",
walletTpReopen: "TokenPocket 다시 열기",
favoritesFilters: "필터",
favoriteSessionExpired: "세션이 만료되었습니다. 다시 로그인해 주세요.",
loadFailed: "즐겨찾기를 불러오지 못했습니다.",
walletChooseDesktop:
"사용할 지갑을 선택하세요. 데스크톱에서는 해당 브라우저 확장 프로그램을 설치하세요.",
walletChooseMobile: "이 사이트를 열 지갑 앱을 선택하세요.",
walletDesktopHint:
"클릭 후 지갑이 열리지 않으면 해당 브라우저 확장 프로그램이 설치되고 활성화되어 있는지 확인하세요.",
walletInstallSelected:
"{wallet} 브라우저 확장 프로그램을 찾을 수 없습니다. 설치하거나 활성화한 후 다시 시도하세요.",
walletDesktopHelpTitle:
"{wallet} 열기를 시도했습니다. 다음 단계를 진행하세요:",
walletDesktopHelpUnlock:
"브라우저 툴바에서 지갑 확장 프로그램을 열고 잠금을 해제하세요.",
walletDesktopHelpSelect:
"지갑에 계정이 있는지 확인한 뒤 해당 계정을 선택하세요.",
walletDesktopHelpRetry: "여기로 돌아와 {wallet} 다시 연결’을 클릭하세요.",
walletReconnectWallet: "{wallet} 다시 연결",
walletInstallWallet: "{wallet} 확장 프로그램 설치",
walletConfirmAddressTitle: "이 {wallet} 주소로 로그인할까요?",
walletConfirmAddressDesc:
"사용하려는 {wallet} 지갑 주소가 맞는지 확인하세요.",
walletConfirmLogin: "로그인 확인",
walletCancelLogin: "취소",
walletDesktopImTokenTitle: "모바일 {wallet} 앱에서 이 사이트를 여세요",
walletDesktopImTokenDesc:
"{wallet}은 데스크톱 브라우저 확장 프로그램을 제공하지 않습니다. 모바일 {wallet} 앱 안에서 https://arkie-library-stag.com 을 열어 로그인하세요.",
walletOpen: "열기",
walletQrLogin: "QR 로그인",
walletMobileQrDesc:
"다른 기기로 이 QR 코드를 스캔하여 이 브라우저에 로그인하세요.",
walletTokenPocketQr: "TokenPocket QR 로그인",
walletTokenPocketQrDesc:
"중국 사용자에게 권장됩니다. TokenPocket으로 스캔하고 서명하면 이 브라우저에서 로그인이 완료됩니다.",
walletGenerateQr: "QR 생성",
walletQrUseAnotherDevice: "다른 기기의 TokenPocket으로 스캔하세요.",
walletOpenTokenPocket: "TokenPocket 열기",
walletOpenMetaMask: "MetaMask 열기",
walletOpenImToken: "imToken 열기",
walletRainbowFallback: "MetaMask / imToken QR 대체",
walletRainbowFallbackDesc:
"MetaMask 또는 imToken QR이 필요하면 RainbowKit/Reown 연결을 사용하세요.",
walletOpenRainbow: "QR 로그인 열기",
walletNetworkWarning:
"이 대체 방식은 WalletConnect/Reown을 사용하므로 일부 중국 네트워크에서 불안정할 수 있습니다. 실패하면 지갑 DApp 브라우저에서 사이트를 여세요.",
walletSigning: "서명 중…",
walletTpExpired: "TokenPocket QR이 만료되었습니다. 새로 생성하세요.",
walletTpQrFailed: "TokenPocket QR을 생성할 수 없습니다.",
walletRainbowUnavailable: "QR 로그인을 사용할 수 없습니다.",
walletLoginSuccess: "지갑이 연결되었습니다",
walletLoginFailed: "지갑 로그인에 실패했습니다",
walletNoAccount:
"지갑에서 계정이 반환되지 않았습니다. 지갑 잠금을 해제하고 계정을 선택한 후 다시 시도하세요.",
walletDisconnected: "지갑 연결이 해제되었습니다",
walletOtherMethods: "다른 로그인 방법",
walletUseCurrent: "현재 지갑 사용",
walletOpening: "{wallet} 열는 중…",
walletAppNotInstalled: "열리지 않으면 앱이 설치되어 있지 않을 수 있습니다.",
walletDownloadApp: "{wallet} 다운로드",
walletRetry: "다시 시도",
walletConnecting: "연결 중…",
featureUnavailable: "준비 중", featureUnavailable: "준비 중",
featureUnavailableDesc: "이 기능은 아직 사용할 수 없습니다.", featureUnavailableDesc: "이 기능은 아직 사용할 수 없습니다.",
confirm: "확인", confirm: "확인",
backToHome: "홈으로", backToHome: "홈으로",
inAppDownloadTitle: "시스템 브라우저에서 열어 다운로드하세요",
inAppDownloadIntro:
"현재 앱 내 브라우저로는 파일을 다운로드할 수 없습니다. 아래 링크를 복사해서 시스템 브라우저에서 열면 바로 저장됩니다.",
inAppDownloadIntroNamed:
"{browser} 앱 내 브라우저로는 파일을 다운로드할 수 없습니다. 아래 링크를 복사해서 시스템 브라우저에서 열면 바로 저장됩니다.",
inAppDownloadStepCopy:
'아래의 "링크 복사"를 누릅니다(파일 직접 다운로드 주소입니다).',
inAppDownloadStepOpen:
"시스템 브라우저(Safari, Chrome 등)를 열고 주소창에 링크를 붙여 넣습니다.",
inAppDownloadStepDownload: "파일이 자동으로 다운로드 폴더에 저장됩니다.",
inAppDownloadCopied: "링크를 복사했습니다",
inAppDownloadCopyFail: "복사하지 못했습니다. 직접 복사해 주세요",
}; };

View File

@@ -144,12 +144,139 @@ export const msDict: Dict = {
adminSearchQuery: "Kata kunci", adminSearchQuery: "Kata kunci",
adminSearchTime: "Masa", adminSearchTime: "Masa",
adminSearchId: "ID", adminSearchId: "ID",
favoriteAdd: "Tambah ke kegemaran",
favoriteRemove: "Buang daripada kegemaran",
favoriteAdded: "Ditambah ke kegemaran",
favoriteRemoved: "Dibuang daripada kegemaran",
favoriteFailed: "Tidak dapat mengemas kini kegemaran",
favoriteLoginRequired: "Sambung dompet untuk menyimpan kegemaran",
favoritesLoginDesc:
"Sambung dompet untuk melihat dan mengurus sumber ARK yang disimpan.",
favoritesLibraryTitle: "Sumber disimpan",
favoritesEmptyTitle: "Belum ada kegemaran",
favoritesEmptyDesc:
"Lihat sumber dan tekan ikon hati untuk menyimpannya di sini.",
favoritesNoFilteredTitle: "Tiada kegemaran sepadan",
favoritesNoFilteredDesc: "Cuba ubah carian, kategori atau susunan.",
favoritesFilterAllCategories: "Semua kategori",
favoritesSortFavoritedAt: "Baru disimpan",
favoritesSortPublishedAt: "Terbaru diterbitkan",
favoritesSortHot: "Sumber popular",
favoritesSearchPlaceholder: "Cari kegemaran anda",
favoritesUnavailable: "Tidak tersedia",
postShownInOriginalLanguage:
"Pos ini tidak tersedia dalam bahasa pilihan anda. Memaparkan bahasa asal.",
favoritesClearFilters: "Kosongkan penapis",
favorites: "Kegemaran Saya", favorites: "Kegemaran Saya",
favoritesComingSoon: "Akan Hadir", favoritesComingSoon: "Akan Hadir",
favoritesComingSoonDesc: favoritesComingSoonDesc:
"Ciri log masuk dan kegemaran sedang dibangunkan. Nantikan.", "Ciri log masuk dan kegemaran sedang dibangunkan. Nantikan.",
close: "Tutup",
walletConnect: "Sambung Dompet",
walletConnectedAs: "Dompet disambungkan",
walletLoginAddress: "Alamat log masuk",
walletDisconnect: "Putuskan",
walletLoginTitle: "Sambung dompet",
walletLoginDesc:
"Tandatangani mesej untuk mengesahkan alamat dompet. Tiada transaksi atau gas.",
walletInjected: "Guna dompet pelayar",
walletInjectedDesc:
"Tandatangani dengan dompet yang tersedia dalam pelayar ini.",
walletNoBrowserWallet: "Tiada dompet pelayar dikesan",
walletNoBrowserWalletDesc:
"Pasang atau aktifkan sambungan dompet pelayar, seperti MetaMask.",
walletOpenWalletApp: "Buka aplikasi dompet",
walletOpenWalletAppDesc:
"Buka laman ini dalam aplikasi dompet anda, kemudian tandatangani untuk log masuk.",
walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask",
walletImToken: "imToken",
walletBack: "Kembali",
walletChooseMethod: "Pilih cara log masuk",
walletTokenPocketLogin: "Log masuk TokenPocket",
walletTpMobileDesc:
"Buka TokenPocket untuk menandatangani, kemudian kembali ke sini untuk selesai. Anda kekal dalam pelayar ini, bukan pelayar dalam aplikasi dompet.",
walletTpLoginBtn: "Log masuk dengan TokenPocket",
walletTpWaiting: "Menunggu tandatangan anda dalam TokenPocket…",
walletTpReopen: "Buka semula TokenPocket",
favoritesFilters: "Penapis",
favoriteSessionExpired: "Sesi anda telah tamat. Sila log masuk semula.",
loadFailed: "Gagal memuatkan kegemaran anda.",
walletChooseDesktop:
"Pilih dompet yang ingin anda gunakan. Pada desktop, pasang sambungan pelayar yang sepadan.",
walletChooseMobile: "Pilih aplikasi dompet untuk membuka laman ini.",
walletDesktopHint:
"Jika tiada dompet terbuka selepas klik, pastikan sambungan pelayar yang sepadan telah dipasang dan diaktifkan.",
walletInstallSelected:
"Tiada sambungan pelayar {wallet} dikesan. Pasang atau aktifkannya, kemudian cuba lagi.",
walletDesktopHelpTitle:
"Kami telah cuba membuka {wallet}. Langkah seterusnya:",
walletDesktopHelpUnlock:
"Buka sambungan dompet pada bar alat pelayar dan nyahkuncinya.",
walletDesktopHelpSelect:
"Pastikan dompet mempunyai akaun, kemudian pilih akaun tersebut.",
walletDesktopHelpRetry: "Kembali ke sini dan klik “Sambung semula {wallet}”.",
walletReconnectWallet: "Sambung semula {wallet}",
walletInstallWallet: "Pasang sambungan {wallet}",
walletConfirmAddressTitle: "Log masuk dengan alamat {wallet} ini?",
walletConfirmAddressDesc:
"Sahkan ini ialah alamat dompet {wallet} yang ingin anda gunakan.",
walletConfirmLogin: "Sahkan log masuk",
walletCancelLogin: "Batal",
walletDesktopImTokenTitle:
"Buka laman ini dalam aplikasi mudah alih {wallet}",
walletDesktopImTokenDesc:
"{wallet} tidak menyediakan sambungan pelayar desktop. Buka https://arkie-library-stag.com dalam aplikasi mudah alih {wallet} untuk log masuk.",
walletOpen: "Buka",
walletQrLogin: "Log masuk QR",
walletMobileQrDesc:
"Guna peranti lain untuk mengimbas kod QR ini dan log masuk pada pelayar ini.",
walletTokenPocketQr: "Log masuk QR TokenPocket",
walletTokenPocketQrDesc:
"Disyorkan untuk pengguna China. Imbas dengan TokenPocket dan tandatangani untuk log masuk pada pelayar ini.",
walletGenerateQr: "Jana QR",
walletQrUseAnotherDevice: "Imbas dengan TokenPocket pada peranti lain.",
walletOpenTokenPocket: "Buka TokenPocket",
walletOpenMetaMask: "Buka MetaMask",
walletOpenImToken: "Buka imToken",
walletRainbowFallback: "Sandaran QR MetaMask / imToken",
walletRainbowFallbackDesc:
"Gunakan RainbowKit/Reown jika perlu QR untuk MetaMask atau imToken.",
walletOpenRainbow: "Buka log masuk QR",
walletNetworkWarning:
"Kaedah sandaran ini menggunakan WalletConnect/Reown dan mungkin tidak stabil pada sesetengah rangkaian China. Jika gagal, buka laman ini dalam pelayar DApp dompet.",
walletSigning: "Menandatangani…",
walletTpExpired: "QR TokenPocket tamat tempoh. Sila jana semula.",
walletTpQrFailed: "Tidak dapat menjana QR TokenPocket.",
walletRainbowUnavailable: "Log masuk QR belum tersedia.",
walletLoginSuccess: "Dompet disambungkan",
walletLoginFailed: "Log masuk dompet gagal",
walletNoAccount:
"Dompet tidak mengembalikan sebarang akaun. Nyahkunci dompet, pilih akaun, kemudian cuba lagi.",
walletDisconnected: "Dompet diputuskan",
walletOtherMethods: "Kaedah log masuk lain",
walletUseCurrent: "Guna dompet semasa",
walletOpening: "Membuka {wallet}…",
walletAppNotInstalled:
"Jika tiada yang terbuka, aplikasi mungkin belum dipasang.",
walletDownloadApp: "Muat turun {wallet}",
walletRetry: "Cuba lagi",
walletConnecting: "Menyambung…",
featureUnavailable: "Belum tersedia", featureUnavailable: "Belum tersedia",
featureUnavailableDesc: "Ciri ini belum tersedia.", featureUnavailableDesc: "Ciri ini belum tersedia.",
confirm: "Faham", confirm: "Faham",
backToHome: "Kembali ke Laman Utama", backToHome: "Kembali ke Laman Utama",
inAppDownloadTitle: "Sila buka dalam pelayar sistem untuk muat turun",
inAppDownloadIntro:
"Pelayar dalam aplikasi semasa tidak dapat memuat turun fail. Salin pautan di bawah dan buka dalam pelayar sistem — fail akan disimpan terus.",
inAppDownloadIntroNamed:
"{browser} tidak dapat memuat turun fail secara langsung. Salin pautan di bawah dan buka dalam pelayar sistem — fail akan disimpan terus.",
inAppDownloadStepCopy:
'Ketik "Salin pautan" di bawah (ini adalah URL muat turun fail terus).',
inAppDownloadStepOpen:
"Buka pelayar sistem (Safari, Chrome, dsb.) dan tampal pautan ke bar alamat.",
inAppDownloadStepDownload:
"Fail akan dimuat turun secara automatik ke folder Muat Turun.",
inAppDownloadCopied: "Pautan disalin",
inAppDownloadCopyFail: "Tidak dapat menyalin, sila salin secara manual",
}; };

View File

@@ -144,12 +144,134 @@ export const viDict: Dict = {
adminSearchQuery: "Từ khóa", adminSearchQuery: "Từ khóa",
adminSearchTime: "Thời gian", adminSearchTime: "Thời gian",
adminSearchId: "ID", adminSearchId: "ID",
favoriteAdd: "Thêm vào yêu thích",
favoriteRemove: "Xóa khỏi yêu thích",
favoriteAdded: "Đã thêm vào yêu thích",
favoriteRemoved: "Đã xóa khỏi yêu thích",
favoriteFailed: "Không thể cập nhật yêu thích",
favoriteLoginRequired: "Kết nối ví để lưu yêu thích",
favoritesLoginDesc: "Kết nối ví để xem và quản lý tài nguyên ARK đã lưu.",
favoritesLibraryTitle: "Tài nguyên đã lưu",
favoritesEmptyTitle: "Chưa có mục yêu thích",
favoritesEmptyDesc:
"Duyệt tài nguyên và bấm biểu tượng trái tim để lưu tại đây.",
favoritesNoFilteredTitle: "Không có mục phù hợp",
favoritesNoFilteredDesc: "Hãy đổi từ khóa, danh mục hoặc cách sắp xếp.",
favoritesFilterAllCategories: "Tất cả danh mục",
favoritesSortFavoritedAt: "Lưu gần đây",
favoritesSortPublishedAt: "Mới xuất bản",
favoritesSortHot: "Tài nguyên hot",
favoritesSearchPlaceholder: "Tìm trong yêu thích",
favoritesUnavailable: "Không khả dụng",
postShownInOriginalLanguage:
"Bài đăng này không có trong ngôn ngữ bạn chọn. Đang hiển thị bằng ngôn ngữ gốc.",
favoritesClearFilters: "Xóa bộ lọc",
favorites: "Yêu thích của tôi", favorites: "Yêu thích của tôi",
favoritesComingSoon: "Sắp ra mắt", favoritesComingSoon: "Sắp ra mắt",
favoritesComingSoonDesc: favoritesComingSoonDesc:
"Tính năng đăng nhập và yêu thích đang phát triển. Hãy chờ đón.", "Tính năng đăng nhập và yêu thích đang phát triển. Hãy chờ đón.",
close: "Đóng",
walletConnect: "Kết nối ví",
walletConnectedAs: "Ví đã kết nối",
walletLoginAddress: "Địa chỉ đăng nhập",
walletDisconnect: "Ngắt kết nối",
walletLoginTitle: "Kết nối ví",
walletLoginDesc:
"Ký tin nhắn để xác minh địa chỉ ví. Không có giao dịch hay phí gas.",
walletInjected: "Dùng ví trình duyệt",
walletInjectedDesc: "Ký bằng ví đã có trong trình duyệt này.",
walletNoBrowserWallet: "Không tìm thấy ví trình duyệt",
walletNoBrowserWalletDesc:
"Cài đặt hoặc bật tiện ích mở rộng ví trình duyệt, chẳng hạn như MetaMask.",
walletOpenWalletApp: "Mở ứng dụng ví",
walletOpenWalletAppDesc:
"Mở trang này trong ứng dụng ví của bạn, sau đó ký để đăng nhập.",
walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask",
walletImToken: "imToken",
walletBack: "Quay lại",
walletChooseMethod: "Chọn cách đăng nhập",
walletTokenPocketLogin: "Đăng nhập TokenPocket",
walletTpMobileDesc:
"Mở TokenPocket để ký, rồi quay lại đây để hoàn tất. Bạn vẫn ở trong trình duyệt này thay vì trình duyệt trong ví.",
walletTpLoginBtn: "Đăng nhập bằng TokenPocket",
walletTpWaiting: "Đang chờ bạn ký trong TokenPocket…",
walletTpReopen: "Mở lại TokenPocket",
favoritesFilters: "Bộ lọc",
favoriteSessionExpired: "Phiên đăng nhập đã hết hạn. Vui lòng đăng nhập lại.",
loadFailed: "Không thể tải mục yêu thích của bạn.",
walletChooseDesktop:
"Chọn ví bạn muốn dùng. Trên máy tính, hãy cài tiện ích mở rộng trình duyệt tương ứng.",
walletChooseMobile: "Chọn ứng dụng ví để mở trang này.",
walletDesktopHint:
"Nếu không có ví nào mở sau khi nhấn, hãy đảm bảo tiện ích mở rộng tương ứng đã được cài đặt và bật.",
walletInstallSelected:
"Không tìm thấy tiện ích mở rộng {wallet}. Hãy cài đặt hoặc bật nó, rồi thử lại.",
walletDesktopHelpTitle: "Chúng tôi đã thử mở {wallet}. Các bước tiếp theo:",
walletDesktopHelpUnlock:
"Mở tiện ích ví trên thanh công cụ trình duyệt và mở khóa ví.",
walletDesktopHelpSelect: "Đảm bảo ví có tài khoản, sau đó chọn tài khoản đó.",
walletDesktopHelpRetry: "Quay lại đây và nhấn “Kết nối lại {wallet}”.",
walletReconnectWallet: "Kết nối lại {wallet}",
walletInstallWallet: "Cài tiện ích {wallet}",
walletConfirmAddressTitle: "Đăng nhập bằng địa chỉ {wallet} này?",
walletConfirmAddressDesc:
"Vui lòng xác nhận đây là địa chỉ ví {wallet} bạn muốn dùng.",
walletConfirmLogin: "Xác nhận đăng nhập",
walletCancelLogin: "Hủy",
walletDesktopImTokenTitle:
"Mở trang này trong ứng dụng {wallet} trên điện thoại",
walletDesktopImTokenDesc:
"{wallet} không có tiện ích mở rộng trình duyệt cho máy tính. Hãy mở https://arkie-library-stag.com trong ứng dụng {wallet} trên điện thoại để đăng nhập.",
walletOpen: "Mở",
walletQrLogin: "Đăng nhập QR",
walletMobileQrDesc:
"Dùng thiết bị khác quét mã QR này để đăng nhập trên trình duyệt này.",
walletTokenPocketQr: "Đăng nhập QR TokenPocket",
walletTokenPocketQrDesc:
"Khuyến nghị cho người dùng Trung Quốc. Quét bằng TokenPocket và ký để đăng nhập trên trình duyệt này.",
walletGenerateQr: "Tạo QR",
walletQrUseAnotherDevice: "Quét bằng TokenPocket trên thiết bị khác.",
walletOpenTokenPocket: "Mở TokenPocket",
walletOpenMetaMask: "Mở MetaMask",
walletOpenImToken: "Mở imToken",
walletRainbowFallback: "QR dự phòng MetaMask / imToken",
walletRainbowFallbackDesc:
"Dùng RainbowKit/Reown nếu cần QR cho MetaMask hoặc imToken.",
walletOpenRainbow: "Mở đăng nhập QR",
walletNetworkWarning:
"Cách dự phòng này dùng WalletConnect/Reown và có thể không ổn định trên một số mạng ở Trung Quốc. Nếu lỗi, hãy mở trang trong trình duyệt DApp của ví.",
walletSigning: "Đang ký…",
walletTpExpired: "QR TokenPocket đã hết hạn. Vui lòng tạo lại.",
walletTpQrFailed: "Không thể tạo QR TokenPocket.",
walletRainbowUnavailable: "Đăng nhập QR chưa khả dụng.",
walletLoginSuccess: "Đã kết nối ví",
walletLoginFailed: "Đăng nhập ví thất bại",
walletNoAccount:
"Ví không trả về tài khoản nào. Hãy mở khóa ví, chọn một tài khoản rồi thử lại.",
walletDisconnected: "Đã ngắt kết nối ví",
walletOtherMethods: "Phương thức đăng nhập khác",
walletUseCurrent: "Dùng ví hiện tại",
walletOpening: "Đang mở {wallet}…",
walletAppNotInstalled:
"Nếu không có gì mở, ứng dụng có thể chưa được cài đặt.",
walletDownloadApp: "Tải {wallet}",
walletRetry: "Thử lại",
walletConnecting: "Đang kết nối…",
featureUnavailable: "Chưa khả dụng", featureUnavailable: "Chưa khả dụng",
featureUnavailableDesc: "Tính năng này hiện chưa khả dụng.", featureUnavailableDesc: "Tính năng này hiện chưa khả dụng.",
confirm: "Đã hiểu", confirm: "Đã hiểu",
backToHome: "Về trang chủ", backToHome: "Về trang chủ",
inAppDownloadTitle: "Vui lòng mở bằng trình duyệt hệ thống để tải",
inAppDownloadIntro:
"Trình duyệt trong ứng dụng hiện tại không thể tải tệp. Sao chép liên kết bên dưới và mở trong trình duyệt hệ thống — tệp sẽ tự động tải về.",
inAppDownloadIntroNamed:
"{browser} không thể tải tệp trực tiếp. Sao chép liên kết bên dưới và mở trong trình duyệt hệ thống — tệp sẽ tự động tải về.",
inAppDownloadStepCopy:
'Nhấn "Sao chép liên kết" bên dưới (đây là URL tải tệp trực tiếp).',
inAppDownloadStepOpen:
"Mở trình duyệt hệ thống (Safari, Chrome…) và dán liên kết vào thanh địa chỉ.",
inAppDownloadStepDownload: "Tệp sẽ tự động tải xuống thư mục Tải về.",
inAppDownloadCopied: "Đã sao chép liên kết",
inAppDownloadCopyFail: "Không sao chép được, vui lòng tự sao chép",
}; };

View File

@@ -141,11 +141,119 @@ export const zhDict: Dict = {
adminSearchQuery: "查询词", adminSearchQuery: "查询词",
adminSearchTime: "时间", adminSearchTime: "时间",
adminSearchId: "编号", adminSearchId: "编号",
favoriteAdd: "加入收藏",
favoriteRemove: "取消收藏",
favoriteAdded: "已加入收藏",
favoriteRemoved: "已取消收藏",
favoriteFailed: "无法更新收藏",
favoriteLoginRequired: "请先连接钱包再收藏",
favoritesLoginDesc: "连接钱包后即可查看和管理你收藏的 ARK 资料。",
favoritesLibraryTitle: "已收藏资料",
favoritesEmptyTitle: "还没有收藏",
favoritesEmptyDesc: "浏览资料时点击爱心,就可以把常用内容保存到这里。",
favoritesNoFilteredTitle: "没有符合条件的收藏",
favoritesNoFilteredDesc: "试着调整搜索、分类或排序条件。",
favoritesFilterAllCategories: "全部分类",
favoritesSortFavoritedAt: "最近收藏",
favoritesSortPublishedAt: "最新发布",
favoritesSortHot: "热门资料",
favoritesSearchPlaceholder: "搜索我的收藏",
favoritesUnavailable: "已下架",
postShownInOriginalLanguage: "该贴子暂未提供当前语言版本,以原语言显示。",
favoritesClearFilters: "清除筛选",
favorites: "我的收藏", favorites: "我的收藏",
favoritesComingSoon: "功能即将推出", favoritesComingSoon: "功能即将推出",
favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。", favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。",
close: "关闭",
walletConnect: "连接钱包",
walletConnectedAs: "已连接钱包",
walletLoginAddress: "登录地址",
walletDisconnect: "断开连接",
walletLoginTitle: "连接钱包",
walletLoginDesc: "签名验证钱包地址,不会发起交易,也不需要 Gas。",
walletInjected: "使用浏览器钱包登录",
walletInjectedDesc: "签名验证当前浏览器里的钱包。",
walletNoBrowserWallet: "未检测到浏览器钱包",
walletNoBrowserWalletDesc: "请安装或启用浏览器钱包插件,例如 MetaMask。",
walletOpenWalletApp: "打开钱包 App",
walletOpenWalletAppDesc: "请在钱包 App 中打开本站后签名登录。",
walletTokenPocket: "TokenPocket",
walletMetaMask: "MetaMask",
walletImToken: "imToken",
walletBack: "返回",
walletChooseMethod: "选择登录方式",
walletTokenPocketLogin: "TokenPocket 登录",
walletTpMobileDesc:
"在 TokenPocket 中签名后会自动返回本页面完成登录,留在当前浏览器,不会跳进钱包内置浏览器。",
walletTpLoginBtn: "使用 TokenPocket 登录",
walletTpWaiting: "等待你在 TokenPocket 中完成签名…",
walletTpReopen: "重新打开 TokenPocket",
favoritesFilters: "筛选",
favoriteSessionExpired: "登录已过期,请重新登录。",
loadFailed: "无法加载收藏,请稍后重试。",
walletChooseDesktop: "选择你要使用的钱包。电脑端需要先安装对应浏览器插件。",
walletChooseMobile: "选择钱包 App 打开本站。",
walletDesktopHint:
"如果点击后没有弹出钱包,请确认已安装并启用对应的钱包浏览器插件。",
walletInstallSelected: "未检测到 {wallet} 浏览器插件,请先安装或启用后再试。",
walletDesktopHelpTitle: "已尝试打开 {wallet},接下来请这样操作:",
walletDesktopHelpUnlock: "打开浏览器右上角的钱包插件,并解锁钱包。",
walletDesktopHelpSelect: "确认钱包里已有账号,并选择一个账号。",
walletDesktopHelpRetry: "回到这里点击“重新连接 {wallet}”。",
walletReconnectWallet: "重新连接 {wallet}",
walletInstallWallet: "安装 {wallet} 插件",
walletConfirmAddressTitle: "确认使用这个 {wallet} 地址登录?",
walletConfirmAddressDesc: "请确认这是你要使用的 {wallet} 钱包地址。",
walletConfirmLogin: "确认登录",
walletCancelLogin: "取消",
walletDesktopImTokenTitle: "请用手机 {wallet} App 打开本站",
walletDesktopImTokenDesc:
"{wallet} 没有电脑浏览器插件。请在手机 {wallet} App 内打开 https://arkie-library-stag.com 后登录。",
walletOpen: "打开",
walletQrLogin: "扫码登录",
walletMobileQrDesc: "适合用另一台设备扫描二维码登录当前浏览器。",
walletTokenPocketQr: "TokenPocket 扫码登录",
walletTokenPocketQrDesc:
"推荐中国用户使用。用 TokenPocket 扫码签名后,会回到当前浏览器完成登录。",
walletGenerateQr: "生成二维码",
walletQrUseAnotherDevice: "请用另一台设备上的 TokenPocket 扫码。",
walletOpenTokenPocket: "打开 TokenPocket",
walletOpenMetaMask: "打开 MetaMask",
walletOpenImToken: "打开 imToken",
walletRainbowFallback: "MetaMask / imToken 扫码备用",
walletRainbowFallbackDesc:
"如果需要 MetaMask 或 imToken 扫码,可使用 RainbowKit/Reown 连接。",
walletOpenRainbow: "打开扫码登录",
walletNetworkWarning:
"此备用方式依赖 WalletConnect/Reown在部分中国网络可能不稳定。失败时请用钱包内置浏览器打开本站。",
walletSigning: "签名中…",
walletTpExpired: "TokenPocket 二维码已过期,请重新生成。",
walletTpQrFailed: "无法生成 TokenPocket 二维码。",
walletRainbowUnavailable: "扫码登录暂不可用。",
walletLoginSuccess: "钱包已连接",
walletLoginFailed: "钱包登录失败",
walletNoAccount: "钱包没有返回账号。请先解锁钱包并选择一个账号后重试。",
walletDisconnected: "钱包已断开",
walletOtherMethods: "其他登录方式",
walletUseCurrent: "使用当前钱包登录",
walletOpening: "正在打开 {wallet}…",
walletAppNotInstalled: "如果没有跳转,可能是未安装该 App。",
walletDownloadApp: "下载 {wallet}",
walletRetry: "重试",
walletConnecting: "连接中…",
featureUnavailable: "未开放", featureUnavailable: "未开放",
featureUnavailableDesc: "该功能暂未开放。", featureUnavailableDesc: "该功能暂未开放。",
confirm: "知道了", confirm: "知道了",
backToHome: "返回首页", backToHome: "返回首页",
inAppDownloadTitle: "请使用系统浏览器打开后下载",
inAppDownloadIntro:
"当前内置浏览器无法下载文件。复制下方链接,到系统浏览器打开即可直接下载。",
inAppDownloadIntroNamed:
"{browser} 内置浏览器无法下载文件。复制下方链接,到系统浏览器打开即可直接下载。",
inAppDownloadStepCopy: "点击下方「复制链接」(这是文件的直接下载地址)。",
inAppDownloadStepOpen:
"打开系统浏览器Safari、Chrome 等),把链接粘贴到地址栏。",
inAppDownloadStepDownload: "文件会自动开始下载到下载文件夹。",
inAppDownloadCopied: "链接已复制",
inAppDownloadCopyFail: "复制失败,请手动复制",
}; };

View File

@@ -1,43 +1,214 @@
import { Heart } from "lucide-react"; import { Heart, RotateCcw } from "lucide-react";
import { Link } from "react-router-dom"; import { useEffect, useState } from "react";
import { useI18n } from "../../i18n"; import { getJSON, itemsOrEmpty, readJSONCache, type Category } from "../../api";
import { homePathForLang } from "../../languageRoutes"; import { isFavoritesAuthError, listFavorites } from "../../favorites/api";
import { useFavorites } from "../../favorites/FavoritesProvider";
import { langQuery, useI18n, type Lang } from "../../i18n";
import { Reveal } from "../../motion"; import { Reveal } from "../../motion";
import { PopularRankRow } from "../../components/PopularRankList";
import { useSetPageTitle } from "../../components/PageTitleContext"; import { useSetPageTitle } from "../../components/PageTitleContext";
import { Skeleton } from "../../components/Skeleton";
import { useWallet } from "../../wallet/WalletProvider";
const pageSize = 50;
type FavoritePosts = Awaited<ReturnType<typeof listFavorites>>["items"];
type FavoriteListCache = {
address: string;
lang: Lang;
mutationVersion: number;
posts: FavoritePosts;
};
let favoriteListCache: FavoriteListCache | null = null;
function useCategories(lang: Lang): Category[] {
const [categories, setCategories] = useState<Category[]>(() => {
const cached = readJSONCache<Category[]>(
`/api/categories?lang=${encodeURIComponent(langQuery(lang))}`,
);
return cached ? itemsOrEmpty(cached) : [];
});
useEffect(() => {
const url = `/api/categories?lang=${encodeURIComponent(langQuery(lang))}`;
const cached = readJSONCache<Category[]>(url);
if (cached) setCategories(itemsOrEmpty(cached));
let cancelled = false;
getJSON<Category[]>(url)
.then((items) => {
if (!cancelled) setCategories(itemsOrEmpty(items));
})
.catch(() => {
if (!cancelled && !cached) setCategories([]);
});
return () => {
cancelled = true;
};
}, [lang]);
return categories;
}
export default function Favorites() { export default function Favorites() {
const { lang, t } = useI18n(); const { lang, t } = useI18n();
// Show "我的收藏" in the global header, consistent with the other pages. const wallet = useWallet();
const { markFavorite, mutationVersion } = useFavorites();
const categories = useCategories(lang);
const [posts, setPosts] = useState<FavoritePosts>([]);
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState("");
const [reloadKey, setReloadKey] = useState(0);
useSetPageTitle(t("favorites")); useSetPageTitle(t("favorites"));
useEffect(() => {
if (!wallet.token || wallet.status !== "loggedIn" || !wallet.address) {
setPosts([]);
setLoading(false);
setLoaded(false);
setError("");
return;
}
const walletAddress = wallet.address;
const walletToken = wallet.token;
if (
reloadKey === 0 &&
favoriteListCache?.address === walletAddress &&
favoriteListCache.lang === lang &&
favoriteListCache.mutationVersion === mutationVersion
) {
setPosts(favoriteListCache.posts);
setLoading(false);
setLoaded(true);
setError("");
return;
}
let cancelled = false;
setLoading(true);
setLoaded(false);
setError("");
listFavorites(walletToken, {
limit: pageSize,
includeUnavailable: true,
})
.then((data) => {
if (cancelled) return;
const items = itemsOrEmpty(data.items);
favoriteListCache = {
address: walletAddress,
lang,
mutationVersion,
posts: items,
};
setPosts(items);
items.forEach((post) => markFavorite(post.id, true));
setLoaded(true);
})
.catch((err) => {
if (cancelled) return;
if (isFavoritesAuthError(err)) {
wallet.logout();
wallet.openLoginModal();
return;
}
setError(err instanceof Error ? err.message : t("loadFailed"));
setLoaded(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [lang, markFavorite, mutationVersion, reloadKey, t, wallet]);
if (wallet.status === "loading") {
return (
<Reveal className="mx-auto grid w-full max-w-[980px] gap-3 overflow-x-clip px-0 py-2 md:py-4">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-[132px] rounded-2xl" />
))}
</Reveal>
);
}
if (wallet.status !== "loggedIn") {
return (
<Reveal className="flex min-h-[60vh] flex-col items-center justify-center gap-5 px-4 py-12 text-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full border border-ark-gold/30 bg-ark-gold/5">
<Heart className="h-10 w-10 text-ark-gold/70" strokeWidth={1.8} />
</div>
<h1 className="text-2xl font-semibold text-neutral-100 md:text-3xl">
{t("favorites")}
</h1>
<p className="max-w-md text-sm leading-relaxed text-neutral-400 md:text-base">
{t("favoritesLoginDesc")}
</p>
<button
type="button"
onClick={wallet.openLoginModal}
className="mt-2 inline-flex h-11 items-center justify-center rounded-full bg-ark-gold px-6 text-sm font-bold text-black transition hover:bg-ark-gold2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
>
{t("walletConnect")}
</button>
</Reveal>
);
}
return ( return (
<Reveal className="flex min-h-[60vh] flex-col items-center justify-center gap-5 px-4 py-12 text-center"> <div className="mx-auto flex w-full max-w-full flex-col gap-2.5 overflow-x-clip py-2 md:max-w-[680px] md:gap-3 md:py-4 lg:max-w-[900px] xl:max-w-[1120px]">
<div className="flex h-20 w-20 items-center justify-center rounded-full border border-ark-gold/30 bg-ark-gold/5"> {loading || !loaded ? (
<Heart Array.from({ length: 4 }).map((_, index) => (
className="h-10 w-10 text-ark-gold/70" <Skeleton key={index} className="h-[132px] rounded-2xl" />
strokeWidth={1.8} ))
aria-hidden ) : error ? (
/> <div className="flex flex-col items-start gap-3 rounded-2xl border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-200">
</div> <p>{error}</p>
<button
<h1 className="text-2xl font-semibold text-neutral-100 md:text-3xl"> type="button"
{t("favorites")} onClick={() => setReloadKey((value) => value + 1)}
</h1> className="inline-flex items-center gap-1.5 rounded-full border border-red-400/40 px-3 py-1.5 text-xs font-semibold text-red-100 transition hover:bg-red-500/20"
>
<p className="text-base font-medium text-ark-gold2 md:text-lg"> <RotateCcw className="h-3.5 w-3.5" />
{t("favoritesComingSoon")} {t("walletRetry")}
</p> </button>
</div>
<p className="max-w-md text-sm leading-relaxed text-neutral-400 md:text-base"> ) : posts.length === 0 ? (
{t("favoritesComingSoonDesc")} <div className="flex min-h-[280px] flex-col items-center justify-center gap-4 rounded-3xl border border-white/10 bg-[#17171d] p-8 text-center">
</p> <Heart className="h-10 w-10 text-ark-gold/60" strokeWidth={1.8} />
<h2 className="text-xl font-semibold text-white">
<Link {t("favoritesEmptyTitle")}
to={homePathForLang(lang)} </h2>
className="mt-4 inline-flex h-11 items-center justify-center rounded-full border border-ark-gold/60 bg-ark-gold/10 px-6 text-sm font-medium text-ark-gold transition hover:bg-ark-gold/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg" <p className="max-w-md text-sm leading-6 text-neutral-400">
> {t("favoritesEmptyDesc")}
{t("backToHome")} </p>
</Link> </div>
</Reveal> ) : (
posts.map((post, index) => (
<Reveal key={post.id} delay={Math.min(index, 8) * 0.05}>
<PopularRankRow
post={post}
index={index}
categories={categories}
browseSort=""
showRank={false}
showDownload={false}
linkToResource
onFavoriteChange={(_, favorited) => {
if (!favorited) setReloadKey((value) => value + 1);
}}
/>
</Reveal>
))
)}
</div>
); );
} }

View File

@@ -7,6 +7,7 @@ import { FigmaBanner } from "../../components/FigmaBanner";
import { PopularRankList } from "../../components/PopularRankList"; import { PopularRankList } from "../../components/PopularRankList";
import { RecommendedCard } from "../../components/RecommendedCard"; import { RecommendedCard } from "../../components/RecommendedCard";
import { SectionHeader } from "../../components/SectionHeader"; import { SectionHeader } from "../../components/SectionHeader";
import { LatestUpdateCard } from "../../components/LatestUpdateCard";
import { MessageBubble } from "../../components/messageStream/MessageBubble"; import { MessageBubble } from "../../components/messageStream/MessageBubble";
import { langQuery, useI18n } from "../../i18n"; import { langQuery, useI18n } from "../../i18n";
import { useLocalizedPath } from "../../useLocalizedPath"; import { useLocalizedPath } from "../../useLocalizedPath";
@@ -50,28 +51,27 @@ type LatestPostColumnItem = {
}; };
function estimateLatestPostHeight(post: Post): number { function estimateLatestPostHeight(post: Post): number {
const textLength = (post.text ?? post.title ?? "").length;
const textRows = Math.ceil(textLength / 72);
const textHeight = Math.min(180, Math.max(0, textRows * 22));
const previewHeight = post.linkPreview ? 132 : 0;
const [firstAttachment] = post.attachments; const [firstAttachment] = post.attachments;
const textLength = (post.text ?? post.title ?? "").length;
const textRows = Math.max(1, Math.ceil(textLength / 26));
const textHeight = textRows * 24;
const footerHeight = 64;
if (!firstAttachment) return 72 + textHeight + previewHeight; if (!firstAttachment) return textHeight + footerHeight + 24;
if (post.attachments.length >= 2) { const isVisual =
const mediaHeight = firstAttachment.kind === "video" ? 340 : 300; firstAttachment.kind === "image" ||
return mediaHeight + textHeight + previewHeight + 42; firstAttachment.kind === "video" ||
} firstAttachment.mime.startsWith("image/") ||
firstAttachment.mime.startsWith("video/");
if (!isVisual) return 52 + textHeight + footerHeight + 28;
if (firstAttachment.kind === "video") { const mediaHeight =
return 300 + textHeight + previewHeight + 42; firstAttachment.kind === "video" ||
} firstAttachment.mime.startsWith("video/")
? 180
if (firstAttachment.kind === "image") { : 230;
return (post.text ? 300 : 260) + textHeight + previewHeight + 42; return mediaHeight + textHeight + footerHeight + 12;
}
return 96 + post.attachments.length * 72 + textHeight + previewHeight;
} }
function splitLatestPostsIntoColumns( function splitLatestPostsIntoColumns(
@@ -548,7 +548,7 @@ export function Home() {
<Reveal> <Reveal>
<section id="latest" className="scroll-mt-16 md:scroll-mt-24"> <section id="latest" className="scroll-mt-16 md:scroll-mt-24">
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]"> <div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1280px]">
<div className="px-4 md:px-0"> <div className="px-4 md:px-0">
<SectionHeader <SectionHeader
title={t("latestSection")} title={t("latestSection")}
@@ -565,7 +565,7 @@ export function Home() {
</div> </div>
{/* Desktop: explicit balanced columns avoid the uneven gaps that {/* Desktop: explicit balanced columns avoid the uneven gaps that
CSS multi-column masonry can create with variable-height cards. */} CSS multi-column masonry can create with variable-height cards. */}
<div className="mt-7 hidden gap-4 px-4 md:grid md:grid-cols-2 lg:grid-cols-3 lg:px-0"> <div className="mt-7 hidden gap-4 px-4 md:grid md:grid-cols-2 lg:grid-cols-3 lg:px-0 xl:grid-cols-[repeat(3,416px)] xl:justify-center">
{latestDesktopColumns.map((column, columnIndex) => ( {latestDesktopColumns.map((column, columnIndex) => (
<div <div
key={`latest-desktop-column-${columnIndex}`} key={`latest-desktop-column-${columnIndex}`}
@@ -576,7 +576,7 @@ export function Home() {
key={post.id} key={post.id}
delay={Math.min(originalIndex, 8) * 0.05} delay={Math.min(originalIndex, 8) * 0.05}
> >
<MessageBubble post={post} fluid /> <LatestUpdateCard post={post} />
</Reveal> </Reveal>
))} ))}
</div> </div>

View File

@@ -1,45 +1,105 @@
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { getJSON } from "../../api"; import { getJSON } from "../../api";
import { langQuery, useI18n } from "../../i18n"; import { langQuery, useI18n, type Lang } from "../../i18n";
import { useLocalizedPath } from "../../useLocalizedPath"; import { useLocalizedPath } from "../../useLocalizedPath";
import { localizePath } from "../../languageRoutes";
import { MOCK_POSTS } from "../../mocks/mockPosts"; import { MOCK_POSTS } from "../../mocks/mockPosts";
import { POST_STREAM_USES_MOCK } from "../../components/messageStream/hooks/usePostStream"; import { POST_STREAM_USES_MOCK } from "../../components/messageStream/hooks/usePostStream";
import { useToast } from "../../components/Toast";
import { Skeleton } from "../../components/Skeleton";
import type { Post } from "../../types/post"; import type { Post } from "../../types/post";
function postLangToUiLang(code: string | undefined): Lang | null {
if (!code) return null;
const lc = code.trim().toLowerCase();
if (lc === "zh" || lc === "zh-cn" || lc === "zh-hans") return "zh-CN";
if (lc === "en") return "en";
if (lc === "ja") return "ja";
if (lc === "ko") return "ko";
if (lc === "vi") return "vi";
if (lc === "id") return "id";
if (lc === "ms") return "ms";
return null;
}
export function PostRedirect() { export function PostRedirect() {
const { id } = useParams(); const { id } = useParams();
const { lang } = useI18n(); const [sp] = useSearchParams();
const singleMode = sp.get("single") === "1";
const { lang, t } = useI18n();
const navigate = useNavigate(); const navigate = useNavigate();
const lp = useLocalizedPath(); const lp = useLocalizedPath();
const { showToast } = useToast();
const handledIdRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
if (!id) { if (!id) {
navigate(lp("/browse"), { replace: true }); navigate(lp("/browse"), { replace: true });
return; return;
} }
if (handledIdRef.current === id) return;
handledIdRef.current = id;
if (POST_STREAM_USES_MOCK) { if (POST_STREAM_USES_MOCK) {
const post = MOCK_POSTS.find((p) => p.id === id); const post = MOCK_POSTS.find((p) => p.id === id);
const suffix = singleMode ? "&single=1" : "";
navigate( navigate(
lp(post ? `/browse?post=${encodeURIComponent(post.id)}` : "/browse"), lp(
{ post
replace: true, ? `/browse?post=${encodeURIComponent(post.id)}${suffix}`
}, : "/browse",
),
{ replace: true },
); );
return; return;
} }
const goToPostInLang = (post: Post, targetLang: Lang) => {
const suffix = singleMode ? "&single=1" : "";
navigate(
localizePath(
`/browse?post=${encodeURIComponent(post.id)}${suffix}`,
targetLang,
),
{ replace: true },
);
};
getJSON<Post>( getJSON<Post>(
`/api/posts/${id}?lang=${encodeURIComponent(langQuery(lang))}`, `/api/posts/${id}?lang=${encodeURIComponent(langQuery(lang))}`,
) )
.then((post) => { .then((post) => goToPostInLang(post, lang))
navigate(lp(`/browse?post=${encodeURIComponent(post.id)}`), { .catch(() => {
replace: true, getJSON<Post>(`/api/posts/${id}`)
}); .then((post) => {
}) const sourceLang =
.catch(() => navigate(lp("/browse"), { replace: true })); postLangToUiLang(post.sourceLanguage) ||
}, [id, lang, navigate, lp]); postLangToUiLang(post.language) ||
lang;
showToast(t("postShownInOriginalLanguage"));
goToPostInLang(post, sourceLang);
})
.catch(() => navigate(lp("/browse"), { replace: true }));
});
}, [id, lang, navigate, lp, showToast, singleMode, t]);
return <div className="text-neutral-400"></div>; return (
<div
aria-live="polite"
aria-label={t("loading")}
className="mx-auto flex max-w-[1180px] flex-col gap-3 px-4 pt-4 md:px-0 md:pt-2"
>
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="mx-auto w-full max-w-[358px] md:max-w-[680px] lg:max-w-[900px] xl:max-w-[1120px]"
>
<Skeleton
className={`rounded-2xl ${i % 3 === 0 ? "h-[220px]" : "h-[80px]"}`}
/>
</div>
))}
</div>
);
} }

43
src/utils/inAppBrowser.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* Detect popular in-app WebViews that block file downloads or ignore
* `Content-Disposition: attachment`, so the UI can show an "open in external
* browser" guide instead of silently opening the file inline.
*/
function ua(): string {
if (typeof navigator === "undefined") return "";
return navigator.userAgent || "";
}
const PATTERNS: Array<{ re: RegExp; name: string }> = [
{ re: /MicroMessenger/i, name: "WeChat" },
{ re: /TokenPocket/i, name: "TokenPocket" },
{ re: /imToken/i, name: "imToken" },
{ re: /Trust(Wallet|Browser)/i, name: "Trust Wallet" },
{ re: /MetaMask/i, name: "MetaMask" },
{ re: /Telegram/i, name: "Telegram" },
{ re: /\bLine\//i, name: "LINE" },
{ re: /FBAN|FBAV/i, name: "Facebook" },
{ re: /Instagram/i, name: "Instagram" },
{ re: /Twitter|\bX\//i, name: "Twitter/X" },
{ re: /Weibo/i, name: "Weibo" },
{ re: /MQQBrowser/i, name: "QQ Browser" },
{ re: /\bQQ\//i, name: "QQ" },
{ re: /MiuiBrowser/i, name: "Mi Browser" },
{ re: /Snapchat/i, name: "Snapchat" },
];
export function isInAppBrowser(): boolean {
const agent = ua();
if (!agent) return false;
return PATTERNS.some(({ re }) => re.test(agent));
}
export function inAppBrowserName(): string | null {
const agent = ua();
if (!agent) return null;
for (const { re, name } of PATTERNS) {
if (re.test(agent)) return name;
}
return null;
}

View File

@@ -46,7 +46,7 @@ export function postToResource(
title, title,
description: postDisplayText(post, lang), description: postDisplayText(post, lang),
type: inferType(post, first), type: inferType(post, first),
language: post.language, language: post.sourceLanguage || post.language,
categoryId: post.categoryId, categoryId: post.categoryId,
categorySlug: post.categorySlug, categorySlug: post.categorySlug,
categoryName: category?.name || post.categorySlug, categoryName: category?.name || post.categorySlug,

1
src/vite-env.d.ts vendored
View File

@@ -4,6 +4,7 @@ interface ImportMetaEnv {
readonly VITE_API_URL: string; readonly VITE_API_URL: string;
readonly VITE_API_PREFIX?: string; readonly VITE_API_PREFIX?: string;
readonly VITE_ADMIN_UI_PREFIX?: string; readonly VITE_ADMIN_UI_PREFIX?: string;
readonly VITE_WALLETCONNECT_PROJECT_ID?: string;
/** When `"true"`, bundle admin UI only (no public pages); use with `VITE_ADMIN_UI_PREFIX` or default secret prefix. */ /** When `"true"`, bundle admin UI only (no public pages); use with `VITE_ADMIN_UI_PREFIX` or default secret prefix. */
readonly VITE_ADMIN_ONLY?: string; readonly VITE_ADMIN_ONLY?: string;
} }

View File

@@ -0,0 +1,83 @@
import { useEffect } from "react";
import {
connectInjectedWallet,
getInjectedWallet,
type WalletKind,
} from "./injected";
import { useWallet } from "./WalletProvider";
const AUTO_LOGIN_PARAMS = ["autoLogin", "autologin"];
const ETHEREUM_WAIT_MS = 8000;
const ETHEREUM_POLL_MS = 200;
function parseKind(value: string | null): WalletKind | null {
if (value === "tokenPocket" || value === "metaMask" || value === "imToken") {
return value;
}
return null;
}
function autoLoginKindFromParams(params: URLSearchParams): WalletKind | null {
for (const key of AUTO_LOGIN_PARAMS) {
const kind = parseKind(params.get(key));
if (kind) return kind;
}
return null;
}
function stripAutoLoginParam(): void {
const url = new URL(window.location.href);
for (const key of AUTO_LOGIN_PARAMS) url.searchParams.delete(key);
const qs = url.searchParams.toString();
const next = url.pathname + (qs ? `?${qs}` : "") + url.hash;
window.history.replaceState({}, "", next);
}
function waitForInjected(kind: WalletKind): Promise<boolean> {
return new Promise((resolve) => {
const start = Date.now();
const tick = () => {
if (getInjectedWallet(kind)) {
resolve(true);
return;
}
if (Date.now() - start >= ETHEREUM_WAIT_MS) {
resolve(false);
return;
}
window.setTimeout(tick, ETHEREUM_POLL_MS);
};
tick();
});
}
export function AutoInjectedLogin() {
const { loginAddress, status } = useWallet();
useEffect(() => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
const kind = autoLoginKindFromParams(params);
if (!kind) return;
stripAutoLoginParam();
if (status === "loggedIn") return;
let cancelled = false;
void waitForInjected(kind).then(async (ready) => {
if (cancelled || !ready) return;
try {
const address = await connectInjectedWallet(kind);
if (cancelled) return;
await loginAddress(address);
} catch (err) {
console.warn("[wallet-autologin] failed", err);
}
});
return () => {
cancelled = true;
};
}, []);
return null;
}

View File

@@ -0,0 +1,73 @@
import "@rainbow-me/rainbowkit/styles.css";
import {
RainbowKitProvider,
connectorsForWallets,
darkTheme,
} from "@rainbow-me/rainbowkit";
import {
imTokenWallet,
tokenPocketWallet,
} from "@rainbow-me/rainbowkit/wallets";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
import { http, createConfig, WagmiProvider, useReconnect } from "wagmi";
import { bsc } from "wagmi/chains";
const projectId =
import.meta.env.VITE_WALLETCONNECT_PROJECT_ID || "ark-library-dev-only";
const connectors = connectorsForWallets(
[
{
groupName: "ARK Library",
wallets: [imTokenWallet, tokenPocketWallet],
},
],
{
appName: "ARK Library",
projectId,
},
);
export const wagmiConfig = createConfig({
chains: [bsc],
connectors,
ssr: false,
transports: {
[bsc.id]: http(),
},
});
function WalletReconnectOnMount() {
useReconnect();
return null;
}
export function RainbowWalletProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return (
<WagmiProvider config={wagmiConfig}>
<QueryClientProvider client={queryClient}>
<WalletReconnectOnMount />
<RainbowKitProvider
modalSize="compact"
theme={darkTheme({
accentColor: "#d7b46a",
accentColorForeground: "#08070c",
borderRadius: "large",
fontStack: "system",
overlayBlur: "small",
})}
>
{children}
</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
);
}
export function hasWalletConnectProjectId(): boolean {
return Boolean(import.meta.env.VITE_WALLETCONNECT_PROJECT_ID);
}

View File

@@ -0,0 +1,28 @@
import type { WalletKind } from "./injected";
const logos: Partial<Record<WalletKind, string>> = {
tokenPocket: "/assets/ark-library/wallets/tokenpocket.svg",
imToken: "/assets/ark-library/wallets/imtoken.svg",
};
export function WalletBrandIcon({
kind,
size = 28,
}: {
kind: WalletKind;
size?: number;
}) {
const logo = logos[kind];
if (!logo) return null;
return (
<img
src={logo}
alt=""
aria-hidden="true"
width={size}
height={size}
className="inline-flex shrink-0 rounded-lg"
/>
);
}

136
src/wallet/WalletButton.tsx Normal file
View File

@@ -0,0 +1,136 @@
import { Heart, LogOut } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { WalletIcon } from "../components/icons/WalletIcon";
import { useI18n } from "../i18n";
import { useLocalizedPath } from "../useLocalizedPath";
import { shortenAddress, useWallet } from "./WalletProvider";
export function WalletButton({
compact = false,
onOpenLogin,
}: {
compact?: boolean;
onOpenLogin?: () => void;
}) {
const { t } = useI18n();
const lp = useLocalizedPath();
const wallet = useWallet();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const closeOnOutside = (event: MouseEvent | TouchEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", closeOnOutside);
document.addEventListener("touchstart", closeOnOutside);
window.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("mousedown", closeOnOutside);
document.removeEventListener("touchstart", closeOnOutside);
window.removeEventListener("keydown", closeOnEscape);
};
}, [open]);
if (wallet.status === "loggedIn" && wallet.address) {
if (compact) {
return (
<div className="flex w-full flex-col gap-1">
<div className="flex flex-col gap-2 px-4 py-3">
<div className="text-[13px] font-bold leading-[21px] text-[#E5E5E5]">
{t("walletLoginAddress")}
</div>
<div className="break-all text-[13px] leading-[21px]">
<span className="font-bold text-white">
{wallet.address.slice(0, 5)}
</span>
<span className="font-medium text-[#A8A9AE]">
{wallet.address.slice(5, -5)}
</span>
<span className="font-bold text-white">
{wallet.address.slice(-5)}
</span>
</div>
</div>
<button
type="button"
onClick={() => wallet.logout()}
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-[#2A1B20] px-4 text-[15px] font-medium text-[#F36161] outline-none transition hover:bg-[#3a242a] focus-visible:ring-2 focus-visible:ring-[#F36161]/60"
>
<span>{t("walletDisconnect")}</span>
<LogOut className="h-4 w-4" strokeWidth={2.3} aria-hidden />
</button>
</div>
);
}
return (
<div ref={rootRef} className="relative">
<button
type="button"
onClick={() => setOpen((value) => !value)}
className={[
"inline-flex h-10 items-center justify-center rounded-full border border-ark-gold/45 bg-ark-gold/10 px-3 text-sm font-semibold text-ark-gold2 outline-none transition hover:bg-ark-gold/15 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg",
compact ? "w-full" : "",
].join(" ")}
aria-label={t("walletConnectedAs")}
aria-expanded={open}
>
<span className="mr-2 h-2 w-2 rounded-full bg-emerald-400" />
{shortenAddress(wallet.address)}
</button>
{open ? (
<div className="absolute right-0 top-[calc(100%+0.5rem)] z-50 w-52 rounded-2xl border border-white/10 bg-[#1c1c21]/95 p-2 shadow-2xl shadow-black/70 ring-1 ring-ark-line/80 backdrop-blur-xl">
<div className="truncate px-3 py-2 text-xs text-neutral-400">
{wallet.address}
</div>
<Link
to={lp("/favorites")}
onClick={() => setOpen(false)}
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm font-medium text-neutral-100 transition hover:bg-ark-gold/10 hover:text-ark-gold"
>
<Heart size={16} strokeWidth={2} />
{t("favorites")}
</Link>
<button
type="button"
onClick={() => {
setOpen(false);
wallet.logout();
}}
className="w-full rounded-xl px-3 py-2 text-left text-sm font-medium text-red-200 transition hover:bg-red-500/10"
>
{t("walletDisconnect")}
</button>
</div>
) : null}
</div>
);
}
return (
<button
type="button"
onClick={() => {
onOpenLogin?.();
wallet.openLoginModal();
}}
className={[
"inline-flex items-center justify-center gap-2 rounded-full border border-ark-gold bg-ark-gold px-4 text-black outline-none transition hover:bg-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg",
compact
? "h-12 w-full text-[15px] font-medium"
: "h-10 min-w-[124px] shrink-0 whitespace-nowrap text-sm font-bold",
].join(" ")}
>
<WalletIcon className="h-[18px] w-[18px]" />
<span>
{wallet.status === "loading" ? t("loading") : t("walletConnect")}
</span>
</button>
);
}

View File

@@ -0,0 +1,338 @@
import { LoaderCircle, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useI18n } from "../i18n";
import { walletDeepLink, walletDownloadUrl } from "./deepLinks";
import {
connectInjectedWallet,
getInjectedWallet,
type WalletKind,
} from "./injected";
import { useWallet } from "./WalletProvider";
import { WalletBrandIcon } from "./WalletBrandIcon";
const AUTO_LOGIN_PARAM = "autologin";
function supportsDirectPull(kind: WalletKind): boolean {
return kind === "tokenPocket" || kind === "imToken";
}
function supportsDesktopExtension(kind: WalletKind): boolean {
return kind === "tokenPocket";
}
function buildAutoLoginDappUrl(kind: WalletKind): string {
const url = new URL(window.location.href);
url.searchParams.set(AUTO_LOGIN_PARAM, kind);
return url.toString();
}
const wallets: WalletKind[] = ["tokenPocket", "imToken"];
type LoginState = "idle" | "connecting";
type PendingLogin = {
kind: WalletKind;
address: string;
};
type Translate = (key: string) => string;
function walletErrorMessage(error: unknown, t: Translate): string {
if (!(error instanceof Error)) return t("walletLoginFailed");
return t(error.message) || t("walletLoginFailed");
}
function isMobileDevice(): boolean {
if (typeof navigator === "undefined") return false;
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(
navigator.userAgent || "",
);
}
export function WalletLoginModal() {
const { t } = useI18n();
const { closeLoginModal, loginAddress, loginModalOpen } = useWallet();
const [selected, setSelected] = useState<WalletKind | null>(null);
const [mobileDevice, setMobileDevice] = useState(() => isMobileDevice());
const [state, setState] = useState<LoginState>("idle");
const [error, setError] = useState("");
const [pendingLogin, setPendingLogin] = useState<PendingLogin | null>(null);
useEffect(() => {
if (!loginModalOpen) return;
setMobileDevice(isMobileDevice());
setSelected(null);
setState("idle");
setError("");
setPendingLogin(null);
}, [loginModalOpen]);
if (!loginModalOpen) return null;
const walletName = (kind: WalletKind) => t(walletNameKey(kind));
const walletText = (key: string, kind: WalletKind) =>
t(key).replaceAll("{wallet}", walletName(kind));
const walletHint = () =>
mobileDevice ? t("walletChooseMobile") : t("walletDesktopHint");
const visibleWallets = mobileDevice
? wallets
: wallets.filter(supportsDesktopExtension);
const busy = state !== "idle";
const close = () => {
closeLoginModal();
setSelected(null);
setState("idle");
setError("");
setPendingLogin(null);
};
const selectWallet = (kind: WalletKind) => {
setSelected(kind);
setError("");
setPendingLogin(null);
};
const loginInjected = async (kind: WalletKind) => {
setSelected(kind);
setState("connecting");
setError("");
setPendingLogin(null);
try {
const address = await connectInjectedWallet(kind);
if (mobileDevice) {
await loginAddress(address);
return;
}
setPendingLogin({ kind, address });
setState("idle");
} catch (err) {
setState("idle");
setError(walletErrorMessage(err, t));
}
};
const confirmPendingLogin = async () => {
if (!pendingLogin) return;
setState("connecting");
setError("");
try {
await loginAddress(pendingLogin.address);
} catch (err) {
setState("idle");
setError(walletErrorMessage(err, t));
}
};
const cancelPendingLogin = () => {
setPendingLogin(null);
setSelected(null);
setState("idle");
setError("");
};
const openWalletAppDirect = (kind: WalletKind) => {
if (!mobileDevice && !supportsDesktopExtension(kind)) {
setSelected(kind);
setPendingLogin(null);
setError("");
return;
}
if (getInjectedWallet(kind)) {
void loginInjected(kind);
return;
}
if (mobileDevice && supportsDirectPull(kind)) {
setSelected(kind);
setError("");
const deeplink = walletDeepLink(kind, buildAutoLoginDappUrl(kind));
window.location.href = deeplink;
return;
}
setSelected(kind);
setPendingLogin(null);
setError(walletText("walletInstallSelected", kind));
};
const openWalletInstall = (kind: WalletKind) => {
window.open(walletDownloadUrl(kind), "_blank", "noopener,noreferrer");
};
return (
<div
className="fixed inset-0 z-[120] flex items-end justify-center bg-black/70 px-3 pb-3 pt-10 backdrop-blur-sm md:items-center md:p-6"
role="dialog"
aria-modal="true"
aria-labelledby="wallet-login-title"
>
<div className="max-h-[92dvh] w-full max-w-[460px] overflow-y-auto rounded-3xl border border-white/10 bg-[#17171d] p-5 shadow-2xl shadow-black/70 md:p-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2
id="wallet-login-title"
className="text-xl font-semibold text-white"
>
{t("walletLoginTitle")}
</h2>
<p className="mt-2 text-sm leading-6 text-neutral-400">
{t("walletLoginDesc")}
</p>
</div>
<button
type="button"
onClick={close}
aria-label={t("close")}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-white/10 text-neutral-300 transition hover:border-ark-gold/50 hover:text-ark-gold"
>
<X size={18} />
</button>
</div>
<div className="mt-5 grid gap-2">
{visibleWallets.map((kind) => {
const active = selected === kind;
const connecting = active && state === "connecting";
return (
<div
key={kind}
className={`rounded-2xl border p-3 transition ${
active
? "border-ark-gold/60 bg-ark-gold/10"
: "border-white/10 bg-[#20202a]"
}`}
>
<button
type="button"
onClick={() =>
mobileDevice
? selectWallet(kind)
: openWalletAppDirect(kind)
}
disabled={busy}
className="flex w-full items-center gap-3 text-left disabled:cursor-wait disabled:opacity-70"
>
<WalletBrandIcon kind={kind} size={32} />
<span className="min-w-0 flex-1">
<span className="block text-base font-semibold text-neutral-100">
{walletName(kind)}
</span>
<span className="mt-1 block text-xs leading-5 text-neutral-400">
{connecting ? t("walletConnecting") : walletHint()}
</span>
</span>
{connecting ? (
<LoaderCircle className="h-4 w-4 animate-spin text-ark-gold" />
) : null}
</button>
{mobileDevice && active ? (
<div className="mt-3">
<button
type="button"
onClick={() => openWalletAppDirect(kind)}
disabled={busy}
className="w-full rounded-full bg-ark-gold px-3 py-2 text-sm font-bold text-black transition hover:bg-ark-gold2 disabled:cursor-wait disabled:opacity-70"
>
{t("walletOpenWalletApp")}
</button>
</div>
) : null}
{!mobileDevice && active ? (
<div className="mt-3 rounded-2xl border border-white/10 bg-black/20 p-3 text-sm text-neutral-300">
{pendingLogin?.kind === kind ? (
<>
<p className="font-semibold text-neutral-100">
{walletText("walletConfirmAddressTitle", kind)}
</p>
<p className="mt-2 text-xs leading-5 text-neutral-400">
{walletText("walletConfirmAddressDesc", kind)}
</p>
<p
className="mt-3 break-all rounded-xl border border-ark-gold/30 bg-ark-gold/10 px-3 py-2 font-mono text-sm text-ark-gold"
title={pendingLogin.address}
>
{pendingLogin.address}
</p>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
<button
type="button"
onClick={confirmPendingLogin}
className="rounded-full bg-ark-gold px-3 py-2 text-sm font-bold text-black transition hover:bg-ark-gold2"
>
{t("walletConfirmLogin")}
</button>
<button
type="button"
onClick={cancelPendingLogin}
className="rounded-full border border-white/20 px-3 py-2 text-sm font-semibold text-neutral-200 transition hover:border-ark-gold/50 hover:text-ark-gold"
>
{t("walletCancelLogin")}
</button>
</div>
</>
) : supportsDesktopExtension(kind) ? (
<>
<p className="font-semibold text-neutral-100">
{walletText("walletDesktopHelpTitle", kind)}
</p>
<ol className="mt-2 list-decimal space-y-1 pl-5 leading-6 text-neutral-400">
<li>{t("walletDesktopHelpUnlock")}</li>
<li>{t("walletDesktopHelpSelect")}</li>
<li>{walletText("walletDesktopHelpRetry", kind)}</li>
</ol>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
<button
type="button"
onClick={() => openWalletAppDirect(kind)}
disabled={busy}
className="rounded-full bg-ark-gold px-3 py-2 text-sm font-bold text-black transition hover:bg-ark-gold2 disabled:cursor-wait disabled:opacity-70"
>
{walletText("walletReconnectWallet", kind)}
</button>
<button
type="button"
onClick={() => openWalletInstall(kind)}
className="rounded-full border border-ark-gold/50 px-3 py-2 text-sm font-semibold text-ark-gold transition hover:bg-ark-gold/10"
>
{walletText("walletInstallWallet", kind)}
</button>
</div>
</>
) : (
<>
<p className="font-semibold text-neutral-100">
{walletText("walletDesktopImTokenTitle", kind)}
</p>
<p className="mt-2 text-sm leading-6 text-neutral-400">
{walletText("walletDesktopImTokenDesc", kind)}
</p>
<button
type="button"
onClick={() => openWalletInstall(kind)}
className="mt-3 w-full rounded-full border border-ark-gold/50 px-3 py-2 text-sm font-semibold text-ark-gold transition hover:bg-ark-gold/10"
>
{walletText("walletDownloadApp", kind)}
</button>
</>
)}
</div>
) : null}
</div>
);
})}
</div>
{error ? (
<p className="mt-4 rounded-2xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
{error}
</p>
) : null}
</div>
</div>
);
}
function walletNameKey(kind: WalletKind): string {
return kind === "tokenPocket" ? "walletTokenPocket" : "walletImToken";
}

View File

@@ -0,0 +1,158 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useToast } from "../components/Toast";
import { useI18n } from "../i18n";
import { fetchWalletMe, loginWithWallet } from "./api";
import { signInWithInjectedWallet, type WalletKind } from "./injected";
import { clearWalletToken, readWalletToken, writeWalletToken } from "./token";
type WalletStatus = "loading" | "loggedOut" | "loggedIn";
type Translate = (key: string) => string;
function walletErrorMessage(error: unknown, t: Translate): string {
if (!(error instanceof Error)) return t("walletLoginFailed");
return t(error.message) || t("walletLoginFailed");
}
type WalletContextValue = {
address: string | null;
token: string | null;
status: WalletStatus;
loginModalOpen: boolean;
openLoginModal: () => void;
closeLoginModal: () => void;
signInInjected: (kind?: WalletKind) => Promise<void>;
completeLogin: (token: string, wallet: string) => void;
loginAddress: (address: string) => Promise<void>;
logout: () => void;
};
const WalletContext = createContext<WalletContextValue | null>(null);
export function shortenAddress(address: string): string {
if (address.length <= 12) return address;
return `${address.slice(0, 6)}...${address.slice(-4)}`;
}
export function WalletProvider({ children }: { children: ReactNode }) {
const { t } = useI18n();
const { showToast } = useToast();
const [token, setToken] = useState<string | null>(() => readWalletToken());
const [address, setAddress] = useState<string | null>(null);
const [status, setStatus] = useState<WalletStatus>(
token ? "loading" : "loggedOut",
);
const [loginModalOpen, setLoginModalOpen] = useState(false);
useEffect(() => {
let cancelled = false;
if (!token) {
setStatus("loggedOut");
setAddress(null);
return;
}
setStatus("loading");
fetchWalletMe(token)
.then((me) => {
if (cancelled) return;
setAddress(me.wallet);
setStatus("loggedIn");
})
.catch(() => {
if (cancelled) return;
clearWalletToken();
setToken(null);
setAddress(null);
setStatus("loggedOut");
});
return () => {
cancelled = true;
};
}, [token]);
const completeLogin = useCallback(
(nextToken: string, wallet: string) => {
writeWalletToken(nextToken);
setToken(nextToken);
setAddress(wallet);
setStatus("loggedIn");
setLoginModalOpen(false);
showToast(t("walletLoginSuccess"));
},
[showToast, t],
);
const loginAddress = useCallback(
async (walletAddress: string) => {
const res = await loginWithWallet(walletAddress);
completeLogin(res.token, res.wallet);
},
[completeLogin],
);
const signInInjected = useCallback(
async (kind?: WalletKind) => {
try {
const res = await signInWithInjectedWallet(kind);
completeLogin(res.token, res.wallet);
} catch (error) {
showToast(walletErrorMessage(error, t), "error");
throw error;
}
},
[completeLogin, showToast, t],
);
const logout = useCallback(() => {
clearWalletToken();
setToken(null);
setAddress(null);
setStatus("loggedOut");
showToast(t("walletDisconnected"));
}, [showToast, t]);
const value = useMemo<WalletContextValue>(
() => ({
address,
token,
status,
loginModalOpen,
openLoginModal: () => setLoginModalOpen(true),
closeLoginModal: () => setLoginModalOpen(false),
signInInjected,
completeLogin,
loginAddress,
logout,
}),
[
address,
completeLogin,
loginModalOpen,
loginAddress,
logout,
signInInjected,
status,
token,
],
);
return (
<WalletContext.Provider value={value}>{children}</WalletContext.Provider>
);
}
export function useWallet() {
const ctx = useContext(WalletContext);
if (!ctx) throw new Error("useWallet must be used within WalletProvider");
return ctx;
}

View File

@@ -0,0 +1,21 @@
import { Component, type ReactNode } from "react";
type Props = { children: ReactNode; fallback?: ReactNode };
type State = { hasError: boolean };
export class WalletStackErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
componentDidCatch(error: unknown): void {
console.error("[wallet-stack] error boundary caught", error);
}
render(): ReactNode {
if (this.state.hasError) return this.props.fallback ?? null;
return this.props.children;
}
}

59
src/wallet/api.ts Normal file
View File

@@ -0,0 +1,59 @@
import { apiBase, getJSONAuth, postJSON } from "../api";
export type WalletLoginResponse = {
token: string;
wallet: string;
};
export type WalletMeResponse = {
wallet: string;
role: "user";
};
export type TokenPocketLoginRequest = {
actionId: string;
nonce: string;
message: string;
qrUrl: string;
expiresAt: string;
};
export type TokenPocketLoginResult =
| {
status: "pending" | "expired" | "failed";
message?: string;
error?: string;
}
| {
status: "completed";
address: string;
message: string;
signature: string;
};
export function loginWithWallet(address: string): Promise<WalletLoginResponse> {
return postJSON<WalletLoginResponse>("/api/auth/wallet/login", { address });
}
export function fetchWalletMe(token: string): Promise<WalletMeResponse> {
return getJSONAuth<WalletMeResponse>("/api/auth/wallet/me", token);
}
export function createTokenPocketLoginRequest(): Promise<TokenPocketLoginRequest> {
return postJSON<TokenPocketLoginRequest>(
"/api/auth/wallet/tp-login-request",
{},
);
}
export async function fetchTokenPocketLoginResult(
actionId: string,
signal?: AbortSignal,
): Promise<TokenPocketLoginResult> {
const res = await fetch(
`${apiBase}/api/auth/wallet/tp-result?actionId=${encodeURIComponent(actionId)}`,
{ signal },
);
if (!res.ok) throw new Error(await res.text());
return res.json() as Promise<TokenPocketLoginResult>;
}

41
src/wallet/deepLinks.ts Normal file
View File

@@ -0,0 +1,41 @@
type WalletKind = "tokenPocket" | "metaMask" | "imToken";
function currentDappUrl(): string {
if (typeof window === "undefined") return "https://ark-library.com";
return window.location.href;
}
export function walletDeepLink(
kind: WalletKind,
dappUrl = currentDappUrl(),
): string {
switch (kind) {
case "tokenPocket":
return `tpdapp://open?params=${encodeURIComponent(
JSON.stringify({ url: dappUrl, chain: "BSC" }),
)}`;
case "metaMask":
return `https://metamask.app.link/dapp/${encodeURIComponent(
dappUrl.replace(/^https?:\/\//, ""),
)}`;
case "imToken":
return `imtokenv2://navigate/DappView?url=${encodeURIComponent(dappUrl)}`;
default:
return dappUrl;
}
}
export function openWalletDeepLink(kind: WalletKind): void {
if (typeof window === "undefined") return;
window.location.href = walletDeepLink(kind);
}
const downloadUrls: Record<WalletKind, string> = {
tokenPocket: "https://extension.tokenpocket.pro/",
metaMask: "https://metamask.io/download/",
imToken: "https://token.im/download",
};
export function walletDownloadUrl(kind: WalletKind): string {
return downloadUrls[kind];
}

184
src/wallet/injected.ts Normal file
View File

@@ -0,0 +1,184 @@
import { loginWithWallet } from "./api";
export type WalletKind = "tokenPocket" | "metaMask" | "imToken";
const BNB_CHAIN_ID_HEX = "0x38";
const BNB_CHAIN_PARAMS = {
chainId: BNB_CHAIN_ID_HEX,
chainName: "BNB Smart Chain",
nativeCurrency: { name: "BNB", symbol: "BNB", decimals: 18 },
rpcUrls: ["https://bsc-dataseed.binance.org"],
blockExplorerUrls: ["https://bscscan.com"],
};
export type EthereumProvider = {
isMetaMask?: boolean;
isTokenPocket?: boolean;
isImToken?: boolean;
providers?: EthereumProvider[];
request: <T = unknown>(args: {
method: string;
params?: unknown[];
}) => Promise<T>;
};
function isAddress(value: unknown): value is string {
return typeof value === "string" && /^0x[a-fA-F0-9]{40}$/.test(value);
}
function errorText(error: unknown): string {
if (!error || typeof error !== "object") return String(error ?? "");
const parts: string[] = [];
const record = error as Record<string, unknown>;
for (const key of ["shortMessage", "message", "details"]) {
const value = record[key];
if (typeof value === "string") parts.push(value);
}
if (record.cause) parts.push(errorText(record.cause));
return parts.join("\n");
}
function isNoAccountError(error: unknown): boolean {
return /wallet must has at least one account|wallet must has one account|must have at least one account|no wallet account returned/i.test(
errorText(error),
);
}
function normalizeWalletError(error: unknown): Error {
if (isNoAccountError(error)) return new Error("walletNoAccount");
if (error instanceof Error) return error;
const message = errorText(error);
return new Error(message || "Wallet login failed");
}
async function ensureBnbChain(ethereum: EthereumProvider): Promise<void> {
const chainId = await ethereum
.request<string>({ method: "eth_chainId" })
.catch(() => "");
if (chainId.toLowerCase() === BNB_CHAIN_ID_HEX) return;
try {
await ethereum.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: BNB_CHAIN_ID_HEX }],
});
} catch (error) {
const code = (error as { code?: number | string } | null)?.code;
if (code !== 4902 && code !== "4902") throw error;
await ethereum.request({
method: "wallet_addEthereumChain",
params: [BNB_CHAIN_PARAMS],
});
}
}
async function requestInjectedAddress(
ethereum: EthereumProvider,
): Promise<string> {
const existingAccounts: unknown[] = await ethereum
.request<unknown[]>({ method: "eth_accounts" })
.catch((): unknown[] => []);
const existingAddress = existingAccounts.find(isAddress);
if (existingAddress) return existingAddress;
const requestedAccounts = await ethereum
.request<unknown[]>({
method: "eth_requestAccounts",
})
.catch((error: unknown): never => {
throw normalizeWalletError(error);
});
const requestedAddress = requestedAccounts.find(isAddress);
if (!requestedAddress) throw new Error("walletNoAccount");
return requestedAddress;
}
export function getInjectedEthereum(): EthereumProvider | null {
if (typeof window === "undefined") return null;
const maybeWindow = window as typeof window & { ethereum?: EthereumProvider };
return maybeWindow.ethereum ?? null;
}
export function isTokenPocketBrowser(): boolean {
if (typeof navigator === "undefined") return false;
return /tokenpocket|tpwallet/i.test(navigator.userAgent || "");
}
export function isImTokenBrowser(): boolean {
if (typeof navigator === "undefined") return false;
return /imtoken/i.test(navigator.userAgent || "");
}
export function getInjectedWallet(kind?: WalletKind): EthereumProvider | null {
const ethereum = getInjectedEthereum();
if (!ethereum || !kind) return ethereum;
const providers = ethereum.providers?.length
? ethereum.providers
: [ethereum];
const match = providers.find((provider) => {
if (kind === "metaMask") return provider.isMetaMask;
if (kind === "tokenPocket") return provider.isTokenPocket;
if (kind === "imToken") return provider.isImToken;
return false;
});
if (match) return match;
if (kind === "tokenPocket" && isTokenPocketBrowser()) {
return providers[0] ?? ethereum;
}
if (kind === "imToken" && isImTokenBrowser()) return providers[0] ?? ethereum;
return null;
}
export function logWalletProviders(): void {
const ethereum = getInjectedEthereum();
const list = (
ethereum?.providers?.length
? ethereum.providers
: ethereum
? [ethereum]
: []
).map((p) => ({
isMetaMask: Boolean(p.isMetaMask),
isTokenPocket: Boolean(p.isTokenPocket),
isImToken: Boolean(p.isImToken),
}));
console.info("[wallet-login] providers", {
hasEthereum: Boolean(ethereum),
count: list.length,
list,
});
}
export async function connectInjectedWallet(
kind?: WalletKind,
): Promise<string> {
console.info("[wallet-login] start injected connect", { kind });
logWalletProviders();
const ethereum = getInjectedWallet(kind);
if (!ethereum) {
console.warn("[wallet-login] no injected provider found");
throw new Error("No injected wallet found");
}
console.info("[wallet-login] requesting BNB wallet account…");
const address = await requestInjectedAddress(ethereum);
console.info("[wallet-login] injected account", address);
console.info("[wallet-login] ensuring BNB Chain (0x38)…");
await ensureBnbChain(ethereum);
return address;
}
export async function signInWithInjectedWallet(kind?: WalletKind): Promise<{
token: string;
wallet: string;
}> {
const address = await connectInjectedWallet(kind);
console.info("[wallet-login] requesting backend login for", address);
const result = await loginWithWallet(address);
console.info("[wallet-login] logged in, wallet =", result.wallet);
return result;
}

28
src/wallet/token.ts Normal file
View File

@@ -0,0 +1,28 @@
const walletTokenKey = "ark-wallet-token:v1";
export function readWalletToken(): string | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage.getItem(walletTokenKey);
} catch {
return null;
}
}
export function writeWalletToken(token: string): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(walletTokenKey, token);
} catch {
return;
}
}
export function clearWalletToken(): void {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(walletTokenKey);
} catch {
return;
}
}

View File

@@ -0,0 +1,321 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useAccount, useConnect, useDisconnect } from "wagmi";
import { bsc } from "wagmi/chains";
import { hasWalletConnectProjectId } from "./RainbowWalletProvider";
import {
connectInjectedWallet,
getInjectedWallet,
type WalletKind,
} from "./injected";
import { useWallet } from "./WalletProvider";
export type WalletConnectLoginState = "idle" | "connecting" | "signing";
export type WalletConnectLoginMode = "deeplink" | "qr";
function isMobileDevice(): boolean {
if (typeof navigator === "undefined") return false;
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(
navigator.userAgent || "",
);
}
function currentUrl(): string {
if (typeof window === "undefined") return "https://ark-library.com";
return window.location.href;
}
function isWalletConnectUri(uri: string): boolean {
return uri.startsWith("wc:");
}
function metaMaskWalletConnectLink(uri: string): string {
if (!isWalletConnectUri(uri)) return uri;
return `https://metamask.app.link/wc?uri=${encodeURIComponent(uri)}`;
}
function walletConnectDeeplink(
kind: WalletKind | undefined,
uri: string,
): string | null {
if (kind === "tokenPocket") {
return isWalletConnectUri(uri)
? `tpoutside://wc?uri=${encodeURIComponent(uri)}`
: uri;
}
if (kind === "metaMask") {
return metaMaskWalletConnectLink(uri);
}
if (kind === "imToken") {
return isWalletConnectUri(uri)
? `imtokenv2://wc?uri=${encodeURIComponent(uri)}`
: uri;
}
return null;
}
function inAppBrowserFallback(kind: WalletKind | undefined): string | null {
if (kind === "imToken") {
return `imtokenv2://navigate/DappView?url=${encodeURIComponent(currentUrl())}`;
}
return null;
}
function openWalletDeeplink(
kind: WalletKind | undefined,
deeplink: string,
): void {
window.location.href = deeplink;
const fallback = inAppBrowserFallback(kind);
if (!fallback) return;
window.setTimeout(() => {
if (document.visibilityState === "visible") {
window.location.href = fallback;
}
}, 1500);
}
function connectorMatchesWallet(
connector: { id: string; name?: string; type?: string },
kind: WalletKind | undefined,
): boolean {
if (!kind) return false;
const id = connector.id.toLowerCase();
const name = connector.name?.toLowerCase() ?? "";
const type = connector.type?.toLowerCase() ?? "";
if (kind === "metaMask") {
return id === "metamask" || type === "metamask" || name === "metamask";
}
if (kind === "tokenPocket") {
return id === "tokenpocket" || name === "tokenpocket";
}
if (kind === "imToken") {
return id === "imtoken" || name === "imtoken";
}
return false;
}
export function useWalletConnectLogin() {
const available = hasWalletConnectProjectId();
const { address: localAddress, loginAddress } = useWallet();
const { address: wagmiAddress, isConnected: wagmiConnected } = useAccount();
const { connectAsync, connectors } = useConnect();
const { disconnectAsync } = useDisconnect();
const [state, setState] = useState<WalletConnectLoginState>("idle");
const [error, setError] = useState("");
const [qrUri, setQrUri] = useState("");
const [connectedAddress, setConnectedAddress] = useState("");
const pendingRef = useRef(false);
const completedAddressRef = useRef<string | null>(null);
const cleanupMessageRef = useRef<(() => void) | null>(null);
const cleanupPollingRef = useRef<(() => void) | null>(null);
const reset = useCallback(() => {
pendingRef.current = false;
completedAddressRef.current = null;
cleanupMessageRef.current?.();
cleanupMessageRef.current = null;
cleanupPollingRef.current?.();
cleanupPollingRef.current = null;
setState("idle");
setError("");
setQrUri("");
setConnectedAddress("");
}, []);
useEffect(() => {
if (!wagmiConnected || !wagmiAddress) return;
const alreadyCompleted =
completedAddressRef.current?.toLowerCase() === wagmiAddress.toLowerCase();
if (alreadyCompleted) return;
completedAddressRef.current = wagmiAddress;
pendingRef.current = false;
setConnectedAddress(wagmiAddress);
setQrUri("");
setState("idle");
if (localAddress?.toLowerCase() !== wagmiAddress.toLowerCase()) {
console.info("[wallet-login] wagmi account connected", {
address: wagmiAddress,
chain: "BNB Chain",
chainId: bsc.id,
});
void loginAddress(wagmiAddress)
.then(() => {
console.info("[wallet-login] wallet session completed", {
address: wagmiAddress,
});
})
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : "Wallet login failed");
});
}
}, [localAddress, loginAddress, wagmiAddress, wagmiConnected]);
const start = useCallback(
async (
preferredWallet?: WalletKind,
mode: WalletConnectLoginMode = "qr",
) => {
if (!available) return;
setError("");
setQrUri("");
setConnectedAddress("");
completedAddressRef.current = null;
pendingRef.current = true;
setState("connecting");
if (
mode === "deeplink" &&
preferredWallet &&
getInjectedWallet(preferredWallet)
) {
try {
const injectedAddress = await connectInjectedWallet(preferredWallet);
console.info("[wallet-login] injected connected", {
preferredWallet,
address: injectedAddress,
chain: "BNB Chain",
chainId: bsc.id,
});
await loginAddress(injectedAddress);
setState("idle");
return;
} catch (err) {
console.info("[wallet-login] injected connect fallback to wc", {
preferredWallet,
message: err instanceof Error ? err.message : String(err),
});
}
}
const walletConnectConnector =
connectors.find((item) => item.type === "walletConnect") ??
connectors.find((item) => item.id === "walletConnect");
const walletSpecificConnector = connectors.find((item) =>
connectorMatchesWallet(item, preferredWallet),
);
const connector =
mode === "qr"
? walletConnectConnector
: (walletSpecificConnector ?? walletConnectConnector);
if (!connector) {
pendingRef.current = false;
setQrUri("");
setState("idle");
setError("WalletConnect is not available");
return;
}
console.info("[wallet-login] walletconnect connector", {
preferredWallet,
connectorId: connector.id,
connectorName: connector.name,
connectorType: connector.type,
});
const onMessage = (message: { type: string; data?: unknown }) => {
if (
message.type !== "display_uri" ||
typeof message.data !== "string"
) {
return;
}
console.info("[wallet-login] walletconnect display_uri", {
preferredWallet,
connectorId: connector.id,
});
if (mode === "qr") {
setQrUri(message.data);
}
const deeplink = walletConnectDeeplink(preferredWallet, message.data);
if (mode === "deeplink" && deeplink && isMobileDevice()) {
openWalletDeeplink(preferredWallet, deeplink);
}
};
cleanupMessageRef.current?.();
connector.emitter.on("message", onMessage);
cleanupMessageRef.current = () =>
connector.emitter.off("message", onMessage);
cleanupPollingRef.current?.();
const finishFromAddress = (address: string, source: string) => {
const alreadyCompleted =
completedAddressRef.current?.toLowerCase() === address.toLowerCase();
if (alreadyCompleted) return;
pendingRef.current = false;
completedAddressRef.current = address;
setConnectedAddress(address);
setQrUri("");
setState("idle");
cleanupMessageRef.current?.();
cleanupMessageRef.current = null;
cleanupPollingRef.current?.();
cleanupPollingRef.current = null;
console.info("[wallet-login] wallet account connected", {
source,
preferredWallet,
address,
chain: "BNB Chain",
chainId: bsc.id,
});
void loginAddress(address)
.then(() => {
console.info("[wallet-login] wallet session completed", {
address,
});
})
.catch((err: unknown) => {
setError(
err instanceof Error ? err.message : "Wallet login failed",
);
});
};
const pollId = window.setInterval(() => {
void connector
.getAccounts()
.then((accounts) => {
const account = accounts[0];
if (account) finishFromAddress(account, "connector-poll");
})
.catch(() => undefined);
}, 1000);
cleanupPollingRef.current = () => window.clearInterval(pollId);
try {
await disconnectAsync().catch(() => undefined);
await connector.disconnect().catch(() => undefined);
const result = await connectAsync({ chainId: bsc.id, connector });
const connectedAddress = result.accounts[0];
if (!connectedAddress)
throw new Error("Wallet connected without an account");
finishFromAddress(connectedAddress, "connectAsync");
} catch (err) {
if (completedAddressRef.current) return;
pendingRef.current = false;
setState("idle");
setError(
err instanceof Error ? err.message : "WalletConnect login failed",
);
cleanupMessageRef.current?.();
cleanupMessageRef.current = null;
cleanupPollingRef.current?.();
cleanupPollingRef.current = null;
}
},
[available, connectAsync, connectors, disconnectAsync, loginAddress],
);
return {
available,
state,
error,
qrUri,
address: connectedAddress,
isConnected: Boolean(connectedAddress),
start,
reset,
};
}