23 Commits

Author SHA1 Message Date
TerryM
c882cce0a4 ci: clarify runner disk diagnostics
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 1m11s
2026-06-09 01:14:08 +08:00
TerryM
41d737da11 ci: make runner disk grow detection robust
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 3s
2026-06-09 01:12:07 +08:00
TerryM
5724d2b08c ci: simplify runner disk resize check
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 2s
2026-06-09 01:10:32 +08:00
TerryM
0b8c4fe1f7 fix(header): kill the inline<->burger oscillation around the threshold width
Some checks failed
Deploy to Frontend Servers / deploy (push) Has been cancelled
User report: after switching to Bahasa Melayu and opening the window to
full width, the header juddered every frame. Root cause was a feedback
loop between the fit measurement and the right-side layout:

* In inline mode the right side renders the favorites label
  ("Kegemaran Saya") AND no burger button => ~80px wider.
* In burger mode it renders the burger but drops the label => ~80px
  narrower.

Available space for the nav was therefore mode-dependent, and the old
60px hysteresis was smaller than that ~80px swing. In a band roughly
1282-1302px (with ms needed=591px) the measurement decided:

  burger: needed + 60 <= available_burger     -> flip to inline
  inline: needed     >  available_inline      -> flip back to burger
  (repeat every frame, ResizeObserver re-fires, header shakes)

Two-part fix:

1. Keep the burger button mounted in both modes. When inline it is
   pointer-events:none + invisible + aria-hidden=true + tabIndex=-1,
   so it stays unclickable but still occupies its 40px box. The
   right-side width no longer changes when we flip modes, so the
   measurement input stops jumping.

2. Widen the burger->inline hysteresis from 60 to 120 to absorb the
   remaining ~80px difference from the favorites label (and per-locale
   variations: "My Favorites" vs "Kegemaran Saya" etc). The header
   now picks a mode at each width and stays there.

Verified by sweeping the row width across 1100-2200px on the ms locale
in the browser: modeFlips=0 at every previously-broken width, single
clean burger->inline transition once there's room.
2026-06-08 01:31:26 +08:00
TerryM
78186486c5 fix(stream): keep previous items visible while refetching on locale switch
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 38s
Root cause of the "Bahasa Melayu lags then shows" report: switching
locale invalidates the cache key in usePostStream (streamKey includes
lang), the effect that handles dep changes calls `setItems([])` before
`fetchPage(true)`, and that blanks the entire stream for the duration
of the network round-trip (~150-300ms in dev). The earlier fixes
(AnimatePresence key, font stack, useLayoutEffect title sync) addressed
visible side effects, but this blank window is the actual source of
the perceived freeze.

Drop the eager reset. fetchPage(true) replaces items wholesale once the
new locale's response arrives, so leaving the previous list in place is
a stale-while-revalidate swap: visible content during the gap, single
replace at the end, no skeleton flash, no scroll jump.

Verified in browser with a cn -> ms switch: mainTextLen never hits
zero during the transition, and the new list takes over at t~120ms
without an intervening blank frame.
2026-06-08 01:23:53 +08:00
TerryM
03a5701798 fix(header): publish page title in useLayoutEffect to avoid stale frame on lang switch
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 39s
Selecting Bahasa Melayu (or Vietnamese, or any locale whose nav width
crosses the inline/burger threshold) showed a one-frame stutter:

  t=0   cn render: inline nav, title "全部资料"
  t=63  ms render: burger nav, **title still "全部资料"** (stale)
  t=78  ms render: burger nav, title "Semua aset"

The stale title frame came from useSetPageTitle running its setTitle in
a useEffect — that fires after the commit, so the first render after a
lang change still saw the previous page's published title. Combined
with the inline -> burger swap, the two-step update read as the flicker
the user reported.

Switching to useLayoutEffect runs setTitle synchronously between commit
and paint, so the header renders the new title in the same frame as the
new locale. Measured a single cn -> ms transition in the browser: the
intermediate stale-title frame is gone, opacity stays at 1 throughout.
2026-06-08 01:18:26 +08:00
TerryM
b5a699c6c7 fix(layout): stop the page fade firing on every language toggle
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 38s
Switching locale (e.g. cn -> ms or cn -> vi) read as a ~220ms flicker
because the <AnimatePresence> wrapping <main> was keyed on the full
pathname. Changing the language prefix changed the key, so AnimatePresence
treated the swap as a route change and re-ran the page fade transition.

Strip the language prefix when computing the key so only real route
changes drive AnimatePresence. Same translations render in place, no
opacity dip. Measured opacity stays at 1 across a cn -> ms switch in
the browser instead of dipping to 0 and back.
2026-06-08 01:11:09 +08:00
TerryM
33d03d1cc7 fix(fonts): put Latin system fonts before CJK so Vietnamese composes
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 39s
Vietnamese text rendered with split diacritics — "Tất cả tài liệu" came
out as "Tâ´t ca' tài liêụ" because the previous font stack led with
six CJK-only families (Noto Sans SC/TC, PingFang SC/TC, Microsoft
YaHei/JhengHei) that have zero Vietnamese coverage. The browser still
matched the Latin base letters in those fonts and dropped the combining
tone marks onto the next available slot, breaking shaping for every
precomposed Vietnamese codepoint (U+1EA5, U+1EC7, …).

Reorder the stack so ui-sans-serif / system-ui / -apple-system / Segoe UI
lead and the CJK families follow as fallbacks. Browsers do per-glyph
font selection so Chinese characters still hit PingFang SC / Noto Sans
SC etc.; the zh-CN and zh-TW pages render unchanged. The fix needs no
extra network requests — every modern OS bundles a Latin font with
Vietnamese support.

Verified in browser at /vi/browse ("Tất cả tài liệu" / "Liên kết" /
"Tệp nén" all compose correctly) and /cn/browse (still PingFang SC).
2026-06-08 01:07:48 +08:00
TerryM
2a702f4e12 feat(header): make nav-vs-burger decision per-language by measuring fit
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 38s
Each translation gives the nav a different natural width: Malay's 6 labels
total ~594px while Chinese is ~508px. With a fixed Tailwind breakpoint
(`xl:flex`) some languages either clipped at viewports where the nav
technically didn't fit, or collapsed too early to a burger on wide screens.

Drive the toggle from runtime measurement instead:

- Always render a hidden ghost nav (`absolute invisible`) so the browser
  can report the inline nav's true scrollWidth for the active locale,
  even while we're showing the burger.
- Add refs on the header row, brand block, and right-side actions; a
  useLayoutEffect + ResizeObserver compares ghost.scrollWidth against
  (rowWidth - brandWidth - rightWidth - 2*rowGap).
- 60px hysteresis on burger -> inline so the layout doesn't oscillate
  when the favorites label / burger button swap changes right-side width.
- Drop the now-unused `xl:flex`, `xl:hidden`, `xl:flex-none`,
  `xl:inline` classes from the affected elements.
- Close the burger drawer automatically when the row grows wide enough
  to show the inline nav again, so the menu doesn't get stuck without
  a toggle.

Verified via puppeteer/eval across en / zh-CN / zh-TW / ms / ru / de at
800-2000px: each language switches to burger at its own threshold and
the inline nav never overflows or clips its labels.
2026-06-08 01:01:33 +08:00
TerryM
6aaa9573e7 ci(deploy): wipe every non-current act workspace before npm ci
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 2s
The previous run still hit ENOSPC, this time during `npm ci` while
extracting node_modules. The earlier cleanup left the just-failed act
workspace on disk (mtime < 10min threshold), and its half-extracted
node_modules took the runner past the limit before `npm ci` finished.

- Drop the mtime threshold for act workspaces; instead detect the
  currently-running job's directory and rm -rf every sibling. The
  current job is preserved by path comparison so we never delete files
  the running step needs.
- Blow away ~/.npm/_cacache, ~/.npm/_logs, ~/.cache/setup-node entirely.
  `npm ci` re-populates what it needs and the cache is the easiest GB
  to reclaim on a tight runner.
- Tighten actions-runner workspace retention from 24h to 30min.
- Drop the docker prune --filter; use `docker system prune -af --volumes`
  to reclaim builder cache and volumes too.
- Hard-fail with a clear error if <3.5GB free after cleanup, instead of
  letting `npm ci` half-write an unusable node_modules and failing
  obscurely. Codebase needs ~3GB for hoisted deps.
2026-06-08 00:39:31 +08:00
TerryM
9b6539ff71 feat(category): add branded icons for contract-address and data-records
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 41s
Backend zh-CN /api/categories now returns two new categories:
  id=17 slug=contract-address  (合约地址)
  id=18 slug=data-records      (数据收录)

Export the matching gold-tone PNGs from Figma and wire them into
slugToAsset so CategoryIcon serves the branded artwork instead of
falling back to the lucide iconKey (folder / play). en locale still
returns 13 categories, so English users will pick this up once the
backend ships translations for the two new entries.
2026-06-08 00:35:27 +08:00
TerryM
8c1dd8189e ci(deploy): make runner cleanup more aggressive to prevent ENOSPC
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 1m0s
A previous deploy failed at the vite chunk-writing stage with
"ENOSPC: no space left on device". The cleanup step ran at the start
of the job but left enough stale data behind that the runner filled up
before `npm run build` could finish.

- Drop the act workspace retention from 60min to 10min. Closely-spaced
  pushes used to keep multiple stale jobs around; 10min still preserves
  any currently-running job because its mtime keeps advancing.
- Drop _work / setup-node / npm cacache retention from 24h to 60min.
- Drop the `until=24h` filter on docker prune so dangling images,
  containers, and builder cache get reclaimed every run.
- Add a second "Ensure free space before build" guard right before the
  Build step. If <3GB is free, aggressively prune act caches, npm
  cacache, and docker volumes before vite starts writing chunks.
2026-06-07 19:59:18 +08:00
TerryM
915d88b3ac feat(video): show filled white track left of the volume slider thumb
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 58s
Add a hard-stop linear-gradient background to the volume <input> so the
portion of the track left of the thumb is solid white while the right
portion stays at the previous translucent white. The user can now see
at a glance how loud the level is set, instead of only inferring it
from the thumb position.
2026-06-07 19:56:17 +08:00
TerryM
f97e367dde fix(header): expand public header full-width and stop nav text clipping
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 58s
- Drop max-w-[1280px] on the desktop header inner row so the header
  expands with wider viewports instead of staying capped.
- Move the inline nav / burger toggle from min-[1000px] to xl (1280px)
  so the nav only appears when all six items have enough room. The old
  threshold relied on overflow-x-auto, which clipped the first/last
  characters ("ll assets", "Popula").
- Remove the now-unused .header-nav-scroll CSS along with the scroll
  fallback wiring on the nav element.

Mid-width viewports (1000–1280px) now show the burger drawer instead
of a horizontally-scrolled nav. main content stays at max-w-[1280px]
on purpose.
2026-06-07 19:53:58 +08:00
TerryM
9821f03929 feat(video): add inline volume control to MessageInlineVideo
- Add mute toggle button (Volume2/VolumeX icons) to the custom control bar.
- Add an always-visible inline straight-line volume slider on desktop;
  mobile keeps mute toggle only and relies on system volume keys.
- Slider at 0 auto-mutes; unmuting from zero restores volume to 1.
- Sync isMuted/volume state via the video volumechange event.

Verified in browser at /browse?type=video: drag slider updates
video.volume, mute toggle preserves volume across on/off.
2026-06-07 19:53:49 +08:00
TerryM
a4cb4f496d fix: show BSC wallet prompt for TokenPocket
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 1m0s
2026-06-06 01:27:28 +08:00
TerryM
24e22a3f25 fix: improve imToken wallet error prompts
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 1m8s
2026-06-06 01:24:27 +08:00
TerryM
2b5ec54896 fix: fallback when imToken returns empty accounts
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 59s
2026-06-06 01:00:44 +08:00
TerryM
fe8dcee9a1 fix: auto-login imToken in-app browser without query
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 1m0s
2026-06-06 00:52:19 +08:00
TerryM
5408a86cc9 docs: record imToken production login fix 2026-06-06 00:49:26 +08:00
TerryM
9c4b8a4df7 fix: support imToken in-app browser login
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 1m2s
2026-06-06 00:45:24 +08:00
cbaa06f77d Merge pull request 'terry-staging' (#16) from terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 1m0s
Reviewed-on: #16
2026-06-05 16:33:11 +00:00
85a24ef982 Merge pull request 'terry-wallet-login' (#15) from terry-wallet-login into terry-staging
Reviewed-on: #15
2026-06-05 16:32:43 +00:00
24 changed files with 593 additions and 110 deletions

View File

@@ -10,35 +10,64 @@ jobs:
runs-on: self-hosted runs-on: self-hosted
steps: steps:
- name: Free disk space - name: Ensure runner disk space
run: | run: |
set +e set -e
echo "=== Disk before cleanup ===" echo "=== Disk before resize ==="
df -h df -h /
# Stale act runner workspaces from previous jobs (older than 60 min).
if [ -d "$HOME/.cache/act" ]; then hostname || true
du -sh "$HOME/.cache/act" 2>/dev/null lsblk -o NAME,SIZE,TYPE,MOUNTPOINT,FSTYPE || true
find "$HOME/.cache/act" -mindepth 1 -maxdepth 1 -type d -mmin +60 -exec rm -rf {} + 2>/dev/null
ROOT_SOURCE=$(findmnt -n -o SOURCE / 2>/dev/null || true)
ROOT_FSTYPE=$(findmnt -n -o FSTYPE / 2>/dev/null || true)
DISK_NAME=$(lsblk -no PKNAME "$ROOT_SOURCE" 2>/dev/null | head -n1 || true)
PART_NUM=$(lsblk -no PARTN "$ROOT_SOURCE" 2>/dev/null | head -n1 || true)
# Fallback for NVMe device names if lsblk does not expose PKNAME/PARTN
# in the runner container. Example: /dev/nvme0n1p1 -> /dev/nvme0n1 + 1.
if [ -z "$DISK_NAME" ] || [ -z "$PART_NUM" ]; then
case "$ROOT_SOURCE" in
/dev/nvme*n*p*)
DISK_NAME=$(basename "$ROOT_SOURCE" | sed 's/p[0-9]*$//')
PART_NUM=$(basename "$ROOT_SOURCE" | sed 's/.*p//')
;;
/dev/*[0-9])
DISK_NAME=$(basename "$ROOT_SOURCE" | sed 's/[0-9]*$//')
PART_NUM=$(basename "$ROOT_SOURCE" | sed 's/.*[^0-9]//')
;;
esac
fi fi
# Stale runner workspaces under common locations.
for dir in "$HOME/actions-runner/_work" "$HOME/.cache/setup-node" "$HOME/.npm/_cacache"; do echo "Root source: $ROOT_SOURCE ($ROOT_FSTYPE), disk: /dev/${DISK_NAME:-unknown}, partition: ${PART_NUM:-unknown}"
if [ -d "$dir" ]; then if [ -n "$DISK_NAME" ]; then
find "$dir" -mindepth 1 -maxdepth 2 -mmin +1440 -exec rm -rf {} + 2>/dev/null echo "Visible disk bytes: $(sudo blockdev --getsize64 "/dev/$DISK_NAME" 2>/dev/null || echo unknown)"
fi
if [ -n "$DISK_NAME" ] && [ -n "$PART_NUM" ]; then
if ! command -v growpart >/dev/null 2>&1; then
sudo dnf -y install cloud-utils-growpart || sudo yum -y install cloud-utils-growpart || true
fi
if command -v growpart >/dev/null 2>&1; then
sudo growpart "/dev/$DISK_NAME" "$PART_NUM" || true
else
echo "growpart not installed; cannot grow partition automatically."
fi fi
done
# Docker leftovers if docker is available.
if command -v docker >/dev/null 2>&1; then
docker image prune -af --filter "until=24h" 2>/dev/null
docker container prune -f --filter "until=24h" 2>/dev/null
docker builder prune -af --filter "until=24h" 2>/dev/null
fi fi
# Stale /tmp files older than 2h, keep currently-running runner files.
find /tmp -mindepth 1 -maxdepth 1 -mmin +120 \ case "$ROOT_FSTYPE" in
-not -name 'runner*' -not -name 'act*' \ ext2|ext3|ext4) sudo resize2fs "$ROOT_SOURCE" || true ;;
-exec rm -rf {} + 2>/dev/null xfs) sudo xfs_growfs / || true ;;
echo "=== Disk after cleanup ===" esac
df -h
exit 0 echo "=== Disk after resize ==="
df -h /
AVAIL_MB=$(df -Pm / | awk 'NR==2 {print $4}')
echo "Available on root volume: ${AVAIL_MB} MB"
if [ "${AVAIL_MB:-0}" -lt 3500 ]; then
echo "::error::Less than 3.5GB free on root volume (${AVAIL_MB}MB)."
echo "growpart could not find extra space. If AWS says EBS is 200GB, this job is likely running on the wrong runner/volume or the OS still has not seen the expanded disk; reboot/rescan the runner host and verify lsblk shows ~200G for the parent disk."
exit 1
fi
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4

View File

@@ -0,0 +1,34 @@
---
title: "imToken production cannot get wallet address — Quick Fix"
type: quick-fix
date: 2026-06-06
---
# imToken production cannot get wallet address — Quick Fix
## Bug
`main` serves `ark-library.com`, while `terry-wallet-login` serves the staging site. Staging can log in with imToken, but production can open imToken's in-app browser without getting the wallet address or completing login.
## Root Cause
The auto-login effect only ran when the URL contained an explicit `autoLogin` / `autologin` query parameter. imToken's in-app-browser deeplink can open the DApp page without preserving that query string. In that case the production page was inside imToken and could have an injected wallet provider, but `AutoInjectedLogin` returned early and never attempted to read the wallet address.
The parser was also case-sensitive, so a lowercase `autologin=imtoken` test URL would not start the flow.
## Fix
Updated the auto-login entrypoint to treat an imToken in-app-browser user agent as an imToken direct-login session even when the query parameter is missing. Also made the wallet-kind parser case-insensitive.
### Files Modified
- `src/wallet/AutoInjectedLogin.tsx` — imports `isImTokenBrowser()`, falls back to `imToken` when inside imToken without an auto-login query, and accepts lowercase wallet kind values.
## Verification
- `npx prettier --write src/wallet/AutoInjectedLogin.tsx`
- `npx tsc --noEmit`
## Notes
This is scoped to imToken UA fallback. TokenPocket and MetaMask still require an explicit auto-login query parameter.

View File

@@ -0,0 +1,40 @@
---
title: "imToken production in-app browser login — Quick Fix"
type: quick-fix
date: 2026-06-06
---
# imToken production in-app browser login — Quick Fix
## Bug
`main` serves `ark-library.com`, while `terry-wallet-login` serves the staging site. imToken login worked on staging, but on `ark-library.com` the imToken deeplink could open the in-app browser without completing login.
## Root Cause
The production imToken in-app browser path depends on injected wallet provider behavior after the deeplink opens `ark-library.com`. Some imToken environments can expose a legacy provider shape (`window.web3.currentProvider`, `selectedAddress`, or `enable()`) or fail BNB-chain switching even though the wallet address is already available. The previous flow required the modern EIP-1193 path and chain switch to succeed, so login could silently fail before the frontend posted the wallet address to the backend.
## Fix
Made the injected wallet login path more tolerant for imToken/mobile in-app browser environments:
- Detect legacy `window.web3.currentProvider` if `window.ethereum` is unavailable.
- Accept `selectedAddress` when it is already exposed by the wallet.
- Fall back to legacy `ethereum.enable()` if `eth_requestAccounts` fails.
- Do not block wallet-address login if BNB-chain switching fails, because the backend login only needs the wallet address.
### Files Modified
- `src/wallet/injected.ts` — adds legacy provider/address fallbacks and makes BNB-chain switching non-blocking for injected wallet login.
## Verification
- `npx prettier --write src/wallet/injected.ts`
- `npx tsc --noEmit`
- `npm run format:check`
- `npm test`
- `VITE_API_URL="" VITE_API_PREFIX="/apnew" VITE_DISABLE_ADMIN="true" npm run build`
## Notes
The production fix was committed and pushed to `main` as `9c4b8a4 fix: support imToken in-app browser login`; Terry confirmed CI handles deploy for `ark-library.com`.

View File

@@ -0,0 +1,40 @@
---
title: "公共 Header 宽屏可扩展 & 导航文字裁切修复 — Quick Fix"
type: quick-fix
date: 2026-06-07
---
# 公共 Header 宽屏可扩展 & 导航文字裁切修复 — Quick Fix
## Bug
1. 桌面 Header 内容固定在 `max-w-[1280px]`,更宽的视口下不能 expand左右出现大面积空白。
2. 在中等宽度(约 10001280px`<nav>` 通过 `overflow-x-auto` 横向滚动来塞 6 个导航项,结果首尾字符被滚动容器边缘切掉,看起来像 "All assets" 变成 "ll assets"、"Popular" 变成 "Popula",类似被 overlay 挡住。
## Root Cause
- `PublicLayout.tsx` 桌面 Header 内层容器固定 `mx-auto max-w-[1280px]`,限制了宽屏扩展。
- nav 在 `min-[1000px]` 就显示出来但实际所需宽度≈1230px大于该断点下的可用空间于是用 `overflow-x-auto` 做兜底,造成视觉裁切。
## Fix
- 移除 Header 桌面内层容器的 `max-w-[1280px]`,改用 `w-full`,宽屏会自然 expand 到视口宽度。
- 将 nav 与移动菜单按钮的显示断点从 `min-[1000px]` 提升到 `xl`1280pxnav 出现时永远有足够空间,不需要横向滚动。
- 删除 nav 上的 `overflow-x-auto overflow-y-hidden``header-nav-scroll` 类,以及 `index.css` 中废弃的 `.header-nav-scroll` 规则。
### Files Modified
- `src/layouts/PublicLayout.tsx`
- Header 桌面行:`mx-auto max-w-[1280px] xl:px-6``w-full xl:px-10`,整体可随视口扩展。
- nav`min-[1000px]:flex``xl:flex`,去掉 `overflow-x-auto``header-nav-scroll`
- 右侧操作区与桌面 burger`min-[1000px]:*``xl:*`,保持与 nav 同步切换。
- 移动菜单抽屉:`min-[1000px]:hidden``xl:hidden`
- `src/index.css` — 移除已无用的 `.header-nav-scroll` 规则。
## Verification
- `npx tsc --noEmit` 通过。
- `npm run format:check` 通过。
- `npm test` 全 49 测试通过。
- 行为预期:
- 视口 ≥1280pxnav 完整显示无任何边缘裁切Header 内容随视口加宽继续 expand。
- 视口 <1280px自动切换到 burger 抽屉(原本 10001280 的横向滚动 nav 不再出现)。
## Notes
- 中等宽度10001280原本能直接看到 nav现在改成 burger这是为了完全消除文字裁切。是否要保留中等宽度的 nav 是设计取舍,目前以用户「不允许文字被裁」的要求为优先。
- 主内容 `<main>` 仍保留 `max-w-[1280px]`;如需主内容也跟随扩展,可后续单独调整。

View File

@@ -0,0 +1,40 @@
---
title: "影片播放音量调整按钮 — Quick Fix"
type: quick-fix
date: 2026-06-07
---
# 影片播放音量调整按钮 — Quick Fix
## Bug
`MessageInlineVideo` 的自定义控制条只有播放/暂停、进度条、全屏按钮,缺少音量控制。用户无法在播放器内静音或调节音量。
## Root Cause
功能缺失。原生 `<video>` 控件被关闭以统一 iOS Safari 体验,但替代实现没有补回音量控制。
## Fix
在底部控制条「剩余时间」与「全屏按钮」之间加入音量控制:
- 静音切换按钮(`Volume2` / `VolumeX`),点击直接 toggle `video.muted`,桌面、移动均可用。
- 桌面端始终可见的内联直线音量滑块(`<input type="range">`0~1step 0.05),紧贴喇叭按钮右侧;不再用 hover 弹出,调音量像 YouTube 一样直接拖一条直线。
- 滑到 0 自动静音;从 0 解除静音时自动恢复到 1避免「点开还是没声音」的体验。
- 新增 `isMuted` / `volume` state监听 `volumechange` 与初始挂载同步,确保按钮图标与滑块位置始终一致。
- 移动端只显示静音按钮,音量大小让系统音量键负责。
### Files Modified
- `src/components/messageStream/MessageInlineVideo.tsx` — 引入 `Volume2/VolumeX` 图标、新增音量 state / `volumechange` 监听 / `toggleMute` / `handleVolumeChange`,在控制条加入音量按钮 + hover 音量滑块。
## Verification
- `npx tsc --noEmit` 通过(严格模式 + 未使用变量检查)。
- `npm run format` 通过。
- `npm test` 全部 49 测试通过。
- 浏览器实测 `/browse?type=video`
- 拖滑块 0.3`video.volume=0.3`slider 同步 0.3 ✓
- 点喇叭按钮:`muted=true`slider 显示 0 ✓
- 再点:`muted=false`volume 保留 0.3 ✓
- 滑块拖到 0`muted=true``volume=0`
## Notes
- 仅修改 `MessageInlineVideo`,全屏播放器 `VideoPlayer.tsx` 复用同一组件,因此全屏模式同时获得音量控制。
- 没有改变 `autoPlay` 默认行为;如未来 iOS autoplay 受限,可考虑默认 `muted` 起播再由用户点按钮解除。
- 滑块在移动端隐藏(仅 `md:` 以上显示),移动端通过按钮 + 系统音量键操作,避免在小气泡里拥挤。

View File

@@ -49,6 +49,18 @@ The workflow expects these Gitea secrets:
## Common failures ## Common failures
### Runner disk still shows the old EBS size
If the EC2 runner EBS volume was expanded but CI still reports a small root filesystem (for example `df -h /` still shows 8GB), the partition/filesystem has not grown yet. The deploy workflow runs an early `Ensure runner disk space` step that tries to grow `/` before installing dependencies:
```bash
sudo growpart <root-disk> <root-partition>
sudo resize2fs <root-partition> # ext filesystems
# or sudo xfs_growfs / # xfs filesystems
```
If the step cannot find `growpart`, it tries to install `cloud-utils-growpart` with `dnf`/`yum`. If install is blocked, install it manually on the runner host and rerun the workflow. The step also prints `hostname`, `lsblk`, and the visible parent disk byte size. If `growpart` says `NOCHANGE` and the parent disk still shows 8GB, the job is running on the wrong runner/volume or the OS still has not seen the expanded EBS volume; verify the EC2 instance/volume pair and reboot/rescan the runner host.
### Node version is too old ### Node version is too old
The workflow pins Node.js 22 using `actions/setup-node`. This keeps the self-hosted runner from using an older system Node version during `npm ci`, tests, and build. The workflow pins Node.js 22 using `actions/setup-node`. This keeps the self-hosted runner from using an older system Node version during `npm ci`, tests, and build.

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

View File

@@ -1,7 +1,7 @@
import { import {
createContext, createContext,
useContext, useContext,
useEffect, useLayoutEffect,
useState, useState,
type PropsWithChildren, type PropsWithChildren,
} from "react"; } from "react";
@@ -31,10 +31,16 @@ export function usePageTitle(): string | null {
return useContext(PageTitleContext)?.title ?? null; return useContext(PageTitleContext)?.title ?? null;
} }
/** Publish the current page's title; clears it again when the page unmounts. */ /**
* Publish the current page's title; clears it again when the page unmounts.
* Uses useLayoutEffect so the title updates synchronously with the page render
* — otherwise switching language (e.g. cn -> ms) showed the previous title for
* one paint while the post-commit useEffect was still pending, which read as a
* flicker in the header.
*/
export function useSetPageTitle(title: string | null): void { export function useSetPageTitle(title: string | null): void {
const setTitle = useContext(PageTitleContext)?.setTitle; const setTitle = useContext(PageTitleContext)?.setTitle;
useEffect(() => { useLayoutEffect(() => {
setTitle?.(title); setTitle?.(title);
return () => setTitle?.(null); return () => setTitle?.(null);
}, [setTitle, title]); }, [setTitle, title]);

View File

@@ -1,4 +1,4 @@
import { Maximize2, Pause, Play } from "lucide-react"; import { Maximize2, Pause, Play, Volume2, VolumeX } from "lucide-react";
import { import {
useCallback, useCallback,
useEffect, useEffect,
@@ -121,6 +121,8 @@ export function MessageInlineVideo({
const [currentTime, setCurrentTime] = useState(initialTime); const [currentTime, setCurrentTime] = useState(initialTime);
const [duration, setDuration] = useState(attachment.durationSec ?? 0); const [duration, setDuration] = useState(attachment.durationSec ?? 0);
const [isScrubbing, setIsScrubbing] = useState(false); const [isScrubbing, setIsScrubbing] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [volume, setVolume] = useState(1);
// When we programmatically seek (e.g. syncing the playhead back from the // When we programmatically seek (e.g. syncing the playhead back from the
// fullscreen overlay) the progress fill should jump straight to the watched // fullscreen overlay) the progress fill should jump straight to the watched
// position instead of sweeping up from its old width via the CSS transition. // position instead of sweeping up from its old width via the CSS transition.
@@ -150,6 +152,10 @@ export function MessageInlineVideo({
setCurrentTime(v.currentTime); setCurrentTime(v.currentTime);
onTimeUpdate?.(v.currentTime); onTimeUpdate?.(v.currentTime);
}; };
const onVolume = () => {
setIsMuted(v.muted);
setVolume(v.volume);
};
const onMeta = () => { const onMeta = () => {
if (Number.isFinite(v.duration)) setDuration(v.duration); if (Number.isFinite(v.duration)) setDuration(v.duration);
if (initialTime > 0) { if (initialTime > 0) {
@@ -165,12 +171,15 @@ export function MessageInlineVideo({
v.addEventListener("timeupdate", onTime); v.addEventListener("timeupdate", onTime);
v.addEventListener("seeked", onSeeked); v.addEventListener("seeked", onSeeked);
v.addEventListener("loadedmetadata", onMeta); v.addEventListener("loadedmetadata", onMeta);
v.addEventListener("volumechange", onVolume);
onVolume();
return () => { return () => {
v.removeEventListener("play", onPlay); v.removeEventListener("play", onPlay);
v.removeEventListener("pause", onPause); v.removeEventListener("pause", onPause);
v.removeEventListener("timeupdate", onTime); v.removeEventListener("timeupdate", onTime);
v.removeEventListener("seeked", onSeeked); v.removeEventListener("seeked", onSeeked);
v.removeEventListener("loadedmetadata", onMeta); v.removeEventListener("loadedmetadata", onMeta);
v.removeEventListener("volumechange", onVolume);
}; };
}, [initialTime, onTimeUpdate]); }, [initialTime, onTimeUpdate]);
@@ -181,6 +190,22 @@ export function MessageInlineVideo({
else v.pause(); else v.pause();
}, []); }, []);
const toggleMute = useCallback(() => {
const v = videoRef.current;
if (!v) return;
// Unmuting at zero volume would leave the user with silence; restore to full.
if (v.muted && v.volume === 0) v.volume = 1;
v.muted = !v.muted;
}, []);
const handleVolumeChange = useCallback((next: number) => {
const v = videoRef.current;
if (!v) return;
const clamped = Math.max(0, Math.min(1, next));
v.volume = clamped;
v.muted = clamped === 0;
}, []);
const seekToClientX = useCallback((clientX: number) => { const seekToClientX = useCallback((clientX: number) => {
const el = scrubRef.current; const el = scrubRef.current;
const v = videoRef.current; const v = videoRef.current;
@@ -367,6 +392,47 @@ export function MessageInlineVideo({
-{formatClock(remaining)} -{formatClock(remaining)}
</span> </span>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleMute();
}}
className={`flex shrink-0 items-center justify-center text-white transition hover:scale-105 ${t.btn}`}
aria-label={isMuted || volume === 0 ? "Unmute" : "Mute"}
>
{isMuted || volume === 0 ? (
<VolumeX className={t.btnIcon} strokeWidth={2.2} />
) : (
<Volume2 className={t.btnIcon} strokeWidth={2.2} />
)}
</button>
{/* Always-visible inline straight-line volume slider (desktop only;
on touch devices users rely on the mute toggle + system volume).
The left portion of the track is filled white via a hard-stop
gradient so the user can see the current level at a glance. */}
<input
type="range"
min={0}
max={1}
step={0.05}
value={isMuted ? 0 : volume}
onChange={(e) => handleVolumeChange(Number(e.target.value))}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
aria-label="Volume"
style={{
backgroundImage: `linear-gradient(to right, #ffffff 0%, #ffffff ${
(isMuted ? 0 : volume) * 100
}%, rgba(255,255,255,0.3) ${
(isMuted ? 0 : volume) * 100
}%, rgba(255,255,255,0.3) 100%)`,
}}
className="hidden h-1 w-20 cursor-pointer appearance-none rounded-full accent-white md:block"
/>
</div>
{hideFullscreen ? null : ( {hideFullscreen ? null : (
<button <button
type="button" type="button"

View File

@@ -288,7 +288,11 @@ export function usePostStream(params: PostStreamParams): PostStreamResult {
return; return;
} }
restoredFromCacheRef.current = false; restoredFromCacheRef.current = false;
setItems([]); // Stale-while-revalidate: keep showing whatever items were on screen for
// the previous params (e.g. previous locale) while the new fetch runs.
// Clearing to [] here was the root cause of the language-switch flicker
// — the stream went blank between the click and the network response.
// fetchPage(true) below will replace items wholesale once data arrives.
cursorRef.current = undefined; cursorRef.current = undefined;
setHasMore(true); setHasMore(true);
hasMoreRef.current = true; hasMoreRef.current = true;

View File

@@ -50,14 +50,6 @@ header button {
} }
} }
.header-nav-scroll {
-ms-overflow-style: none;
scrollbar-width: none;
}
.header-nav-scroll::-webkit-scrollbar {
display: none;
}
.gold-underline { .gold-underline {
box-shadow: inset 0 -2px 0 #eeb726; box-shadow: inset 0 -2px 0 #eeb726;
} }

View File

@@ -6,7 +6,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { AnimatePresence, m } from "framer-motion"; import { AnimatePresence, m } from "framer-motion";
import { useEffect, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Link, useLocation, useNavigate, useOutlet } from "react-router-dom"; import { Link, useLocation, useNavigate, useOutlet } from "react-router-dom";
import { pageTransition } from "../motion"; import { pageTransition } from "../motion";
import { ArkLogoMark } from "../components/ArkLogoMark"; import { ArkLogoMark } from "../components/ArkLogoMark";
@@ -309,6 +309,17 @@ export function PublicLayout() {
const desktopMenuButtonRef = useRef<HTMLButtonElement>(null); const desktopMenuButtonRef = useRef<HTMLButtonElement>(null);
const desktopSearchRef = useRef<HTMLDivElement>(null); const desktopSearchRef = useRef<HTMLDivElement>(null);
const desktopSearchPanelRef = useRef<HTMLDivElement>(null); const desktopSearchPanelRef = useRef<HTMLDivElement>(null);
// Runtime fit detection for the desktop nav. Different languages and page
// titles change the natural width of the nav and brand, so a fixed CSS
// breakpoint (e.g. `xl:flex`) clips or overlaps text in some translations.
// Instead we render a hidden “ghost” nav, measure its scrollWidth against
// the space left after the brand + right-side actions, and toggle to the
// burger menu the moment it would no longer fit.
const [showInlineNav, setShowInlineNav] = useState(true);
const headerRowRef = useRef<HTMLDivElement>(null);
const headerBrandRef = useRef<HTMLDivElement>(null);
const headerRightRef = useRef<HTMLDivElement>(null);
const ghostNavRef = useRef<HTMLElement>(null);
const nav = useNavigate(); const nav = useNavigate();
const lp = useLocalizedPath(); const lp = useLocalizedPath();
@@ -387,6 +398,54 @@ export function PublicLayout() {
}, [lang]); }, [lang]);
const popularHref = lp("/browse?sort=popular"); const popularHref = lp("/browse?sort=popular");
// Decide whether the inline nav fits next to the brand and right-side
// actions for the current language. Hysteresis (60px) on the
// burger -> inline transition keeps the layout from oscillating when the
// right-side width shrinks slightly after we hide the burger button.
useLayoutEffect(() => {
const row = headerRowRef.current;
const brand = headerBrandRef.current;
const right = headerRightRef.current;
const ghost = ghostNavRef.current;
if (!row || !brand || !right || !ghost) return;
const measure = () => {
const rowWidth = row.clientWidth;
if (rowWidth === 0) return;
const rowStyle = window.getComputedStyle(row);
const rowGap = parseFloat(rowStyle.columnGap || rowStyle.gap || "0");
const totalGap = rowGap * 2; // brand <-> nav <-> right
const available =
rowWidth - brand.offsetWidth - right.offsetWidth - totalGap;
const needed = ghost.scrollWidth;
setShowInlineNav((current) => {
if (current) return needed <= available;
// Hysteresis has to be wider than the right-side width difference
// between the two modes (“My Favorites / Kegemaran Saya” label
// shows in inline mode, ~80px). 60px was not enough and produced a
// dead zone (~12821302px in ms) where the header oscillated each
// frame. 120px keeps switching one-way — once were in burger we
// only flip back when theres real headroom.
return needed + 120 <= available;
});
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(row);
ro.observe(ghost);
ro.observe(brand);
ro.observe(right);
return () => ro.disconnect();
}, [lang, pageTitle, t]);
// When the layout grows enough that we move back to the inline nav, the
// burger drawer would otherwise stay stuck open with no visible toggle.
useEffect(() => {
if (showInlineNav) setOpen(false);
}, [showInlineNav]);
const goSearch = () => { const goSearch = () => {
const s = q.trim(); const s = q.trim();
if (!s) return; if (!s) return;
@@ -610,10 +669,19 @@ export function PublicLayout() {
</div> </div>
</div> </div>
<div className="mx-auto hidden max-w-[1280px] px-4 py-[15px] min-[440px]:px-5 sm:px-6 md:block md:px-9 xl:px-6"> <div className="hidden w-full px-4 py-[15px] min-[440px]:px-5 sm:px-6 md:block md:px-9 xl:px-10">
{/* Single row (md+): logo | scrollable nav (左對齊,可橫向滑動) | 搜尋 + 語言 */} {/* Single row (md+): logo | nav | search + language. The nav and the
<div className="flex h-10 items-center gap-2 min-[1000px]:gap-0 lg:gap-4"> burger toggle are driven by a runtime fit measurement (see
<div className="flex min-w-0 shrink items-center gap-2.5 text-xl font-bold tracking-wide text-ark-gold"> useLayoutEffect above) instead of a fixed Tailwind breakpoint,
so each language collapses to the burger at its own width. */}
<div
ref={headerRowRef}
className="relative flex h-10 items-center gap-2 xl:gap-4"
>
<div
ref={headerBrandRef}
className="flex min-w-0 shrink items-center gap-2.5 text-xl font-bold tracking-wide text-ark-gold"
>
{/* Logo → home; page-name text → scroll to top of the current page. */} {/* Logo → home; page-name text → scroll to top of the current page. */}
<Link <Link
to={homePath} to={homePath}
@@ -637,55 +705,79 @@ export function PublicLayout() {
</button> </button>
</div> </div>
{/* Hidden measurement copy of the nav. Always rendered so the
ResizeObserver can ask the browser “how wide would the inline
nav need to be?” without flickering the visible layout. */}
<nav <nav
className="header-nav-scroll hidden min-w-0 flex-1 items-center justify-center gap-4 overflow-x-auto overflow-y-hidden py-0.5 min-[1000px]:flex lg:gap-5" ref={ghostNavRef}
aria-label={t("mainNav")} aria-hidden
tabIndex={-1}
className="pointer-events-none invisible absolute left-0 top-0 flex h-10 items-center gap-4 py-0.5 whitespace-nowrap xl:gap-5"
> >
<Link <span className={navClassName(false)}>{t("all")}</span>
to={lp("/browse")} <span className={navClassName(false)}>{t("categories")}</span>
className={navClassName(na("browseAll"))} <span className={navClassName(false)}>{t("official")}</span>
aria-current={na("browseAll") ? "page" : undefined} <span className={navClassName(false)}>{t("latest")}</span>
> <span className={navClassName(false)}>{t("favorites")}</span>
{t("all")} <span className={navClassName(false)}>{t("popular")}</span>
</Link>
<Link
to={lp("/categories")}
className={navClassName(na("categories"))}
aria-current={na("categories") ? "page" : undefined}
>
{t("categories")}
</Link>
<Link
to={lp("/official-recommendations")}
className={navClassName(na("browseRecommended"))}
aria-current={na("browseRecommended") ? "page" : undefined}
>
{t("official")}
</Link>
<Link
to={lp("/browse?sort=latest")}
className={navClassName(na("browseLatest"))}
aria-current={na("browseLatest") ? "page" : undefined}
>
{t("latest")}
</Link>
<Link
to={lp("/favorites")}
className={navClassName(na("favorites"))}
aria-current={na("favorites") ? "page" : undefined}
>
{t("favorites")}
</Link>
<Link
to={popularHref}
className={navClassName(na("browsePopular"))}
aria-current={na("browsePopular") ? "page" : undefined}
>
{t("popular")}
</Link>
</nav> </nav>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 min-[1000px]:flex-none"> {showInlineNav ? (
<nav
className="flex min-w-0 flex-1 items-center justify-center gap-4 py-0.5 xl:gap-5"
aria-label={t("mainNav")}
>
<Link
to={lp("/browse")}
className={navClassName(na("browseAll"))}
aria-current={na("browseAll") ? "page" : undefined}
>
{t("all")}
</Link>
<Link
to={lp("/categories")}
className={navClassName(na("categories"))}
aria-current={na("categories") ? "page" : undefined}
>
{t("categories")}
</Link>
<Link
to={lp("/official-recommendations")}
className={navClassName(na("browseRecommended"))}
aria-current={na("browseRecommended") ? "page" : undefined}
>
{t("official")}
</Link>
<Link
to={lp("/browse?sort=latest")}
className={navClassName(na("browseLatest"))}
aria-current={na("browseLatest") ? "page" : undefined}
>
{t("latest")}
</Link>
<Link
to={lp("/favorites")}
className={navClassName(na("favorites"))}
aria-current={na("favorites") ? "page" : undefined}
>
{t("favorites")}
</Link>
<Link
to={popularHref}
className={navClassName(na("browsePopular"))}
aria-current={na("browsePopular") ? "page" : undefined}
>
{t("popular")}
</Link>
</nav>
) : (
<div className="min-w-0 flex-1" />
)}
<div
ref={headerRightRef}
className="flex shrink-0 items-center justify-end gap-2"
>
<div ref={desktopSearchRef} className="hidden md:block"> <div ref={desktopSearchRef} className="hidden md:block">
<button <button
type="button" type="button"
@@ -718,19 +810,30 @@ export function PublicLayout() {
aria-current={na("favorites") ? "page" : undefined} aria-current={na("favorites") ? "page" : undefined}
> >
<Heart className="h-[18px] w-[18px]" strokeWidth={2.2} /> <Heart className="h-[18px] w-[18px]" strokeWidth={2.2} />
<span className="hidden xl:inline">{t("favorites")}</span> {showInlineNav ? (
<span className="inline">{t("favorites")}</span>
) : null}
</Link> </Link>
<div className="hidden md:block"> <div className="hidden md:block">
<WalletButton /> <WalletButton />
</div> </div>
{/* Burger toggle is always in the DOM so the right-side width
doesnt change between inline and burger modes. The visible
/ invisible toggle controls click-ability without resizing
the row, which would otherwise feed back into the fit
measurement and cause oscillation at borderline widths. */}
<button <button
ref={desktopMenuButtonRef} ref={desktopMenuButtonRef}
type="button" type="button"
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-ark-line bg-[#1a1b20] text-neutral-200 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg min-[1000px]:hidden" className={`inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-ark-line bg-[#1a1b20] text-neutral-200 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
showInlineNav ? "pointer-events-none invisible" : ""
}`}
tabIndex={showInlineNav ? -1 : 0}
onClick={() => { onClick={() => {
setDesktopSearchOpen(false); setDesktopSearchOpen(false);
setOpen((v) => !v); setOpen((v) => !v);
}} }}
aria-hidden={showInlineNav}
aria-label="menu" aria-label="menu"
> >
{open ? <X size={18} /> : <Menu size={18} />} {open ? <X size={18} /> : <Menu size={18} />}
@@ -743,7 +846,7 @@ export function PublicLayout() {
{open ? ( {open ? (
<div <div
ref={menuRef} 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`} 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]`}
> >
<nav className="flex-1 overflow-y-auto px-5"> <nav className="flex-1 overflow-y-auto px-5">
{( {(
@@ -836,7 +939,12 @@ export function PublicLayout() {
> >
<AnimatePresence mode="wait" initial={false}> <AnimatePresence mode="wait" initial={false}>
<m.div <m.div
key={`${pathname}${search}`} // Key on the language-stripped path so switching locale (e.g.
// /cn/browse → /ms/browse) is not seen as a route change. Without
// this the AnimatePresence fade-out/fade-in (~220ms) fires on every
// language toggle and reads as a flicker, especially for ms / vi
// where the nav width also changes and re-runs layout.
key={`${stripLangPrefix(pathname)}${search}`}
variants={pageTransition} variants={pageTransition}
initial="initial" initial="initial"
animate="enter" animate="enter"

View File

@@ -14,6 +14,8 @@ const slugToAsset: Record<string, string> = {
"media-coverage": "svg/news-record.svg", "media-coverage": "svg/news-record.svg",
"academy-video": "png/academy-video.png", "academy-video": "png/academy-video.png",
"acedemy-video": "png/academy-video.png", "acedemy-video": "png/academy-video.png",
"contract-address": "png/contract-address.png",
"data-records": "png/data-records.png",
general: "svg/general.svg", general: "svg/general.svg",
}; };

View File

@@ -252,6 +252,10 @@ export const enDict: Dict = {
walletLoginFailed: "Wallet login failed", walletLoginFailed: "Wallet login failed",
walletNoAccount: walletNoAccount:
"No wallet account was returned. Unlock your wallet and select an account, then try again.", "No wallet account was returned. Unlock your wallet and select an account, then try again.",
walletRequestCanceled:
"Wallet connection was canceled. Unlock your wallet, choose an account, and authorize this site, then try again.",
walletBnbChainRequired:
"Create or switch to a BNB Smart Chain (BSC) wallet in your wallet app, then try again.",
walletDisconnected: "Wallet disconnected", walletDisconnected: "Wallet disconnected",
walletOtherMethods: "Other login methods", walletOtherMethods: "Other login methods",
walletUseCurrent: "Use current wallet", walletUseCurrent: "Use current wallet",

View File

@@ -254,6 +254,10 @@ export const idDict: Dict = {
walletLoginFailed: "Login dompet gagal", walletLoginFailed: "Login dompet gagal",
walletNoAccount: walletNoAccount:
"Dompet tidak mengembalikan akun apa pun. Buka kunci dompet, pilih akun, lalu coba lagi.", "Dompet tidak mengembalikan akun apa pun. Buka kunci dompet, pilih akun, lalu coba lagi.",
walletRequestCanceled:
"Koneksi dompet dibatalkan. Buka kunci dompet, pilih akun, dan izinkan situs ini, lalu coba lagi.",
walletBnbChainRequired:
"Buat atau beralih ke dompet BNB Smart Chain (BSC) di aplikasi dompet Anda, lalu coba lagi.",
walletDisconnected: "Dompet terputus", walletDisconnected: "Dompet terputus",
walletOtherMethods: "Metode login lainnya", walletOtherMethods: "Metode login lainnya",
walletUseCurrent: "Gunakan dompet saat ini", walletUseCurrent: "Gunakan dompet saat ini",

View File

@@ -205,6 +205,10 @@ export const jaDict: Dict = {
walletLoginFailed: "ウォレットログインに失敗しました", walletLoginFailed: "ウォレットログインに失敗しました",
walletNoAccount: walletNoAccount:
"ウォレットからアカウントが返されませんでした。ウォレットのロックを解除してアカウントを選択し、もう一度お試しください。", "ウォレットからアカウントが返されませんでした。ウォレットのロックを解除してアカウントを選択し、もう一度お試しください。",
walletRequestCanceled:
"ウォレット接続がキャンセルされました。ウォレットのロックを解除し、アカウントを選択してこのサイトを承認してから、もう一度お試しください。",
walletBnbChainRequired:
"ウォレットアプリで BNB Smart ChainBSCウォレットを作成または切り替えてから、もう一度お試しください。",
walletDisconnected: "ウォレットを切断しました", walletDisconnected: "ウォレットを切断しました",
walletNoBrowserWallet: "ブラウザウォレットが見つかりません", walletNoBrowserWallet: "ブラウザウォレットが見つかりません",
walletNoBrowserWalletDesc: walletNoBrowserWalletDesc:

View File

@@ -251,6 +251,10 @@ export const koDict: Dict = {
walletLoginFailed: "지갑 로그인에 실패했습니다", walletLoginFailed: "지갑 로그인에 실패했습니다",
walletNoAccount: walletNoAccount:
"지갑에서 계정이 반환되지 않았습니다. 지갑 잠금을 해제하고 계정을 선택한 후 다시 시도하세요.", "지갑에서 계정이 반환되지 않았습니다. 지갑 잠금을 해제하고 계정을 선택한 후 다시 시도하세요.",
walletRequestCanceled:
"지갑 연결이 취소되었습니다. 지갑 잠금을 해제하고 계정을 선택한 뒤 이 사이트를 승인한 후 다시 시도하세요.",
walletBnbChainRequired:
"지갑 앱에서 BNB Smart Chain(BSC) 지갑을 만들거나 전환한 후 다시 시도하세요.",
walletDisconnected: "지갑 연결이 해제되었습니다", walletDisconnected: "지갑 연결이 해제되었습니다",
walletOtherMethods: "다른 로그인 방법", walletOtherMethods: "다른 로그인 방법",
walletUseCurrent: "현재 지갑 사용", walletUseCurrent: "현재 지갑 사용",

View File

@@ -253,6 +253,10 @@ export const msDict: Dict = {
walletLoginFailed: "Log masuk dompet gagal", walletLoginFailed: "Log masuk dompet gagal",
walletNoAccount: walletNoAccount:
"Dompet tidak mengembalikan sebarang akaun. Nyahkunci dompet, pilih akaun, kemudian cuba lagi.", "Dompet tidak mengembalikan sebarang akaun. Nyahkunci dompet, pilih akaun, kemudian cuba lagi.",
walletRequestCanceled:
"Sambungan dompet dibatalkan. Nyahkunci dompet, pilih akaun dan benarkan laman ini, kemudian cuba lagi.",
walletBnbChainRequired:
"Cipta atau tukar kepada dompet BNB Smart Chain (BSC) dalam aplikasi dompet anda, kemudian cuba lagi.",
walletDisconnected: "Dompet diputuskan", walletDisconnected: "Dompet diputuskan",
walletOtherMethods: "Kaedah log masuk lain", walletOtherMethods: "Kaedah log masuk lain",
walletUseCurrent: "Guna dompet semasa", walletUseCurrent: "Guna dompet semasa",

View File

@@ -249,6 +249,10 @@ export const viDict: Dict = {
walletLoginFailed: "Đăng nhập ví thất bại", walletLoginFailed: "Đăng nhập ví thất bại",
walletNoAccount: 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.", "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.",
walletRequestCanceled:
"Kết nối ví đã bị hủy. Hãy mở khóa ví, chọn tài khoản và cấp quyền cho trang này rồi thử lại.",
walletBnbChainRequired:
"Hãy tạo hoặc chuyển sang ví BNB Smart Chain (BSC) trong ứng dụng ví, rồi thử lại.",
walletDisconnected: "Đã ngắt kết nối ví", walletDisconnected: "Đã ngắt kết nối ví",
walletOtherMethods: "Phương thức đăng nhập khác", walletOtherMethods: "Phương thức đăng nhập khác",
walletUseCurrent: "Dùng ví hiện tại", walletUseCurrent: "Dùng ví hiện tại",

View File

@@ -233,6 +233,10 @@ export const zhDict: Dict = {
walletLoginSuccess: "钱包已连接", walletLoginSuccess: "钱包已连接",
walletLoginFailed: "钱包登录失败", walletLoginFailed: "钱包登录失败",
walletNoAccount: "钱包没有返回账号。请先解锁钱包并选择一个账号后重试。", walletNoAccount: "钱包没有返回账号。请先解锁钱包并选择一个账号后重试。",
walletRequestCanceled:
"钱包连接已取消。请解锁钱包,选择账号并授权本站后重试。",
walletBnbChainRequired:
"请先在钱包 App 创建或切换到 BNB Smart ChainBSC钱包然后重试。",
walletDisconnected: "钱包已断开", walletDisconnected: "钱包已断开",
walletOtherMethods: "其他登录方式", walletOtherMethods: "其他登录方式",
walletUseCurrent: "使用当前钱包登录", walletUseCurrent: "使用当前钱包登录",

View File

@@ -2,6 +2,7 @@ import { useEffect } from "react";
import { import {
connectInjectedWallet, connectInjectedWallet,
getInjectedWallet, getInjectedWallet,
isImTokenBrowser,
type WalletKind, type WalletKind,
} from "./injected"; } from "./injected";
import { useWallet } from "./WalletProvider"; import { useWallet } from "./WalletProvider";
@@ -11,18 +12,23 @@ const ETHEREUM_WAIT_MS = 8000;
const ETHEREUM_POLL_MS = 200; const ETHEREUM_POLL_MS = 200;
function parseKind(value: string | null): WalletKind | null { function parseKind(value: string | null): WalletKind | null {
if (value === "tokenPocket" || value === "metaMask" || value === "imToken") { const normalized = value?.toLowerCase();
return value; if (normalized === "tokenpocket") return "tokenPocket";
} if (normalized === "metamask") return "metaMask";
if (normalized === "imtoken") return "imToken";
return null; return null;
} }
function autoLoginKindFromParams(params: URLSearchParams): WalletKind | null { function autoLoginKind(params: URLSearchParams): WalletKind | null {
for (const key of AUTO_LOGIN_PARAMS) { for (const key of AUTO_LOGIN_PARAMS) {
const kind = parseKind(params.get(key)); const kind = parseKind(params.get(key));
if (kind) return kind; if (kind) return kind;
} }
return null; return isImTokenBrowser() ? "imToken" : null;
}
function hasAutoLoginParam(params: URLSearchParams): boolean {
return AUTO_LOGIN_PARAMS.some((key) => params.has(key));
} }
function stripAutoLoginParam(): void { function stripAutoLoginParam(): void {
@@ -57,10 +63,10 @@ export function AutoInjectedLogin() {
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const kind = autoLoginKindFromParams(params); const kind = autoLoginKind(params);
if (!kind) return; if (!kind) return;
stripAutoLoginParam(); if (hasAutoLoginParam(params)) stripAutoLoginParam();
if (status === "loggedIn") return; if (status === "loggedIn") return;
let cancelled = false; let cancelled = false;

View File

@@ -15,13 +15,22 @@ export type EthereumProvider = {
isMetaMask?: boolean; isMetaMask?: boolean;
isTokenPocket?: boolean; isTokenPocket?: boolean;
isImToken?: boolean; isImToken?: boolean;
selectedAddress?: string;
providers?: EthereumProvider[]; providers?: EthereumProvider[];
enable?: () => Promise<unknown[]>;
request: <T = unknown>(args: { request: <T = unknown>(args: {
method: string; method: string;
params?: unknown[]; params?: unknown[];
}) => Promise<T>; }) => Promise<T>;
}; };
type LegacyWeb3 = {
currentProvider?: EthereumProvider;
eth?: {
getAccounts?: () => Promise<unknown[]>;
};
};
function isAddress(value: unknown): value is string { function isAddress(value: unknown): value is string {
return typeof value === "string" && /^0x[a-fA-F0-9]{40}$/.test(value); return typeof value === "string" && /^0x[a-fA-F0-9]{40}$/.test(value);
} }
@@ -38,13 +47,33 @@ function errorText(error: unknown): string {
return parts.join("\n"); return parts.join("\n");
} }
function errorCode(error: unknown): string {
if (!error || typeof error !== "object") return "";
const code = (error as Record<string, unknown>).code;
return typeof code === "string" || typeof code === "number"
? String(code)
: "";
}
function isNoAccountError(error: unknown): boolean { 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( 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), errorText(error),
); );
} }
function isUserCanceledError(error: unknown): boolean {
const text = errorText(error);
const code = errorCode(error);
return (
code === "4001" ||
/user[_ -]?cancell?ed|user rejected|request rejected|request denied|action rejected/i.test(
text,
)
);
}
function normalizeWalletError(error: unknown): Error { function normalizeWalletError(error: unknown): Error {
if (isUserCanceledError(error)) return new Error("walletRequestCanceled");
if (isNoAccountError(error)) return new Error("walletNoAccount"); if (isNoAccountError(error)) return new Error("walletNoAccount");
if (error instanceof Error) return error; if (error instanceof Error) return error;
const message = errorText(error); const message = errorText(error);
@@ -72,9 +101,22 @@ async function ensureBnbChain(ethereum: EthereumProvider): Promise<void> {
} }
} }
function selectedInjectedAddress(ethereum: EthereumProvider): string | null {
return isAddress(ethereum.selectedAddress) ? ethereum.selectedAddress : null;
}
async function requestLegacyAccounts(): Promise<unknown[]> {
if (typeof window === "undefined") return [];
const maybeWindow = window as typeof window & { web3?: LegacyWeb3 };
return maybeWindow.web3?.eth?.getAccounts?.().catch(() => []) ?? [];
}
async function requestInjectedAddress( async function requestInjectedAddress(
ethereum: EthereumProvider, ethereum: EthereumProvider,
): Promise<string> { ): Promise<string> {
const selectedAddress = selectedInjectedAddress(ethereum);
if (selectedAddress) return selectedAddress;
const existingAccounts: unknown[] = await ethereum const existingAccounts: unknown[] = await ethereum
.request<unknown[]>({ method: "eth_accounts" }) .request<unknown[]>({ method: "eth_accounts" })
.catch((): unknown[] => []); .catch((): unknown[] => []);
@@ -85,18 +127,37 @@ async function requestInjectedAddress(
.request<unknown[]>({ .request<unknown[]>({
method: "eth_requestAccounts", method: "eth_requestAccounts",
}) })
.catch((error: unknown): never => { .catch(async (error: unknown): Promise<unknown[]> => {
throw normalizeWalletError(error); if (!ethereum.enable) throw normalizeWalletError(error);
return ethereum.enable().catch((fallbackError: unknown): never => {
throw normalizeWalletError(fallbackError || error);
});
}); });
const requestedAddress = requestedAccounts.find(isAddress); const requestedAddress = requestedAccounts.find(isAddress);
if (!requestedAddress) throw new Error("walletNoAccount"); if (requestedAddress) return requestedAddress;
return requestedAddress;
const enabledAccounts: unknown[] = ethereum.enable
? await ethereum.enable().catch((): unknown[] => [])
: [];
const enabledAddress = enabledAccounts.find(isAddress);
if (enabledAddress) return enabledAddress;
const legacyAccounts = await requestLegacyAccounts();
const legacyAddress = legacyAccounts.find(isAddress);
if (legacyAddress) return legacyAddress;
const latestSelectedAddress = selectedInjectedAddress(ethereum);
if (latestSelectedAddress) return latestSelectedAddress;
throw new Error("walletNoAccount");
} }
export function getInjectedEthereum(): EthereumProvider | null { export function getInjectedEthereum(): EthereumProvider | null {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
const maybeWindow = window as typeof window & { ethereum?: EthereumProvider }; const maybeWindow = window as typeof window & {
return maybeWindow.ethereum ?? null; ethereum?: EthereumProvider;
web3?: LegacyWeb3;
};
return maybeWindow.ethereum ?? maybeWindow.web3?.currentProvider ?? null;
} }
export function isTokenPocketBrowser(): boolean { export function isTokenPocketBrowser(): boolean {
@@ -168,7 +229,13 @@ export async function connectInjectedWallet(
console.info("[wallet-login] injected account", address); console.info("[wallet-login] injected account", address);
console.info("[wallet-login] ensuring BNB Chain (0x38)…"); console.info("[wallet-login] ensuring BNB Chain (0x38)…");
await ensureBnbChain(ethereum); await ensureBnbChain(ethereum).catch((error: unknown) => {
console.warn("[wallet-login] BNB Chain switch failed", error);
if (kind === "imToken" || kind === "tokenPocket") {
throw new Error("walletBnbChainRequired");
}
throw normalizeWalletError(error);
});
return address; return address;
} }

View File

@@ -33,17 +33,26 @@ export default {
"scale-in": "scale-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) both", "scale-in": "scale-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) both",
}, },
fontFamily: { fontFamily: {
// Put system Latin fonts FIRST so Latin script (incl. Vietnamese
// precomposed glyphs like ấ/ẳ/ệ) renders from a font that actually
// ships those code points. The CJK fonts that used to lead the stack
// (Noto Sans SC/TC, PingFang SC/TC, Microsoft YaHei/JhengHei) have no
// Vietnamese coverage, so the browser was greedily rendering the base
// Latin letter with a CJK font and rendering the combining tone mark
// separately, producing “Tâ´t / liêụ” instead of “Tất / liệu”.
// Per-glyph fallback still picks up the CJK fonts for Chinese
// characters, so existing zh-CN / zh-TW pages keep the same look.
sans: [ sans: [
"ui-sans-serif",
"system-ui",
"-apple-system",
"Segoe UI",
"Noto Sans SC", "Noto Sans SC",
"Noto Sans TC", "Noto Sans TC",
"PingFang SC", "PingFang SC",
"PingFang TC", "PingFang TC",
"Microsoft YaHei", "Microsoft YaHei",
"Microsoft JhengHei", "Microsoft JhengHei",
"ui-sans-serif",
"system-ui",
"-apple-system",
"Segoe UI",
"sans-serif", "sans-serif",
], ],
}, },