Compare commits
23 Commits
terry-wall
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c882cce0a4 | ||
|
|
41d737da11 | ||
|
|
5724d2b08c | ||
|
|
0b8c4fe1f7 | ||
|
|
78186486c5 | ||
|
|
03a5701798 | ||
|
|
b5a699c6c7 | ||
|
|
33d03d1cc7 | ||
|
|
2a702f4e12 | ||
|
|
6aaa9573e7 | ||
|
|
9b6539ff71 | ||
|
|
8c1dd8189e | ||
|
|
915d88b3ac | ||
|
|
f97e367dde | ||
|
|
9821f03929 | ||
|
|
a4cb4f496d | ||
|
|
24e22a3f25 | ||
|
|
2b5ec54896 | ||
|
|
fe8dcee9a1 | ||
|
|
5408a86cc9 | ||
|
|
9c4b8a4df7 | ||
| cbaa06f77d | |||
| 85a24ef982 |
@@ -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
|
fi
|
||||||
done
|
if [ -n "$DISK_NAME" ] && [ -n "$PART_NUM" ]; then
|
||||||
# Docker leftovers if docker is available.
|
if ! command -v growpart >/dev/null 2>&1; then
|
||||||
if command -v docker >/dev/null 2>&1; then
|
sudo dnf -y install cloud-utils-growpart || sudo yum -y install cloud-utils-growpart || true
|
||||||
docker image prune -af --filter "until=24h" 2>/dev/null
|
fi
|
||||||
docker container prune -f --filter "until=24h" 2>/dev/null
|
if command -v growpart >/dev/null 2>&1; then
|
||||||
docker builder prune -af --filter "until=24h" 2>/dev/null
|
sudo growpart "/dev/$DISK_NAME" "$PART_NUM" || true
|
||||||
|
else
|
||||||
|
echo "growpart not installed; cannot grow partition automatically."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$ROOT_FSTYPE" in
|
||||||
|
ext2|ext3|ext4) sudo resize2fs "$ROOT_SOURCE" || true ;;
|
||||||
|
xfs) sudo xfs_growfs / || true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
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
|
fi
|
||||||
# Stale /tmp files older than 2h, keep currently-running runner files.
|
|
||||||
find /tmp -mindepth 1 -maxdepth 1 -mmin +120 \
|
|
||||||
-not -name 'runner*' -not -name 'act*' \
|
|
||||||
-exec rm -rf {} + 2>/dev/null
|
|
||||||
echo "=== Disk after cleanup ==="
|
|
||||||
df -h
|
|
||||||
exit 0
|
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
34
.unipi/docs/fix/2026-06-06-imtoken-production-address-fix.md
Normal file
34
.unipi/docs/fix/2026-06-06-imtoken-production-address-fix.md
Normal 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.
|
||||||
40
.unipi/docs/fix/2026-06-06-imtoken-production-login-fix.md
Normal file
40
.unipi/docs/fix/2026-06-06-imtoken-production-login-fix.md
Normal 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`.
|
||||||
@@ -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. 在中等宽度(约 1000–1280px)`<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`(1280px),nav 出现时永远有足够空间,不需要横向滚动。
|
||||||
|
- 删除 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 测试通过。
|
||||||
|
- 行为预期:
|
||||||
|
- 视口 ≥1280px:nav 完整显示,无任何边缘裁切;Header 内容随视口加宽继续 expand。
|
||||||
|
- 视口 <1280px:自动切换到 burger 抽屉(原本 1000–1280 的横向滚动 nav 不再出现)。
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- 中等宽度(1000–1280)原本能直接看到 nav,现在改成 burger,这是为了完全消除文字裁切。是否要保留中等宽度的 nav 是设计取舍,目前以用户「不允许文字被裁」的要求为优先。
|
||||||
|
- 主内容 `<main>` 仍保留 `max-w-[1280px]`;如需主内容也跟随扩展,可后续单独调整。
|
||||||
40
.unipi/docs/fix/2026-06-07-video-volume-control-fix.md
Normal file
40
.unipi/docs/fix/2026-06-07-video-volume-control-fix.md
Normal 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~1,step 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:` 以上显示),移动端通过按钮 + 系统音量键操作,避免在小气泡里拥挤。
|
||||||
@@ -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.
|
||||||
|
|||||||
BIN
public/assets/ark-library/media/png/contract-address.png
Normal file
BIN
public/assets/ark-library/media/png/contract-address.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 288 KiB |
BIN
public/assets/ark-library/media/png/data-records.png
Normal file
BIN
public/assets/ark-library/media/png/data-records.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 282 KiB |
@@ -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]);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (~1282–1302px in ms) where the header oscillated each
|
||||||
|
// frame. 120px keeps switching one-way — once we’re in burger we
|
||||||
|
// only flip back when there’s 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,8 +705,26 @@ 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-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"
|
||||||
|
>
|
||||||
|
<span className={navClassName(false)}>{t("all")}</span>
|
||||||
|
<span className={navClassName(false)}>{t("categories")}</span>
|
||||||
|
<span className={navClassName(false)}>{t("official")}</span>
|
||||||
|
<span className={navClassName(false)}>{t("latest")}</span>
|
||||||
|
<span className={navClassName(false)}>{t("favorites")}</span>
|
||||||
|
<span className={navClassName(false)}>{t("popular")}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{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")}
|
aria-label={t("mainNav")}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
@@ -684,8 +770,14 @@ export function PublicLayout() {
|
|||||||
{t("popular")}
|
{t("popular")}
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
) : (
|
||||||
|
<div className="min-w-0 flex-1" />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 min-[1000px]:flex-none">
|
<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
|
||||||
|
doesn’t 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"
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -205,6 +205,10 @@ export const jaDict: Dict = {
|
|||||||
walletLoginFailed: "ウォレットログインに失敗しました",
|
walletLoginFailed: "ウォレットログインに失敗しました",
|
||||||
walletNoAccount:
|
walletNoAccount:
|
||||||
"ウォレットからアカウントが返されませんでした。ウォレットのロックを解除してアカウントを選択し、もう一度お試しください。",
|
"ウォレットからアカウントが返されませんでした。ウォレットのロックを解除してアカウントを選択し、もう一度お試しください。",
|
||||||
|
walletRequestCanceled:
|
||||||
|
"ウォレット接続がキャンセルされました。ウォレットのロックを解除し、アカウントを選択してこのサイトを承認してから、もう一度お試しください。",
|
||||||
|
walletBnbChainRequired:
|
||||||
|
"ウォレットアプリで BNB Smart Chain(BSC)ウォレットを作成または切り替えてから、もう一度お試しください。",
|
||||||
walletDisconnected: "ウォレットを切断しました",
|
walletDisconnected: "ウォレットを切断しました",
|
||||||
walletNoBrowserWallet: "ブラウザウォレットが見つかりません",
|
walletNoBrowserWallet: "ブラウザウォレットが見つかりません",
|
||||||
walletNoBrowserWalletDesc:
|
walletNoBrowserWalletDesc:
|
||||||
|
|||||||
@@ -251,6 +251,10 @@ export const koDict: Dict = {
|
|||||||
walletLoginFailed: "지갑 로그인에 실패했습니다",
|
walletLoginFailed: "지갑 로그인에 실패했습니다",
|
||||||
walletNoAccount:
|
walletNoAccount:
|
||||||
"지갑에서 계정이 반환되지 않았습니다. 지갑 잠금을 해제하고 계정을 선택한 후 다시 시도하세요.",
|
"지갑에서 계정이 반환되지 않았습니다. 지갑 잠금을 해제하고 계정을 선택한 후 다시 시도하세요.",
|
||||||
|
walletRequestCanceled:
|
||||||
|
"지갑 연결이 취소되었습니다. 지갑 잠금을 해제하고 계정을 선택한 뒤 이 사이트를 승인한 후 다시 시도하세요.",
|
||||||
|
walletBnbChainRequired:
|
||||||
|
"지갑 앱에서 BNB Smart Chain(BSC) 지갑을 만들거나 전환한 후 다시 시도하세요.",
|
||||||
walletDisconnected: "지갑 연결이 해제되었습니다",
|
walletDisconnected: "지갑 연결이 해제되었습니다",
|
||||||
walletOtherMethods: "다른 로그인 방법",
|
walletOtherMethods: "다른 로그인 방법",
|
||||||
walletUseCurrent: "현재 지갑 사용",
|
walletUseCurrent: "현재 지갑 사용",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -233,6 +233,10 @@ export const zhDict: Dict = {
|
|||||||
walletLoginSuccess: "钱包已连接",
|
walletLoginSuccess: "钱包已连接",
|
||||||
walletLoginFailed: "钱包登录失败",
|
walletLoginFailed: "钱包登录失败",
|
||||||
walletNoAccount: "钱包没有返回账号。请先解锁钱包并选择一个账号后重试。",
|
walletNoAccount: "钱包没有返回账号。请先解锁钱包并选择一个账号后重试。",
|
||||||
|
walletRequestCanceled:
|
||||||
|
"钱包连接已取消。请解锁钱包,选择账号并授权本站后重试。",
|
||||||
|
walletBnbChainRequired:
|
||||||
|
"请先在钱包 App 创建或切换到 BNB Smart Chain(BSC)钱包,然后重试。",
|
||||||
walletDisconnected: "钱包已断开",
|
walletDisconnected: "钱包已断开",
|
||||||
walletOtherMethods: "其他登录方式",
|
walletOtherMethods: "其他登录方式",
|
||||||
walletUseCurrent: "使用当前钱包登录",
|
walletUseCurrent: "使用当前钱包登录",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user