361 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
TerryM
ae84e73736 fix: keep popular card hover ring inset
All checks were successful
Deploy Staging (terry-wallet-login) / deploy (push) Successful in 1m5s
2026-06-06 00:30:18 +08:00
TerryM
bd48fded30 fix: add favorites to public navigation
All checks were successful
Deploy Staging (terry-wallet-login) / deploy (push) Successful in 1m10s
2026-06-06 00:25:12 +08:00
TerryM
fd1a3f4b3e fix: favorites click opens isolated single-post view
When `linkToResource` is set, PopularRankRow now navigates to /resource/<id>?single=1 and PostRedirect propagates the `single=1` flag to the /browse?post=... target. MessageStream already supports singlePostMode (no filter bar, no sentinel, only the target post in the list), so favorites cards open the post in isolation instead of dropping the user into the surrounding stream.
2026-06-06 00:23:46 +08:00
TerryM
37e6e4901f fix: route favorites cards through /resource so they survive language switch
All checks were successful
Deploy Staging (terry-wallet-login) / deploy (push) Successful in 1m18s
Favorites are stored regardless of language but the card click was navigating to /browse?post=<id>&single=1 in the current UI language. When the user switched languages (e.g. zh→en) and tapped a zh-only favorite, the /browse stream couldn't find the post in its English view and showed "Couldn't find this post in the current view".

Make Favorites navigate via /resource/<id> instead. PostRedirect already tries the current UI language first, then falls back to the post's source language and shows a toast. Renamed PopularRankRow's seldom-used `singlePostLink` to `linkToResource` to make the intent obvious; Favorites passes the new prop.
2026-06-06 00:04:19 +08:00
TerryM
e80330b13c fix: stop language picker shrinking in flex header
All checks were successful
Deploy Staging (terry-wallet-login) / deploy (push) Successful in 1m5s
After dropping the fixed width, the language pill still got truncated because its flex parent could shrink it below content size. Add `shrink-0` to the wrapper and switch the trigger button from `w-full` to `w-auto` with `whitespace-nowrap`, so the button always sizes to fit the longest label like "Bahasa Indonesia".
2026-06-05 21:39:05 +08:00
TerryM
b38b28f175 fix: show full language name in the picker
The desktop language pill had a fixed width (md:w-36 lg:w-40) that truncated long labels like "Bahasa Indonesia". Drop the fixed width so the button sizes to content, remove the truncate on the label, and let the dropdown menu match parent width with a sensible min-width. Same `truncate` was hiding the full label inside the mobile drawer dropdown — bumped its width to fit too.
2026-06-05 21:31:43 +08:00
TerryM
ec8ef5b774 fix: hide URL preview in in-app download guide
All checks were successful
Deploy Staging (terry-wallet-login) / deploy (push) Successful in 1m8s
Drop the visible URL box from the modal — only the Copy link button remains. Copy still writes the absolute file download URL to the clipboard so the user can paste it into Chrome/Safari to trigger the download.
2026-06-05 21:17:34 +08:00
TerryM
4c684d75a3 Revert "fix: hide download URL in in-app browser guide"
All checks were successful
Deploy Staging (terry-wallet-login) / deploy (push) Successful in 1m18s
This reverts commit 3275aff121.
2026-06-05 21:09:42 +08:00
TerryM
ee3f2c43eb Revert "fix: download button on cards opens the post page"
This reverts commit 75ccfd78ed.
2026-06-05 21:09:42 +08:00
TerryM
75ccfd78ed fix: download button on cards opens the post page
On compact cards the user cannot see which file is attached, especially for multi-attachment posts. Make the small download button on PopularRankRow, RecommendedCard, and LatestUpdateCard navigate to the post detail page instead of triggering an immediate download, so the user lands in the full post and picks the exact attachment to download.
2026-06-05 19:36:54 +08:00
TerryM
69bef7ee6e fix: hide download button on favorites cards
All checks were successful
Deploy Staging (terry-wallet-login) / deploy (push) Successful in 1m8s
Add `showDownload` prop to PopularRankRow (default true to keep Home popular cards unchanged) and pass `showDownload={false}` from the favorites list.
2026-06-05 19:31:27 +08:00
TerryM
3275aff121 fix: hide download URL in in-app browser guide
All checks were successful
Deploy Staging (terry-wallet-login) / deploy (push) Successful in 1m3s
Stop exposing the internal /apnew/api/.../download URL in the guide. The dialog no longer renders the file URL or copies it to the clipboard; instead the user copies the current page link (window.location.href) and opens it in their system browser, then taps download again — which uses the real fetch+blob path. Updated step copy and intro in all 7 locales to match the new flow.
2026-06-05 19:15:10 +08:00
TerryM
356d8a0207 fix: use absolute URL in in-app download guide
All checks were successful
Deploy Staging (terry-wallet-login) / deploy (push) Successful in 1m10s
Production builds the frontend with VITE_API_URL="" so attachmentDownloadUrl() returns a relative path like /apnew/api/.../download. Pasting that into Safari from another origin fails. Convert the URL to an absolute one (window.location.origin + path) before showing it in the in-app browser guide, and update the modal text in all 7 locales to make clear the copied link is the direct file download URL that, when opened in the system browser, triggers the download automatically.
2026-06-05 19:10:44 +08:00
TerryM
7a33a62c8f fix: in-app browser download opens file inline
- Detect in-app WebViews (WeChat / TokenPocket / imToken / Telegram / iOS WKWebView, etc.) and show a guide modal asking the user to open the link in their system browser, with a copy-link action.
- For normal browsers, fetch the attachment as a Blob and trigger download from a same-origin object URL so the file always lands in the user's Downloads folder with the original filename, even when the browser would otherwise inline-preview the response.
- Fall back to the anchor download for files larger than 50MB (avoid loading them entirely into memory) or when fetch fails.
- Pass `sizeBytes` from known call sites so the threshold actually applies.
- Add localized strings for the guide modal in all 7 locales.

See .unipi/docs/debug/2026-06-05-in-app-browser-download-debug.md.
2026-06-05 19:06:53 +08:00
TerryM
abfd92b16a fix: avoid unnecessary favorites reloads
All checks were successful
Deploy Staging (terry-wallet-login) / deploy (push) Successful in 1m20s
2026-06-05 18:56:11 +08:00
TerryM
9f5367ae12 fix: show only selected post from favorites 2026-06-05 18:52:25 +08:00
TerryM
2d003c6fef fix: unify home download button sizing 2026-06-05 18:47:04 +08:00
TerryM
908f89ac24 fix: clarify multi-document downloads 2026-06-05 18:45:32 +08:00
TerryM
aae7faf9dd fix: adjust mobile popular card layout 2026-06-05 18:39:32 +08:00
TerryM
4e459aa4be fix: hide rank badges on favorites 2026-06-05 18:25:17 +08:00
TerryM
5d550e0342 fix: show download per document attachment 2026-06-05 18:20:27 +08:00
TerryM
a9ec46e008 fix: refresh favorites after unfavorite 2026-06-05 18:16:33 +08:00
TerryM
486c09dd39 fix: simplify mobile favorite button state 2026-06-05 18:14:48 +08:00
TerryM
292c745549 feat(wallet): redesign drawer wallet states to match Figma 4476-15287/15669
Disconnected state (Figma 4476-15287): bump the compact CTA to the
spec'd dimensions — h-12 (48px), text-[15px], font-medium — so the
yellow 链接钱包 button matches the Figma pill exactly while leaving
the desktop header pill (compact=false) untouched.

Connected state (Figma 4476-15669/16024): rebuild the compact branch as
the spec'd info card + danger button:
- A transparent 登录地址 label (text-[13px] font-bold #E5E5E5) with an
  8px gap to the full 0x… address, which uses Figma's character-level
  styling: first 5 and last 5 chars rendered bold white, middle 32
  chars rendered #A8A9AE / font-medium, replicating Figma's
  characterStyleOverrides.
- A full-width 48px disconnect pill at bg-[#2A1B20] with #F36161 text
  and the LogOut glyph on the right at 15px font-medium.

Add a new walletLoginAddress i18n key across all 7 locales (en, zh-CN,
ja, ko, vi, id, ms) for the new 登录地址 label.
2026-06-05 12:22:41 +08:00
TerryM
e73e25077e Merge branch 'main' into terry-wallet-login
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 34s
2026-06-05 01:08:47 +08:00
TerryM
36ab5be3c2 feat(categories): hide the 综合 (general) category from the UI
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 33s
Both the categories page grid and the home categories carousel now
filter out the 'general' slug before sorting. The full backend list is
still kept in state so posts that resolve their label via the
categories lookup don't lose their name. Backend will follow up by
dropping the slug from /api/categories; this client-side filter then
becomes a defensive no-op.
2026-06-05 01:07:40 +08:00
TerryM
062f630798 fix: dedupe post redirect language fallback
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 30s
2026-06-04 18:07:51 +08:00
TerryM
1fcf2ea46d fix: fall back to original language on post redirect 2026-06-04 17:50:03 +08:00
TerryM
ec98ff5a03 fix: align favorites page with post adapter
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 28s
2026-06-04 17:46:09 +08:00
TerryM
4f6cbbc314 fix: simplify favorites display
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 38s
2026-06-04 17:25:55 +08:00
TerryM
6471559b3b fix: stabilize favorites page loading 2026-06-04 17:15:14 +08:00
TerryM
efaf92c4e4 fix: limit visible toasts 2026-06-04 17:10:11 +08:00
TerryM
01eab88c0f feat: connect wallet favorites to backend 2026-06-04 17:06:29 +08:00
TerryM
fd19ed438e fix: hide imtoken on desktop wallet login
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 30s
2026-06-04 15:29:22 +08:00
TerryM
53dc35e7dc fix: restore mobile wallet login feedback
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 37s
2026-06-04 15:19:50 +08:00
TerryM
863a448ec9 Merge branch 'main' into terry-wallet-login
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 30s
# Conflicts:
#	src/App.tsx
2026-06-04 12:04:22 +08:00
TerryM
1b52a6d93d feat(routing): shorten language URL prefixes to ISO codes with legacy redirects
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 31s
Rename the localized URL prefixes from full English names to short
ISO-style codes:
  /chinese    -> /cn
  /japanese   -> /ja
  /korean     -> /ko
  /vietnamese -> /vi
  /indonesian -> /id
  /malay      -> /ms

Add legacyLanguageRedirects mapping and a LegacyLangRedirect component
in App.tsx so links shared on WeChat (and elsewhere) that still use the
long-form paths keep landing on the right page. The redirect preserves
the sub-path, query string, and hash, e.g.
  /malay/browse?post=42#x -> /ms/browse?post=42#x

Also refresh doc-comment examples in i18n.tsx, FigmaBanner.tsx,
PublicLayout.tsx, and useLocalizedPath.ts so future readers see the new
prefixes.
2026-06-04 12:01:38 +08:00
TerryM
4059ec3f20 feat: confirm desktop wallet address
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 32s
2026-06-04 11:55:13 +08:00
TerryM
90f27b050c i18n: translate wallet desktop guidance
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 38s
2026-06-04 11:36:28 +08:00
TerryM
0a86619b6c feat: guide desktop wallet connection 2026-06-04 11:32:59 +08:00
TerryM
ae64f96bbe fix: translate wallet no account error
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 32s
2026-06-04 11:14:52 +08:00
TerryM
65dee3a37e feat: simplify wallet login options
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 29s
2026-06-04 10:52:41 +08:00
TerryM
fb904d3a55 fix: restore staging imtoken login behavior
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 37s
2026-06-04 10:27:31 +08:00
TerryM
355c920c80 chore: remove wallet code comments 2026-06-04 09:55:19 +08:00
TerryM
f8369b6361 fix: auto login inside imtoken browser 2026-06-04 09:46:24 +08:00
TerryM
53eab4a066 fix: restore imtoken direct login path 2026-06-04 09:39:14 +08:00
TerryM
93790cb885 fix: remove wallet verification popup 2026-06-04 09:36:20 +08:00
TerryM
a1b318016f fix: detect imtoken injected provider by browser 2026-06-04 09:31:07 +08:00
TerryM
469e53a860 fix: skip wallet verification gate when logged in 2026-06-04 07:39:17 +08:00
TerryM
8140828c11 style(layout): align mobile drawer spacing with Figma 4164-5336
Drawer top: drop nav pt-2 so the first menu item sits flush with the
drawer top edge per Figma frame 173 (first item y matches drawer y).

Drawer bottom: raise CTA bottom inset from 20px to 34px so the gap
between the 链接钱包 button and the drawer's bottom edge matches the
Figma measurement (Btn Primary bottom y=25041 vs drawer bottom y=25075).
The safe-area-inset env() still wins on devices with a larger inset.
2026-06-04 07:32:30 +08:00
TerryM
526facb261 fix: require signature for tokenpocket direct login 2026-06-04 07:23:05 +08:00
TerryM
57dc25e5eb style(layout): apply Figma drawer translucency with backdrop blur
Figma 4164-5336 frame 173 specifies the drawer body as #14131A at 90%
opacity with a 24px background blur. Switch bg-ark-bg to bg-ark-bg/90
backdrop-blur-xl so the underlying page bleeds through softly rather
than being fully masked.
2026-06-04 07:21:33 +08:00
TerryM
173c283fb8 feat(wallet): swap CTA glyph to Figma 4414-12829 filled wallet icon
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 33s
The 链接钱包 CTA was using the lucide outline Wallet icon. Replace it
with a local WalletIcon component built from the exact Figma path
(filled body, currentColor fill) so the icon paints in dark on the
yellow CTA, matching Figma's #08070C fill via the button's text-black
utility.
2026-06-03 21:59:38 +08:00
TerryM
39f9cba8c7 feat(layout): full-screen mobile menu drawer matching Figma 4164-5733
Rebuild the mobile hamburger menu as a full-screen drawer that matches
the Figma design 1:1 — five nav items (全部资料 / 资料分类 / 官方推荐 /
最新更新 / 热门资料), transparent item backgrounds over the ark-bg
drawer, hairline dividers at #2B2B37, gold text on the active route,
and the existing WalletButton compact pill as the bottom CTA. Drop the
chevron-right indicators per the rendered Figma frame and remove the
old 收藏 row since it's not in the design.

Also move the drawer JSX out of <header sticky top-0 z-40> and render
it as a sibling at the layout root. The sticky+z-index header was
creating a stacking context that trapped the drawer's z-50 fixed below
the bottom nav at z-40 global, so the drawer never reached the
foreground.

Add the same iOS-safe body scroll lock used for the search overlay so
the underlying page doesn't drift while the drawer is open.
2026-06-03 21:59:31 +08:00
TerryM
2ef26390be feat(stream): bubble footer with timestamp and inline favorite/download
Match the Figma 4206-6509 card layout for /browse: every bubble now
renders a bottom row with the publish timestamp on the left and the
action buttons on the right. Image, album, video, text and link cards
show only the FavoriteButton; file-document cards show the
FavoriteButton plus a new BubbleAttachmentDownloadButton sized to
match. Removes the absolute-positioned favorite from the default
variant, drops the right-aligned timestamp block, and strips the inline
per-row download button from FileDocBubble's default variant since the
download now lives in the footer. The 'latest' masonry variant is
untouched so the home page continues to use LatestFileCard's existing
internal footer.
2026-06-03 21:20:53 +08:00
TerryM
6800a8e9b6 feat(wallet): bypass WalletConnect for TP/imToken on mobile to fix China users
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 40s
The WalletConnect relay (wss://relay.walletconnect.org) is unreliable/blocked
in mainland China. Every wallet flow (desktop QR, mobile deeplink, mobile QR)
depends on it, so Chinese users see the login button hang forever and the
QR code never appears. When RainbowKit's render fails, the whole site goes
white because nothing catches the error.

Changes:
- Add WalletStackErrorBoundary around <RainbowWalletProvider> + modal so
  RainbowKit init failures no longer blank the entire app.
- Hoist <WalletProvider> above the boundary; it only depends on the injected
  provider, so useWallet keeps working for header / favorites / etc. even
  when the WC stack is dead.
- On mobile, the TP/imToken 'Open Wallet App' button now navigates directly
  to tpdapp://open / imtokenv2://navigate/DappView with an ?autoLogin=<kind>
  query, pulling the site into the wallet's in-app browser without ever
  touching the WC relay. MetaMask still uses the WC path (no equivalent
  deeplink).
- Add AutoInjectedLogin: when the page loads with ?autoLogin=<kind>, wait
  up to 8s for window.ethereum, then connectInjectedWallet + completeLogin.
  Strips the param via history.replaceState to avoid re-firing on reload.
- Guard against the in-app-browser disconnect/reconnect case: if
  getInjectedWallet(kind) is already truthy, skip the deeplink and let
  useWalletConnectLogin's deeplink mode take the injected fast path
  (avoids TP trying to open TP recursively).
2026-06-03 20:07:23 +08:00
TerryM
b4ef5ddb61 Merge branch 'main' into terry-wallet-login
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 31s
# Conflicts:
#	src/components/messageStream/MessageStream.tsx
2026-06-03 14:42:07 +08:00
TerryM
53614189ce refactor(stream): simplify FilterChips by dropping the 1.5s scroll watcher
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 38s
The defensive rAF + scroll loop and its touching guard were added to
fight an iOS sticky-relayout quirk, but the module-level lastScrollLeft
plus the useLayoutEffect mount restore already cover the common case.
The watch loop also interfered with a fresh slide gesture immediately
after a filter tap. Strip it out together with the surrounding inline
comments so the component is the minimum needed: gold active state on
click and a remount-surviving scroll position.
2026-06-03 14:32:47 +08:00
TerryM
f2f2572cd2 feat(stream): surface source filename on official-assets cards
Image, album, and video bubbles in the official-assets category now
render the attachment filename as a bottom-left overlay so editors can
identify the source asset at a glance. Shared AttachmentFilenameLabel
component mirrors the AttachmentDownloadPill style, uses
filenameWithExtension so MIME-only attachments still get a sensible
label, and is pointer-events-none so it never blocks the bubble's tap
target.
2026-06-03 14:30:34 +08:00
TerryM
f7c0c0387e fix(stream): preserve FilterChips horizontal scroll across remount
PublicLayout wraps the routed page in <AnimatePresence> keyed by
pathname+search, so changing ?type=… fully unmounts the page and creates
a fresh FilterChips. A useRef-based save/restore therefore reset on
every filter switch. Persist the scrollLeft in a module-level value
that survives the unmount, restore synchronously on mount, and keep an
~1.5s post-mount watch window for the iOS Safari sticky relayout that
asynchronously snaps scrollLeft back to 0. Also gate the inactive-chip
hover color behind [@media(hover:hover)] so iOS sticky-hover no longer
leaves a faint gold tint on the last-tapped filter.
2026-06-03 14:30:27 +08:00
TerryM
fc19b92158 fix(layout): align /category main padding with /browse
Both routes render the same MessageStream; the layout wrapper used to inset
/category by px-4 / sm:px-6 on mobile while /browse stayed edge-to-edge,
shrinking bubble width and making the category waterfall feel narrower
than the all-resources page.
2026-06-03 14:30:20 +08:00
TerryM
724bfb8f24 fix(home): match Figma media pills for latest cards
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 37s
- Align latest-update media size pills to the Figma spec: 72x24 pill, black background, 24px gray icon cell, 10px label, and the exact small Figma cloud-download SVG.
- For non-document latest cards, remove the duplicate footer download action so media cards only show per-media size/download pills plus the bookmark action, matching the Figma non-document card design.
- Keep card heights flexible so content determines the final card height instead of locking to design mock heights.
2026-06-03 08:33:29 +08:00
TerryM
be638e32c9 feat(home): align desktop cards with Figma actions
- Update desktop header actions to match Figma: remove the standalone desktop favorites button and style the wallet connect pill with the wallet icon while allowing localized labels to expand.
- Replace favorite action with the Figma bookmark SVG and hover state; replace download cloud with the provided Figma SVG.
- Align official recommendation cards with the Figma card structure, colors, and bottom action row.
- Rework popular rows to the Figma desktop rhythm with 90px rows, wide thumbnails, rank area, and right-side action buttons.
- Add a dedicated desktop LatestUpdateCard for Figma-style latest-update masonry cards with flexible text-driven heights instead of fixed card heights.
2026-06-03 08:18:05 +08:00
TerryM
4f0d8925a4 style(popular): drop static border on rank row to match figma spec 2026-06-03 08:13:55 +08:00
TerryM
7e4be0a590 style(home): set recommended card background to #1D1E23 per figma spec 2026-06-03 08:11:48 +08:00
TerryM
a2f6c4fc35 style(home): align popular rank row and recommended card with figma
- PopularRankList: switch row to 90px Figma layout (246x90 cover, gap 24/12, pill px-3 py-1, meta color #9FA0A8, object-cover image)
- RecommendedCard: unify card and cover background to #272632
2026-06-03 08:08:28 +08:00
TerryM
49380dc5ed ci(staging): revert act cache wipe, keep other aggressive cleanup
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 46s
Previous attempt deleted the in-flight act workspace and broke
actions/checkout. Restore the safe >60min sweep for ~/.cache/act
while keeping npm/docker/tmp/log cleanup aggressive.
2026-06-03 02:18:29 +08:00
TerryM
42b25b9e09 ci(staging): aggressive disk cleanup to prevent ENOSPC during npm ci
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 2s
- wipe all stale act workspaces (keep only current run's dir)
- clear ~/.npm/_cacache and setup-node cache fully
- docker system prune -af --volumes
- apt/yum cache clean, journald vacuum to 100M
- /tmp older than 30min instead of 120min
2026-06-03 02:11:26 +08:00
TerryM
966663f3d7 ci: add staging deploy workflow for terry-wallet-login
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 24s
- trigger on push to terry-wallet-login
- deploys to staging server via STAGING_* secrets
- rsync to /var/www/ark-library-staging/, sha256 verify
2026-06-03 01:57:56 +08:00
TerryM
985463b7da fix(stream): resolve search deep-links without pagination stall
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 37s
Search results can link to older posts that are not present in the first
/browse page. The previous deep-link flow kept paginating the all-assets
stream until the target id appeared, leaving users stuck on the waiting
indicator for very old posts.

Fetch /api/posts/:id directly for ?post= arrivals and inject the resolved
target post at the top of the stream when it is not already in loaded
items. The normal paginated feed still loads below for context. Keep the
explicit finding/not-found status messages as a fallback for slow or
missing direct fetches.

Verified with search result c5eeb17d-3bd0-4d32-9c92-5efa6e4a015c: target
post rendered within 100ms instead of waiting for pagination. Checks:
tsc, format:check, tests, build.
2026-06-03 01:40:21 +08:00
TerryM
a68dd8f616 fix(wallet): complete desktop qr login after approval 2026-06-03 00:25:46 +08:00
TerryM
6552b92c50 fix(wallet): restore metamask mobile login 2026-06-03 00:12:50 +08:00
TerryM
b19486e908 Merge branch 'main' of https://repo.skywalker-rs.com/terry/Arkie-Library-Frontend into terry-wallet-login 2026-06-03 00:05:58 +08:00
TerryM
0326cb2998 fix: limit latest updates preview to nine
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 26s
2026-06-02 23:52:49 +08:00
TerryM
2955ba1039 fix: balance latest updates desktop columns
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 37s
2026-06-02 23:51:14 +08:00
TerryM
cd85a4bcfa fix(wallet): improve imtoken mobile login fallback 2026-06-02 23:32:39 +08:00
TerryM
a8863c5478 Merge remote-tracking branch 'origin/main' into terry-wallet-login 2026-06-02 22:23:53 +08:00
TerryM
3c72917bf9 fix: preserve localized view all routes
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 37s
2026-06-02 22:23:07 +08:00
TerryM
8a948e41e0 fix(wallet): reconnect wallet session on reload 2026-06-02 22:19:30 +08:00
TerryM
850daf3a2a fix(wallet): handle desktop walletconnect reconnect 2026-06-02 22:18:35 +08:00
TerryM
4d38c4513d fix(wallet): support no-signature wallet connect 2026-06-02 21:52:15 +08:00
TerryM
803d3d57c1 refactor(wallet): use unified rainbowkit login 2026-06-02 21:25:05 +08:00
TerryM
243e98b829 fix(wallet): simplify mobile login choices 2026-06-02 21:10:58 +08:00
TerryM
f0209eb894 fix(wallet): improve mobile login and logout flows 2026-06-02 21:05:01 +08:00
TerryM
0898744deb Merge remote-tracking branch 'origin/main' into terry-wallet-login 2026-06-02 12:08:45 +08:00
TerryM
edba16bbd2 feat(stream): hold deep-link scroll until first content reveals
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 28s
Banner / Home-card deep-links were starting the smooth scroll the
moment the target post entered the DOM, before the in-view Reveal
animations on the top bubbles had time to fade in. Users perceived the
page as 'scrolling past nothing' because most bubbles were still at
opacity 0 when the viewport moved.

Track the moment first non-skeleton content appears for the current
target via firstContentAtRef, then hold the smooth-scroll start until
~300ms after that — long enough for the initial Reveal staggers to
play. Elapsed time is subtracted so cached arrivals don't pay the full
wait twice, and the ref resets per target so each navigation re-times.

Verified in the browser: with cold cache, content arrives ~480ms after
click, smooth scroll starts ~800ms (300ms settle), reaches deep target
by ~1.3s. With warm cache same pattern; users now see content before
motion begins.
2026-06-02 11:48:05 +08:00
TerryM
562843e4b2 feat(stream): reset scroll to top on ?post deep-link arrivals
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 28s
Banner / Home-card clicks landing on /browse?post=X now always start the
smooth-scroll positioning from the top of the stream, instead of from
whatever scrollY the user happened to leave the page at. Runs in
useLayoutEffect before paint so the user never briefly sees the previous
position before the jump, giving a clearly visible scroll journey to the
target post every time.

Verified in the browser: before banner click scrollY=2000, immediately
after =0, then smooth-scrolled to ~25k as pagination loaded the target
post deeper in the stream.
2026-06-02 11:42:10 +08:00
TerryM
387b25f1e3 feat(stream): friendlier pagination loading + error retry
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 29s
- Replace the bare '…' loading dots at the bottom of the post stream
  with a skeleton bubble that matches the initial-load placeholders, so
  pagination feels like content arriving instead of a frozen indicator.
- Localize the retry control via new 'retry' / 'loadMoreFailed' keys
  across all 7 locales and surface a user-friendly error string instead
  of the raw exception message.
- Retry button now picks reset() vs loadMore() based on item count so a
  pagination failure only refetches the next page, not the whole stream.
- When a banner deep-link can't find its target post and pagination
  errors, break out of the retry loop and release the scroll lock so the
  user sees the inline retry instead of an endless freeze.

Verified in the browser: zh-CN renders '加载更多资料失败,请检查网络后重试。'
with a '重试' button; banner clicks with empty / '#' / 'javascript:' /
null linkUrls render no anchor and do not navigate.
2026-06-02 11:39:17 +08:00
TerryM
7ed9f8c8bf perf(banner): smoother deep-link from banner to /browse post
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 27s
- MessageStream: drop the mount-time scroll lock and the 80ms-delayed
  custom rAF; engage the lock only while the smooth animation runs and
  use native scrollTo({behavior:'smooth'}) so the page never feels frozen
  during pagination and the easing is buttery.
- PublicLayout: fire the default /browse prefetch immediately on mount
  (banner / Home tile destination) so a fast tap hits a warm cache;
  popular / latest stay deferred to idle.
- FigmaBanner: prefetch the all-scope stream on mount and on pointerdown
  as safety nets, and ignore empty / '#' / javascript: link URLs so a
  contentless banner renders as a non-interactive image.
- usePostStream: dedupe in-flight prefetches by key so concurrent
  callers (layout + banner) collapse into a single network request.
2026-06-02 11:30:47 +08:00
TerryM
fbb9d21f24 fix: cap banners shown to 10 with a single-row dot indicator
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 28s
The banners API can return hundreds of records; show at most 10 so the carousel
and its dot indicator stay on one row within the phone width, regardless of how
many exist on the backend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:16:49 +08:00
TerryM
e752de67e1 fix(banner): preserve active language when navigating to post links
Banner linkUrls come back from the API as unprefixed paths
(e.g. /browse?post=123). Navigating to them directly dropped non-English
viewers into the English version of the post. Localize both the rendered
href and the SPA navigate target via stripLangPrefix + localizePath.
2026-06-02 11:12:26 +08:00
TerryM
8b0ee18cd8 fix: seed home categories from cache to stop icon flicker
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 29s
Initialize the categories state from the cached response on first render so the
category icons stay visible when navigating back to the home page, instead of
flashing empty for a frame.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:05:59 +08:00
TerryM
d3e562663d fix: cap banner dot indicator at 10 to avoid mobile overflow
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 40s
With many banners the pagination dots overflowed the phone width. Show at most
10 dots in a window that follows the active slide; each dot still maps to its
real slide index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:01:15 +08:00
TerryM
92210cf0a2 fix(lightbox): extend save hint display to 2.5s 2026-06-02 10:52:08 +08:00
TerryM
6c0c3b89a9 fix(lightbox): anchor save hint below rendered image
Measure the image's rendered bottom edge with refs + ResizeObserver and
position the long-press save hint relative to it instead of pinning to
screen center or stage bottom. Enlarges the toast for mobile legibility
and clamps the offset so tall portrait images don't push it offscreen.
2026-06-02 10:51:17 +08:00
TerryM
8acb3a281b fix: opt out of browser auto-translation
The app ships its own 7-language i18n and serves localized content, but mobile
browsers (Google Translate) were auto-translating the Chinese UI into broken
English (brand, nav, language dropdown). Add translate="no" + the Google
notranslate meta, and keep the attribute set on language changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:47:36 +08:00
TerryM
4e33c7deef docs: add wallet/favorites UI redesign requirements brief
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:42:57 +08:00
TerryM
e1b24aa0f9 fix: keep desktop browser-wallet on direct injected; add login diagnostics
Revert desktop primary back to the direct injected sign (no WalletConnect relay,
which could spin forever) — reliable for a BNB-chain extension. Add console
diagnostics ([wallet-login] ...) and provider enumeration so a stuck/no-popup
flow can be pinpointed. WalletConnect stays as the explicit mobile MetaMask/
imToken option.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:38:29 +08:00
TerryM
11599e54ea fix: use RainbowKit picker for desktop browser-wallet login
Raw window.ethereum is unreliable when several extensions contend for it, so
desktop now opens the RainbowKit connect modal (EIP-6963 wallet discovery +
WalletConnect QR) when a project id is configured, falling back to the injected
flow otherwise. In-wallet mobile browsers keep the direct injected sign.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:33:59 +08:00
TerryM
8821058c0a fix: reset TokenPocket request state on expired/failed poll
Clear tpRequest when a TokenPocket login expires or fails so the mobile UI
returns to the initial state instead of showing a stuck waiting spinner
alongside the error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 04:30:27 +08:00
TerryM
ed04e1fb7e fix: TokenPocket mobile deep-link login, desktop empty-state, toast above modal
- Mobile TokenPocket now opens the tpoutside:// sign deep link and returns to
  the original browser to finish login (no wallet in-app browser); desktop
  keeps the QR. Fixes mobile login + logout being trapped in TP's browser.
- Desktop without an injected wallet shows a clear message instead of a dead
  button; TokenPocket login card is always available as a working path.
- Raise toast z-index above the login modal so feedback is visible.
- Add native TokenPocket-login strings across 7 locales.
- Document that the live backend lacks favorites + TokenPocket routes (404),
  the real blocker for those features in production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 04:00:30 +08:00
TerryM
7abe4a868c feat: redesign wallet login and favorites, fix desktop/mobile bugs
- Remove forced BNB chain switch on injected login (signature is chain-agnostic)
- Refine isMobileDevice so touch Macs stay on desktop flow
- Wire RainbowKit/WalletConnect as a real MetaMask/imToken QR fallback,
  gated on a valid VITE_WALLETCONNECT_PROJECT_ID
- Rebuild login modal: single desktop primary action, collapsible other
  methods, mobile open-app fallback feedback, brand icons
- Add My Favorites entry points (header, mobile menu, wallet dropdown)
- Favorites page: error retry, mobile filter drawer
- Auto sign-out and re-login prompt on favorites 401
- Full native translations for all wallet strings across 7 locales

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 03:43:13 +08:00
TerryM
f935f122f9 docs: design wallet login + favorites redesign and backend checklist
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 03:20:06 +08:00
TerryM
0edcc80513 fix: simplify wallet choices and use bnb chain 2026-06-02 02:58:01 +08:00
TerryM
b9fe7ff168 fix: batch favorite status checks 2026-06-02 01:11:00 +08:00
TerryM
fb6cb5bc11 fix: encode metamask dapp deep link 2026-06-02 01:06:55 +08:00
TerryM
184193e655 docs: note wallet session tradeoffs 2026-06-02 01:00:13 +08:00
TerryM
fc2ca62957 fix: clean up wallet favorites state 2026-06-02 00:57:37 +08:00
TerryM
05c2252b49 fix: close mobile menu before wallet login 2026-06-02 00:45:58 +08:00
TerryM
4900256423 feat: add favorites to latest rows 2026-06-02 00:41:06 +08:00
TerryM
de93e883c9 feat: build favorites page 2026-06-02 00:39:36 +08:00
TerryM
337e8f7e67 feat: add favorites state and buttons 2026-06-02 00:36:11 +08:00
TerryM
43700d9fdc feat: add wallet login modal 2026-06-02 00:32:46 +08:00
TerryM
71dac8373e feat: add wallet provider foundation 2026-06-02 00:28:22 +08:00
TerryM
df20005357 docs: design user favorites 2026-06-02 00:14:10 +08:00
TerryM
b265a57541 docs: design china-friendly wallet login 2026-06-02 00:05:37 +08:00
TerryM
5a5acfcbc2 fix: show save guide on mobile only
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 27s
2026-06-01 23:13:25 +08:00
TerryM
097c12bab5 copy: polish save album guide
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 36s
2026-06-01 23:09:25 +08:00
TerryM
e096d59fa6 feat: add media save guide 2026-06-01 23:00:28 +08:00
TerryM
7b48f9780c fix: use backend video preview urls
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 37s
2026-06-01 17:52:33 +08:00
TerryM
b4eb44f824 Merge terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 37s
2026-06-01 16:56:51 +08:00
TerryM
56d6bd033d fix: sync language prefixes 2026-06-01 16:36:55 +08:00
TerryM
da4c13f304 fix: preserve localized redirects 2026-06-01 16:36:36 +08:00
TerryM
a968f47640 feat: support mobile video previews 2026-06-01 16:35:40 +08:00
TerryM
c53032155b feat(i18n): add full ja/vi/id/ms translations and drop languageNames fallback
- Add complete dicts: src/locales/{ja,vi,id,ms}.ts (115 keys each)
- Remove languageNames override map; dict object now points directly to each locale
- i18n.tsx shrinks from ~414 lines to ~81 lines
2026-06-01 15:54:29 +08:00
TerryM
337d19e626 feat(i18n): split locale dicts into src/locales/ and add full Korean translation
- Extract zhDict/enDict from i18n.tsx into src/locales/{zh-CN,en}.ts
- Add full Korean dictionary (src/locales/ko.ts) covering all 115 UI keys
- Update formatBytes test/impl boundary for 1000-based units
2026-06-01 15:49:15 +08:00
TerryM
c490524575 fix: widen desktop header brand max-width to fit longer translations 2026-06-01 15:42:00 +08:00
TerryM
c32ae539f6 fix: use decimal (1000-based) units in formatBytes to match S3/curl display 2026-06-01 15:24:41 +08:00
TerryM
4dcf68bc71 Merge terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 36s
2026-06-01 15:11:53 +08:00
TerryM
fa78568c94 feat: add localized home routes 2026-06-01 15:09:58 +08:00
TerryM
6c4936fea3 Merge terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 26s
2026-05-31 19:24:26 +08:00
TerryM
9b08379d50 fix: 2-column masonry at md with side padding, 3 at lg
- md (768-1023px): 2 columns with px-4 so cards don't kiss the screen edge.
- lg+ (>=1024px): 3 columns, parent wrapper provides spacing.
- <768px stays on the original single-column mobile branch.
2026-05-31 19:24:18 +08:00
TerryM
04badc26d1 Merge terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 26s
2026-05-31 18:41:02 +08:00
TerryM
186ba362f3 fix: align banner width with 3-column latest section 2026-05-31 18:40:27 +08:00
d0302218b2 Merge pull request 'terry-staging' (#14) from terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 35s
Reviewed-on: #14
2026-05-31 10:36:12 +00:00
TerryM
06fe117ebc feat: render desktop latest section as 3-column masonry
- Matches Figma design (file uHDZkVHjAp7BXDKQKB0PM4, node 4367-11405).
- Mobile keeps the existing 5-post single column unchanged.
- Desktop (md+) renders all 12 latest posts in a CSS-columns masonry
  with break-inside-avoid so each card's height stays content-driven.
- Adds an optional 'fluid' prop to MessageBubble that drops the
  standalone-feed max-widths so bubbles fill the masonry column. The
  /browse stream keeps the default non-fluid widths.
2026-05-31 18:35:57 +08:00
TerryM
34ef6cba15 fix: ensure minimum horizontal padding on desktop header at all viewports 2026-05-31 18:35:20 +08:00
TerryM
c7e0562d9a feat: desktop banner peek with framer-motion blur
Match Figma node 4366-11092 desktop banner design:
- Slides shrink to 78%/72%/60% width on md/lg/xl with snap-center,
  first/last get matching left/right margin so the edges still center.
- Each slide is wrapped in a framer-motion m.div that animates filter,
  opacity, and scale between active and idle states.
- goTo and scroll/drag handlers use the slide's real offsetWidth so
  centering math holds at every breakpoint; mobile (full-width, snap-start
  visual) is unchanged.
2026-05-31 18:22:03 +08:00
TerryM
5faa18d343 fix: make desktop search a button and keep nav down to 1000px
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 26s
- Replace the desktop header search field with a search icon button that
  opens the search panel on click (matching mobile), so there is one search
  input (in the panel) instead of a redundant header field, and the trigger
  no longer vanishes when the panel opens.
- Lower the nav collapse breakpoint from 1100px to 1000px so the full menu
  stays visible on smaller desktop widths before falling back to the
  hamburger menu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 03:36:54 +08:00
TerryM
c71ebba807 fix: simplify desktop search panel
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 25s
2026-05-31 03:21:23 +08:00
TerryM
345ccb0a25 fix: preview search results 2026-05-31 03:10:56 +08:00
TerryM
92a8a83585 fix: lower desktop header breakpoint 2026-05-31 03:04:27 +08:00
TerryM
6b3211f26f style: format homepage files
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 34s
2026-05-31 02:56:34 +08:00
TerryM
39c593c454 fix: add desktop search dropdown 2026-05-31 02:55:04 +08:00
TerryM
cf6bd7339e fix: unify homepage card background with the browse page
Match the official-recommendation and popular cards to the message bubble
surface color (#272632) used on the browse/all page and the latest section,
so homepage content cards and the browse page share one background. Also
align the popular card border with the official card (#27292E).

Categories tiles are intentionally left unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 02:48:18 +08:00
TerryM
00913a26a7 fix: remove duplicate menu search 2026-05-31 02:47:13 +08:00
TerryM
46b7ee861e fix: expire frontend caches 2026-05-31 02:44:44 +08:00
TerryM
5b93e8dc77 fix: match banner and popular list width to the latest cards
Size the banner and the popular rank list to the same responsive widths as
the latest section's message bubbles (680/900/1120), so the banner, latest
cards and popular cards line up at one consistent content width on desktop.

Desktop-scoped; mobile stays full-width.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 02:39:54 +08:00
TerryM
a564fddfcb chore: remove unused public assets 2026-05-31 02:36:46 +08:00
TerryM
e35573083a fix: align homepage section titles and unify download button color
- Wrap the Categories and Popular sections in the same responsive
  max-width container (820/1080/1180) used by Official and Latest, so all
  four section titles line up vertically on desktop.
- Official carousel arrows: hide the arrow at the edge already reached, and
  snap one card per click (reveal the next/previous card fully, keeping a
  small peek) instead of a fixed-pixel scroll.
- Show the pagination dots on desktop too (was mobile-only).
- RecommendedCard download button icon: text-ark-gold -> text-white to match
  the file bubble / popular / video download buttons.

Desktop-scoped; mobile layout unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 02:32:15 +08:00
TerryM
320e09cc87 fix: desktop homepage responsive polish and nav tweaks
- Banner: scale down and center on desktop (md:max-w-[680px] lg:max-w-[800px])
  so it no longer fills the whole screen.
- Align section widths to one responsive container (820/1080/1180): wrap the
  Official row and match Popular to Latest, fixing left-edge/width mismatches.
- RecommendedCard: stop the carousel card from shrinking back to 246.4px at xl.
- Popular download button now matches the file bubble's filled round
  DownloadCloud button for visual consistency.
- Header nav vertical padding py-1 -> py-0.5; swap Favorites before Popular
  across desktop nav and mobile menu to match the bottom tab order.
- Official carousel: hide the left arrow at the start and the right arrow at
  the end instead of always showing both.

All changes are md/lg/xl-scoped; mobile layout is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 02:04:26 +08:00
TerryM
9ac072e8d8 fix: jump to top on page change before post deep-link alignment
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 26s
Entering /browse via a popular-section card carried ?post=, which made
ScrollToTop skip the reset entirely. The window stayed at the previous
page's bottom scroll, so the deep-link animation visibly scrolled UP from
the bottom to the target post.

Now any pathname change jumps to the top first (even with ?post=), letting
the destination align to the post by scrolling down from the top. Hash
anchors and same-page ?post= changes are still left alone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 23:47:26 +08:00
TerryM
14aca7bc8d Merge terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 37s
2026-05-30 23:38:47 +08:00
TerryM
5179a8c068 Fix popular rank dates 2026-05-30 23:33:23 +08:00
TerryM
4c01e4fa52 fix: unify mobile homepage section spacing to 10px
Mobile sections had no vertical rhythm (space-y only at md+) and
inconsistent header-to-content gaps. Add uniform 10px spacing between
sections and header-to-content so each section header sits symmetric.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 23:28:06 +08:00
TerryM
cff857362d Merge terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 27s
2026-05-30 23:00:16 +08:00
TerryM
ada117f4f4 fix: remove duplicate mobile page headings 2026-05-30 22:59:50 +08:00
TerryM
1ab8319465 Merge terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 27s
2026-05-30 22:55:09 +08:00
TerryM
3f251710d0 fix: polish mobile menu titles 2026-05-30 22:52:46 +08:00
TerryM
7ed4cbbeba fix: disable video controls text selection 2026-05-30 22:19:20 +08:00
92de7a57f4 Merge pull request 'terry-staging' (#13) from terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 35s
Reviewed-on: #13
2026-05-30 14:05:28 +00:00
ad4eeeb87a Merge branch 'main' into terry-staging 2026-05-30 14:05:09 +00:00
TerryM
cc58ee8aac fix: smooth mobile footer tab switching 2026-05-30 21:48:14 +08:00
TerryM
d531ba40f3 fix: tighten mobile bottom nav spacing 2026-05-30 21:30:08 +08:00
TerryM
5277943196 fix: anchor mobile bottom nav 2026-05-30 21:27:48 +08:00
08c474e86b Merge pull request 'terry-staging' (#12) from terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 36s
Reviewed-on: #12
2026-05-30 10:45:30 +00:00
TerryM
5ce52943e9 feat: add image save hint 2026-05-30 18:44:15 +08:00
TerryM
0e877d4959 i18n: add longPressImageSave string used by the image lightbox
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 18:14:58 +08:00
TerryM
942db88f58 fix(ui): disable text selection on header, filter chips, and bottom nav
Add `select-none` to the sticky header, the type filter chips row, and the
mobile bottom nav so their labels and icons can't be highlighted/selected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 18:14:58 +08:00
TerryM
5f7c4eea62 fix: show recommended card thumbnails 2026-05-30 18:08:41 +08:00
TerryM
b2e4a4e710 fix: bound post deeplink scrolling 2026-05-30 18:08:39 +08:00
TerryM
41299b5b65 feat(deeplink): jump from banner/rank list to the exact post in All Materials
- FigmaBanner: route same-app linkUrl through SPA navigation so the stream's
  scroll-to-post runs without a full reload; defer pointer capture until a real
  drag starts, fixing plain clicks being swallowed by setPointerCapture
- PopularRankList: rank rows navigate straight to /browse?sort=popular&post=<id>
- MessageStream: ?post= deep links jump directly to the target instead of
  resetting to the top and animating through the stream
- ScrollToTop: skip the top-reset for ?post= navigations so the target page
  handles its own alignment

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 17:54:16 +08:00
TerryM
0733ea8b18 feat(category-icon): 分类图标支持 PNG 资源,学院类目改用 PNG
categorySvgUrlForSlug → categoryAssetUrlForSlug,映射值改为带子目录
的相对路径(svg/ 或 png/),新增 academy-materials / academy-video
的 PNG 图标并兼容拼写别名 acedemy-video。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 17:34:18 +08:00
TerryM
07f040a549 fix(video): 全屏关闭后内嵌进度条跳变而非从0扫动
程序化 seek 同步全屏播放进度时,新增 snapProgress 抑制进度条
的宽度过渡动画,使其直接落到观看位置,恢复播放时再启用过渡。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:54:03 +08:00
TerryM
eb0eabe21f Merge remote-tracking branch 'origin/main' into terry-staging 2026-05-30 16:04:04 +08:00
TerryM
40d64f1293 fix: 链接预览强调色按域名兜底,腾讯会议不再用黑色
部分站点(如 meeting.tencent.com)返回 themeColor=#000000,在深色气泡上看不见。
按域名覆盖为品牌金色,其余仍优先用 themeColor。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:43:30 +08:00
TerryM
9bef178bc8 feat: 大图查看器支持 iOS 长按"存储到照片"
去掉全尺寸图上的 select-none 并显式设 -webkit-touch-callout:default,使 iOS
Safari 长按图片能弹出原生「存储到照片」菜单(保存的是 current.url 全图)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:43:30 +08:00
TerryM
cc9f0a5730 fix(stream): preserve filename extension in narrow bubbles
Previously the file bubble wrapped JS middle-ellipsis in CSS truncate,
so narrow containers silently clipped the tail+extension, leaving
e.g. "25cb264a-e06…." instead of "25cb264a-e06…811a.jpg".

Split the displayed name into a shrinking head (with CSS truncate)
and a non-shrinking tail (last 4 base chars + extension). The browser
now decides how much head to clip while the suffix is always visible.
2026-05-30 15:42:13 +08:00
TerryM
1d74f29c2a copy(search): shorten mobile search placeholder 2026-05-30 13:55:52 +08:00
21e078fd89 Merge pull request 'terry-staging' (#11) from terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 35s
Reviewed-on: #11
2026-05-29 19:29:58 +00:00
TerryM
1557d29af7 docs(posts): clarify backend title field requirements
Clarify that compact browsing surfaces should receive short post title fields
from the backend even if some frontend pages do not display them yet. Separate
the product requirement from current frontend implementation status so backend
can implement the API contract first and frontend can opt in per surface later.
2026-05-30 03:28:33 +08:00
TerryM
a8fd540ef5 fix(stream): smooth scroll to linked post cards
Route resource card clicks to /browse?post=<id> instead of relying on hash
anchors, then manually calculate the sticky filter offset when scrolling to
the target bubble. Start from the top and smooth-scroll to the card for a
clear transition, with delayed auto realignments after media above the target
settles.
2026-05-30 03:11:03 +08:00
TerryM
6798e90708 feat(posts): support short titles for resource cards
Add optional post-level and localized title fields, and use a new
postTitleText helper for Resource.title so card/list surfaces prefer short
backend-provided titles instead of full body text. When title is missing,
fallback to the first non-empty body line, then filename, then post id.

Document the backend handoff in docs/posts-title-api.md alongside the other
backend task docs.
2026-05-30 03:11:03 +08:00
TerryM
2b9ab9eb2c fix: 搜索弹窗开启时点 burger 能正常打开菜单
关闭搜索弹窗会触发滚动恢复(window.scrollTo)发出 scroll 事件,被菜单的
closeOnScroll 立即捕获、把刚打开的菜单关掉。菜单打开后 250ms 内忽略滚动关闭,
跳过这次恢复滚动;之后用户真实滚动仍正常收起菜单。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 03:04:09 +08:00
TerryM
4c441244c8 style: 搜索提示行与输入框内容像素级对齐
输入框 border 改为 ring-inset(不占布局),使输入框内容与下方提示行(ⓘ+文字)
都位于「容器左缘+12px」同一基准,图标列严丝合缝对齐,不再依赖魔法数值。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 03:00:44 +08:00
TerryM
f1e5e17fce docs: 新增搜索与标签接口说明(给后端)
说明 /api/posts/search 期望的模糊匹配规则(跨标题/分类/标签/简介/类型/正文、
大小写不敏感、相关度排序)与参数,以及新增 /api/tags 返回全量标签+计数以替代
前端从最新80条现算的不完整标签来源。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 02:51:20 +08:00
TerryM
ea38503f37 ui(thumbnails): force preview images to fixed ratio
Use object-fill for compact preview thumbnails so rank-list covers and
file previews fill their fixed boxes without cropping or black bars. This
keeps the list layout stable while matching the desired compressed-ratio
thumbnail treatment.
2026-05-30 02:51:09 +08:00
TerryM
609c119277 fix: 搜索面板标签可再次点击取消(toggle)
再次点击已选中的标签时清空选中/查询/结果,而不是永久停留在该标签。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 02:50:19 +08:00
TerryM
78d055bb99 style: 热门榜封面与文件档预览缩略图改 object-contain 完整显示
热门榜单封面、文件档预览缩略图由 object-cover 改为 object-contain(文件预览
加底色),完整显示不裁剪。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 02:47:41 +08:00
TerryM
ce5505ea23 perf: 预热扩展至最新页,串行错峰避免卡顿
预热列表新增「最新」(/browse?sort=latest)。三个预取改为串行执行:每个在空闲
(requestIdleCallback)时触发、间隔 400ms,避免并发请求拖卡低端手机。仅 JSON。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 02:46:26 +08:00
TerryM
2e50b301a3 perf: 空闲预热全部资料/热门资料,点击前已缓存
usePostStream 导出 prefetchPostStream(已缓存/mock 则跳过)。PublicLayout 在
requestIdleCallback 空闲时后台预取「全部资料」「热门资料」首页数据并写入缓存,
用户点击进入时直接读缓存秒显,不再进页面才开始加载。预取仅 JSON,不拉图片。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 02:44:53 +08:00
TerryM
4a20d80f68 feat: 顶栏显示当前页名,去掉独立标题行;Logo回首页/页名回顶部
- 新增 PageTitleContext:页面上报标题,顶栏 brand 位显示当前页名(全部资料/
  热门资料/最新/官方/分类名/搜索/我的收藏),未上报则回退品牌名
- AssetStreamPage、Favorites 上报标题;移除资料流内单独的标题行,省出空间
- 顶栏拆分点击:Logo→首页(首页则回顶部);页名文字→回到当前页顶部

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 02:37:30 +08:00
TerryM
ed6e0023b8 ui(lightbox): hide nav arrow at the gallery ends
Clamp goPrev / goNext instead of wrapping the index, and hide the
left chevron on the first image and the right chevron on the last
one. Arrow keys, swipe gestures, and the on-screen buttons all
behave like a linear gallery now, so users get a clear cue that
they have reached the end instead of unexpectedly looping back.
2026-05-30 02:27:20 +08:00
TerryM
b848ce5db3 fix: 2图相册按真实比例+缩放贴合,竖图不再被裁
- albumLayout: 2 图不再 clamp 比例,格子按图片真实比例,避免下限 0.55 把竖图
  当矮图导致裁顶;3+ 张马赛克仍 clamp 保持整齐
- AlbumBubble: 2 图改用 object-contain,整图缩放贴合框内,永不裁剪;高度上限
  不变,不占用过多空间

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 02:26:43 +08:00
TerryM
27f9dbbc45 fix(album): keep true aspect ratios for two-image layouts
Split clampRatio into a sanity-check helper (safeRatio: reject
zero / NaN / negatives) and the actual aspect clamp. Two-image
albums now keep each image's real ratio so the cells match the
images exactly with no object-cover cropping. Three-plus image
layouts still clamp ratios to a 0.55-2 band so a single extreme
image cannot warp the mosaic.
2026-05-30 02:25:01 +08:00
TerryM
8646b51b6c feat(video): cross-platform inline player + polished overlay controls
- MessageInlineVideo (new): custom-controlled inline video that disables
  the iOS Safari / Chromium native overlays entirely and reimplements
  the essentials: tap-to-play, centered play affordance while paused,
  bottom bar with play/pause + current time + drag-to-scrub progress
  bar + remaining time + fullscreen. Pointer events with pointer
  capture cover both mouse and touch scrubbing, including dragging
  past the bar's bounds. The element listens to 'seeked' as well as
  'timeupdate' so external currentTime writes paint the bar even when
  the video is paused, and the goFullscreen callback synchronously
  syncs React state on close so the inline progress reflects the user's
  fullscreen playhead with no perceptible delay.
- VideoBubble: replace the inline <video controls> with
  MessageInlineVideo and thread postId through openVideo so the
  fullscreen overlay can attach the download pill to the right post.
- VideoPlayer overlay: replace its <video controls> with
  MessageInlineVideo size='lg', removing the iOS native arrows / PiP /
  mute / overflow controls. The overlay supplies its own large
  download pill and a beefier close button.
- AttachmentDownloadPill: new 'size' prop ('sm' default 30 px, 'lg'
  44 px with 22 px icon and text-[14px]) for overlay surfaces where
  the affordance can breathe and should feel touch-friendly.
- ImageLightbox: drop the inline LightboxDownloadButton and use the
  shared AttachmentDownloadPill size='lg' instead, with a matching
  larger close button. Unused imports cleaned up.
2026-05-30 02:25:01 +08:00
TerryM
3d5681e7de fix: 消除页面切换残影,旧页瞬间移除仅新页淡入
页面过渡改为纯 opacity 且 exit 时长 0:配合 AnimatePresence mode="wait",
旧页立即卸载、不再残留一帧,只有新页淡入。既无交叉淡入淡出的残影,又保留
进场动画。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 02:15:34 +08:00
TerryM
ae14b33f83 perf: 资料流会话级缓存,切换页面不再重载
usePostStream 增加按筛选参数为 key 的内存缓存,保存已加载的全部内容与分页
游标。再次进入同一视图(如首页⇄全部资料来回切)时直接还原,不重置、不重新
请求、不显示骨架屏。整页刷新清空缓存以获取最新数据。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 02:04:26 +08:00
TerryM
6d62aad8c4 style: 缩小视觉气泡标题与底部时间的间距
视觉气泡(图片/视频/图文)底部时间上间距 pt-3(12px) → pt-0.5(2px),
标题与日期时间更贴近。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 01:58:30 +08:00
TerryM
a4884a689d perf: 图片渐进加载,缩短首屏等待
- 流内单图改用缩略图(原图仅在灯箱按需加载),体积大幅减小
- BubbleImage 加 decoding=async + 加载完淡入(ark-img-fade),图片逐张出现
- 视频海报/文件预览/推荐卡/热门榜补 decoding=async、lazy
原图无缩略图时自动回退,无回归。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 01:54:28 +08:00
TerryM
29dc71d2dd feat(link-preview): frontend interface for Telegram-style URL preview
Adds the front-end side of the link-preview feature so the back-end
team has a fixed contract to implement against.

- docs/link-preview.md: full spec for the `/api/link-preview` proxy
  and the preferred inline-on-Post integration. Covers caching, SSRF
  guards, metadata-extraction precedence, provider quirks, and the
  front-end rendering rules. Scope is the first URL only.
- types/post.ts: new `LinkPreview` type and optional `linkPreview`
  field on `Post`.
- LinkPreviewCard: clickable card with a themeColor accent bar,
  siteName / title / description (line-clamped), and an optional
  1.91:1 thumbnail. Whole card is an `<a target="_blank">` to the
  canonical URL.
- MessageBubble: render the card between the bubble body and the
  timestamp, with padding that matches visual vs. text-only bubbles.
- mockPosts: example `linkPreview` payloads on p-005 and p-010 so
  the visual works when running with VITE_USE_MOCK_POSTS=true,
  and so the back-end has concrete reference values.
2026-05-30 01:40:00 +08:00
TerryM
09d887dd52 feat: 筛选条支持鼠标滚轮横向滚动
桌面鼠标无横向滚轮且滚动条隐藏,溢出时末尾筛选项不可达。加非被动 wheel
监听,溢出时将 deltaY 转为横向 scrollLeft;未溢出则不接管,不影响页面滚动。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 01:39:06 +08:00
TerryM
8425809f98 fix: 全部资料页底部导航固定在屏幕底部
/browse 的 main 分支缺少 flex-1,内容少时主区不撑开,底部导航跟着内容
跳到屏幕中间。补上 flex-1,主区填满剩余高度,sticky 底部导航始终贴底。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 01:34:16 +08:00
TerryM
8610ac521e fix: 筛选标签选中态手机端改为金色
之前手机端选中标签是白色(text-white),仅桌面金色;统一为 text-ark-gold,
与品牌色一致。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 01:32:06 +08:00
TerryM
61f3c41567 feat: 资料流标题+筛选吸顶,滚动时保持可见
把「全部资料」标题与筛选标签合并为一个吸顶块,钉在全局顶栏下方
(top-[64px]/md:top-[70px]),向下滚动时标题和筛选都不再消失,仅内容流滚动。
移除 FilterChips 原先吸到 top-0(藏到顶栏背后)的行为。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 01:31:10 +08:00
TerryM
e9245875a3 feat(stream): floating page title pill when heading scrolls out
Add a Telegram-style sticky title to AssetStreamPage: an
IntersectionObserver watches a sentinel placed just under the main
heading and, once the heading scrolls past the sticky global header,
a floating pill slides in showing the current page title so users
always have a context anchor.
2026-05-30 01:27:02 +08:00
TerryM
fd46359fd9 ui: re-introduce adaptive download pill sizing for album tiles
- AttachmentDownloadPill: bring back the optional adaptive sizing
  branch (default off). When the host sets containerType: inline-size,
  the pill scales 22-30px with the tile width using a steeper
  18cqw curve, so two-image tiles already reach 30px while tiny
  thumbs in mixed layouts stay at 22px.
- AlbumBubble: opt every tile into adaptive sizing and add the query
  container so the cqw units resolve against each tile's width.
- Single-image and video bubbles continue to render the pill at a
  fixed 30px (no host opt-in needed).
2026-05-30 01:27:02 +08:00
TerryM
4e5093cae0 ui: drop the 'download complete' success toast
Successful downloads no longer pop a 'downloadOk' toast across all
the download sites (PopularRankList, RecommendedCard, FileDocBubble,
ImageLightbox). The browser's native download UI already confirms
the action, and the toast was redundant noise. Failure toasts and
the spinner state remain so users still see errors.
2026-05-30 01:21:35 +08:00
TerryM
92e6ce9dd8 feat(video): explicit fullscreen button, simplify download pill sizing
- VideoBubble: add a Maximize2 fullscreen button at the top-right of
  the inline player (mirrors the download pill on the left), so the
  user explicitly opts into fullscreen instead of any tile click being
  promoted to one. Removed the outer onClick that opened fullscreen
  on any tap.
- AttachmentDownloadPill: drop the adaptive (cqw-based) sizing branch
  and the corresponding 'adaptive' prop. The pill is now a uniform
  30px tall in every host (album tile, single image, video tile),
  matching the design without needing query containers on parents.
- AlbumBubble: remove the now-unused 'adaptive' prop and the
  containerType: inline-size hook that supported it.
2026-05-30 01:21:35 +08:00
TerryM
6b42981419 fix(video): keep scroll position and stop pause from going fullscreen
- VideoBubble: stopPropagation on the inline <video> element so
  clicks on the native controls (play/pause/seek) don't bubble up
  to the outer tile, which previously promoted any control click
  into a fullscreen overlay.
- VideoPlayer: replace overflow:hidden body lock with the iOS-safe
  position-fixed + scroll-restore pattern. Closing the fullscreen
  player now returns the page to the exact scroll offset it had,
  instead of snapping back to the top on iOS Safari.
2026-05-30 01:09:19 +08:00
TerryM
f7230de12b ui(mobile): block pinch-zoom and iOS input auto-zoom
- index.html: tighten the viewport meta with maximum-scale=1,
  user-scalable=no, viewport-fit=cover so pinch-zoom is disabled
  on browsers that honor the hint (Android Chrome).
- SearchPanel: bump the mobile search input from text-sm (14px) to
  text-base (16px). iOS Safari auto-zooms inputs whose font-size is
  below 16px on focus, which widened the viewport and let the user
  pan horizontally; 16px stops that behavior at the source.
2026-05-30 01:05:25 +08:00
TerryM
ac208dfe25 style: stronger contrast on image lightbox nav buttons
Bump the previous/next chevron buttons from translucent white tint
to a solid-ish black with a subtle ring + shadow, so the controls
stay legible on light or busy image backgrounds.
2026-05-30 01:02:18 +08:00
TerryM
cb14cb166a ui: prevent layout shift when mobile search overlay opens
- SearchPanel: focus input with { preventScroll: true } so the browser
  doesn't auto-scroll the underlying page when the overlay mounts.
- SearchPanel: add overscroll-contain on the scroll container so
  reaching the panel's edges does not chain into the body on Android.
- PublicLayout: lock background scroll while the mobile search overlay
  is open using the iOS-compatible position-fixed + restore pattern,
  so the page underneath cannot move at all.
2026-05-30 01:02:18 +08:00
TerryM
d19f2f9efa feat: 移除「关于本站」按钮与功能
- PublicLayout: 删除桌面导航/下拉菜单/页脚三处入口及空页脚,移除 about 导航类型与判断
- App: 移除 /about 路由及 AboutPage 引入
- 删除 pages/About 页面
- DocumentMeta: 移除 /about 的 meta 处理与 about 描述文案(中/英)
- i18n: 移除 aboutTitle / aboutIntro / footerAbout(中/英)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 00:59:06 +08:00
TerryM
15bcb6bdf0 feat: 相册下载胶囊自适应尺寸(随图块大小缩放)
- AttachmentDownloadPill 新增 adaptive 模式:用容器查询单位 cqw + clamp,
  尺寸随所在容器宽度缩放(图标框 22~30px、图标 13~18px、文字 10~12px)
- 相册图块设 container-type: inline-size 作为查询容器,小缩略图上的下载按钮
  自动缩小;单图/视频默认固定尺寸不受影响

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 00:52:23 +08:00
TerryM
d7e2e56cde ui: home carousel height lock + bubble polish
- Home: lock category carousel height to the tallest page so the
  Official Recommendations section below does not jump up when
  swiping to a page with fewer categories.
- CollapsibleText: raise default threshold to 25 lines and tighten
  the spacing between the expand-all button and the timestamp
  (drop the fixed h-8 and use mt-1 instead of mt-1.5).
- formatTime: always render dates as yyyy/m/d HH:mm regardless of
  locale, matching the requested timestamp format.
2026-05-30 00:43:54 +08:00
TerryM
c0068e957e feat: 文档预览图改方形放大,下载胶囊整体放大
- FileDocBubble 预览缩略图 52px 圆形 → 64px 方形圆角(rounded-lg),行高自适应
- AttachmentDownloadPill 放大:图标框 24→30px、图标 14→18px、文字 11→12px

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 00:43:27 +08:00
6249b14096 Merge pull request 'terry-media-adaptive-trial' (#10) from terry-media-adaptive-trial into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 25s
Reviewed-on: #10
2026-05-29 16:21:39 +00:00
TerryM
ad885f578c Merge origin/main into terry-media-adaptive-trial
Combine collapsible long-text (main) with adaptive image frame
(this branch). Resolved conflict in ImageWithTextBubble.tsx by
keeping both SingleImageFrame and CollapsibleText imports.
2026-05-30 00:17:10 +08:00
TerryM
b370bf756c ci: free disk space on self-hosted runner before each job
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 33s
Add a pre-checkout cleanup step that removes stale act caches,
old setup-node/npm cache entries, dangling docker resources, and
leftover /tmp files older than 2h. Prevents recurring ENOSPC
failures on the EC2 self-hosted runner.

Note: the very first run after this change may still fail if the
runner disk was already at 100% beforehand; one-time manual cleanup
on the host is required to bootstrap.
2026-05-30 00:10:39 +08:00
TerryM
f3c51725fb Merge terry-staging into main: 消息气泡长文字可展开/收起
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 6s
2026-05-30 00:03:54 +08:00
TerryM
64a41359b4 style: 展开/收起按钮去掉背景胶囊样式,仅保留文字+chevron 2026-05-29 23:58:14 +08:00
TerryM
b283ba74da feat: 消息气泡长文字支持「展开全部/收起全部」折叠
- 新增 CollapsibleText 组件:超过 8 行文字自动折叠,按行高对齐裁切,底部柔和渐隐遮罩暗示有更多内容
- 折叠按钮左对齐,胶囊样式 + chevron 图标,hover 浅背景高亮,点击 chevron 旋转 180°
- 应用到全部 5 种气泡:Text/ImageWithText/Album/Video/FileDoc
- 动画统一使用 motion 包的 EASE_OUT 缓动,尊重 reducedMotion=user
- i18n 七种语言新增 showMore/showLess 文案
2026-05-29 23:49:59 +08:00
TerryM
471d29bec9 fix: 相册改用绝对定位百分比布局,彻底消除底部黑边
CSS grid + aspect-ratio 下 fr 行在部分浏览器不撑满,留出 bg-black。
改为 Telegram 同款:算法输出每块 {left,top,width,height} 百分比坐标,
图块绝对定位铺满容器。顺带修复竖主图在左时右侧堆叠图高度算错未填满列。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 22:56:20 +08:00
TerryM
789920d2b9 fix: 修复相册底部黑边(grid 用 aspect-ratio 时 fr 行不撑满)
aspect-ratio 单独作用在 grid 上时,容器高度对 fr 行属"非确定高度",
fr 退化为 max-content,行被压成内容高度,容器底部露出 bg-black。
改为:aspect-ratio + maxHeight 放外层 wrapper,内层 grid 用 absolute
inset-0 撑满,获得确定高度,fr 行正常拉伸,黑边消除。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 22:37:16 +08:00
TerryM
0035457c6d feat: 媒体流图片自适应显示(单图/2图/Telegram式相册)
- 单图气泡按真实比例显示:横图限高260px、过高裁上下;竖图完整铺满宽度不裁、无黑边
- 2张同类相册:都竖图左右并排、都横图上下堆叠,按比例不裁
- 3+张相册:Telegram式马赛克拼贴(竖主图占左+其余堆右 / 横主图占顶+其余排底)
- 图片比例优先用后端width/height,缺失时从加载后的naturalWidth/Height读取
- 新增 constants/media.ts 统一尺寸规范;albumLayout 纯算法附单测

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 22:16:55 +08:00
TerryM
a7792c117d fix: 资料流提早显现内容,避免空白缺口被误认为到底
Reveal 加 300px 前置 viewport margin,内容在进入视口前淡入;
无限滚动预加载触发线 200px → 1000px,下一页提前请求。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:04:31 +08:00
TerryM
b66c35be11 Merge terry-staging into main: format fix (PublicLayout)
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 21s
2026-05-29 18:19:58 +08:00
TerryM
33a466841c style: prettier 格式化 PublicLayout 以修复 format:check
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:19:08 +08:00
b4f9b5b304 Merge pull request 'terry-staging' (#9) from terry-staging into main
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 12s
Reviewed-on: #9
2026-05-29 10:17:12 +00:00
3e68bb4334 Merge branch 'main' into terry-staging 2026-05-29 10:17:00 +00:00
TerryM
042635528a feat: 灯箱支持下载、底部缩略图居中、关闭后保留滚动位置
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:11:30 +08:00
TerryM
e04dd7dc2a fix: 修复相册缩略图无法显示(dev 代理 + 前端兜底)
- vite: /uploads 代理指向源站根而非 /apnew 前缀,relative thumbnailUrl
  (/uploads/thumb…) 在 dev 下不再落到 SPA index.html
- BubbleImage: 新增 fallbackSrc 候选链,缩略图加载失败时自动回退到绝对
  thumbUrl/url,全部失败才显示占位符
- AlbumBubble / ImageLightbox: 缩略图传入绝对地址作为兜底

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:59:33 +08:00
TerryM
4a097bad9d feat: 热门榜单移除预览按钮,仅保留下载
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:58:30 +08:00
TerryM
1f8772f645 feat: 相册点击直接进大图查看器,移除「选择图片」列表
- AlbumBubble: 点 +N 直接打开全屏查看器(从该图开始),删除中间的选择列表弹窗
- ImageLightbox: 底部加可滑动缩略图条(当前高亮+自动滚动定位),顶栏加下载按钮
- 下载按钮保证此前藏在 +N 列表里的图片仍可下载

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:50:53 +08:00
TerryM
b22ecc22ad feat: 首页「最新更新」桌面端统一为手机端竖向气泡流(responsive)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:49:58 +08:00
TerryM
db5da8a841 feat: 优先使用 thumbUrl 作为 PDF 封面大图 2026-05-29 17:42:37 +08:00
TerryM
54bdbbc0e9 feat: 点击 logo 在首页时 scroll to top 2026-05-29 17:27:54 +08:00
3fa0a3ccc2 Merge pull request 'terry-staging' (#8) from terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 22s
Reviewed-on: #8
2026-05-29 08:40:53 +00:00
TerryM
7eb2aa8b5b feat: 区块标题改回「热门资料」/ Popular assets
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 15:42:16 +08:00
TerryM
512fa53c2b feat: 首页热门区改为「社群常用资料」榜单(封面+名次,零数字)
- 新增 PopularRankList:前3名奖牌🥇🥈🥉 + 4·5灰序号,封面缩略图,
  类型·分类·更新时间,预览/下载按钮;与「最新更新」「官方推荐」版式区分
- 无封面时按资料类型渲染渐变+图标兜底(纯CSS),封面加载失败亦回退
- 分类名复用 cleanCategoryDisplayName,与全站一致(去掉括号后缀)
- i18n popularSection 改为 社群常用资料 / Community Favorites
- 新增后端接口契约文档 docs/specs/2026-05-29-popular-resources-section.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:53:52 +08:00
TerryM
cfae09a7d3 fix: make window the vertical scroller so back-to-top works
body had height:100% + overflow-x:hidden, which forces computed
overflow-y to `auto` — turning body into its own scroll box at viewport
height. window.scrollY then never moved, so the back-to-top button never
appeared and window.scrollTo was a no-op.

Apply overflow-x:hidden to <html> only; it propagates to the viewport
(still clipping horizontal overflow) while leaving the window as the
vertical scroller.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:30:44 +08:00
TerryM
4f6b4a498f fix: scope back-to-top to browse, speed up reveal, reset scroll on sort change
- BackToTop now only mounts on the /browse feed (covers all / latest /
  popular / search) instead of every route.
- Reveal animation duration cut 0.4s -> 0.25s so scrolled-in content
  appears faster.
- ScrollToTop also watches `search`, so switching between sort views on
  the same /browse path (e.g. 全部资料 <-> 热门资料) returns to the top.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:22:40 +08:00
TerryM
559c4f19c8 feat: image error placeholder + scroll-to-top on navigation
Image bubbles previously used the raw filename as alt text, so a failed
asset load exposed the file name in the broken-image box. Add a reusable
BubbleImage that renders an empty alt and falls back to a neutral
placeholder (ImageOff icon) on error; use it in the album, image, and
image-with-text bubbles, and drop the filename from their aria-labels.

Also add a global ScrollToTop that resets the window on route change so
desktop navigation matches mobile (e.g. clicking a category card no
longer lands at the bottom of the new page). Hash navigations are skipped
so #post-<id> deep-link scrolling still works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:09:09 +08:00
TerryM
4464e6fdc5 fix: fall back to file icon when document thumbnail fails to load
Render the 52x52 preview with an empty alt and an onError handler, so a
broken thumbnail no longer shows the browser's broken-image box (which
leaked the raw filename) — it falls back to the file-type icon instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:08:16 +08:00
TerryM
b252fa113d feat: make entire recommended card clickable to jump to post bubble
Wrap the card in a stretched link overlay so clicking anywhere (not
just the title) navigates to /resource/:id and scrolls to the matching
bubble. Keep the download button above the overlay so it stays
clickable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:08:16 +08:00
87683293da Merge pull request 'style: apply prettier formatting to fix format:check' (#7) from terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 21s
Reviewed-on: #7
2026-05-29 04:49:48 +00:00
TerryM
f73131dc03 style: apply prettier formatting to fix format:check
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:49:22 +08:00
bcd7395e77 Merge pull request 'feat: show attachment preview thumbnail in document bubble' (#6) from terry-staging into main
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 11s
Reviewed-on: #6
2026-05-29 04:46:20 +00:00
TerryM
14c3defd23 feat: show attachment preview thumbnail in document bubble
Use thumbnailUrl/posterUrl (and the image url itself for image-type
attachments) inside the 52x52 box of the file/document bubble, falling
back to the file-type icon when no preview is available.

Also tune the deep-link scroll-mt offset (82px / 98px) so a targeted
bubble lands just below the sticky header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:40:17 +08:00
TerryM
88a25b6ad4 feat: scroll to post bubble from recommended card + back-to-top button
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 14s
Recommended cards already routed to /browse#post-<id>, but the stream had
no logic to scroll to the target bubble — and the post might not be paged
in yet. MessageStream now resolves the #post-<id> hash, auto-loads more
pages until the bubble renders, scrolls to it, and gives it a brief gold
highlight. Bubbles get scroll-mt so they clear the sticky header.

Also adds a global floating back-to-top button (BackToTop) mounted in
PublicLayout, shown after scrolling past 400px.

Bundles related staging UI work already present in the working tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:50:27 +08:00
TerryM
8e36894851 docs: add website motion & UX design spec 2026-05-29 10:44:54 +08:00
TerryM
35e25fa023 fix: hide language selector in desktop menu
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 20s
2026-05-29 01:08:26 +08:00
TerryM
9b21b7e301 feat: add public page fade in 2026-05-29 00:50:15 +08:00
TerryM
9afb4de859 docs: add Cloudflare cache purge manual 2026-05-29 00:25:14 +08:00
TerryM
026b037c5b fix: remove home category height animation
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 21s
2026-05-29 00:20:41 +08:00
TerryM
bca69fe3bd fix: use mark-only favicon
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 22s
2026-05-28 23:48:11 +08:00
TerryM
f7828d8776 Avoid search cache loading flicker 2026-05-28 23:17:49 +08:00
TerryM
320739f91b Add stale cache for public data 2026-05-28 23:09:18 +08:00
TerryM
5ae9647465 Add header reveal animations 2026-05-28 22:57:05 +08:00
TerryM
b59fd82006 Refine mobile dropdown close behavior 2026-05-28 22:41:23 +08:00
TerryM
b24529afc4 feat: enable category slug pages 2026-05-28 22:36:08 +08:00
TerryM
f183a401fc feat: enhance SEO with meta tags and sitemap, add DocumentMeta component 2026-05-28 22:28:23 +08:00
TerryM
f1a0e9ab40 feat: animate dropdown menus 2026-05-28 22:01:17 +08:00
TerryM
4e44636d68 feat: add global animation styles 2026-05-28 21:55:17 +08:00
TerryM
15d873be63 fix: align popular sorting links
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 20s
2026-05-28 18:52:01 +08:00
TerryM
6f901f48e1 feat: add mobile search panel 2026-05-28 18:51:55 +08:00
TerryM
5ca38a0eca fix: overlay lightbox captions without shifting image 2026-05-28 18:40:17 +08:00
TerryM
ef1f3163eb fix: use 16:9 home banner aspect 2026-05-28 18:38:26 +08:00
TerryM
c03a3c6d89 feat: add latest updates carousel controls 2026-05-28 18:28:28 +08:00
TerryM
c480eea7b7 Merge terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 19s
2026-05-28 17:56:40 +08:00
TerryM
b6ba4d53e7 fix: hide empty popular home section 2026-05-28 17:40:35 +08:00
TerryM
5036c930bb fix: stabilize desktop recommendation layouts 2026-05-28 17:31:32 +08:00
459c051fc8 Merge pull request 'terry-staging' (#5) from terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 21s
Reviewed-on: #5
2026-05-28 08:56:29 +00:00
TerryM
5fec82dbba fix: route home view-all links 2026-05-28 16:49:30 +08:00
TerryM
4c15e01460 fix: keep video downloads visible while playing 2026-05-28 16:37:00 +08:00
TerryM
ca6dfe0fe1 fix: use backend covers for recommendations 2026-05-28 16:33:16 +08:00
TerryM
6a998c0186 feat: separate popular browse navigation 2026-05-28 16:28:50 +08:00
TerryM
fea6e1c93b feat: add category and recommendations pages 2026-05-28 16:19:45 +08:00
TerryM
16b047ba04 fix: stick mobile footer nav to bottom 2026-05-28 16:19:21 +08:00
TerryM
e0240f6217 feat: refine home section navigation 2026-05-28 16:07:08 +08:00
TerryM
9d977be2d2 feat: link nav to home sections 2026-05-28 15:55:37 +08:00
TerryM
28b0ef3f9a feat: update figma category card icons 2026-05-28 15:49:08 +08:00
TerryM
3ed3d00655 feat: align home category and recommendation figma assets 2026-05-28 15:36:24 +08:00
TerryM
4b497380ee feat: add popular resources home section 2026-05-28 15:35:56 +08:00
TerryM
0e98428f64 feat: polish figma mobile home and nav 2026-05-28 15:32:15 +08:00
TerryM
e65c473369 feat: align figma browse and category sections 2026-05-28 15:11:13 +08:00
TerryM
16f3f06431 refactor: remove dead /api/resources download fallback from RecommendedCard
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 19s
2026-05-28 10:53:11 +08:00
TerryM
49f61b89f1 feat: apply figma browse mobile redesign 2026-05-28 10:41:53 +08:00
TerryM
3825c4ec2f feat: replace language selector text/globe with country flag icons 2026-05-28 10:27:47 +08:00
TerryM
f7d62bff6e i18n: translate brand name for ja/ko/vi/id/ms 2026-05-28 10:16:38 +08:00
TerryM
47cff67b87 feat: replace mobile header logo image with i18n text
Use ArkLogoMark + t('brand') on the mobile header so the wordmark can be translated (zh-CN: 'ARK 资料库', en: 'ARK Library'). Desktop nav already used this pattern.
2026-05-28 10:15:03 +08:00
TerryM
02c9d454c1 swap home banners, 3s autoplay, animated rec scroll thumb 2026-05-28 09:16:32 +08:00
TerryM
4a718926da fix: show download buttons on video albums
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 19s
2026-05-27 15:37:46 +08:00
TerryM
7e70798d68 fix: show download buttons on image albums
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 19s
2026-05-27 15:30:40 +08:00
TerryM
f5e858659f feat: add media overflow pickers 2026-05-27 15:28:51 +08:00
TerryM
23a7807bef feat: support multi-video post bubbles 2026-05-27 15:19:31 +08:00
TerryM
1ad599c3ac Prevent mobile horizontal scroll black edge 2026-05-27 15:18:27 +08:00
TerryM
a3b989303a Merge terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 19s
2026-05-27 13:38:55 +08:00
TerryM
d3d054ad32 Toggle lightbox captions on image click 2026-05-27 13:37:14 +08:00
910cebb03c Merge pull request 'terry-staging' (#4) from terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 19s
Reviewed-on: #4
2026-05-27 05:18:47 +00:00
TerryM
a6fda3cd03 Align compact message bubbles within media column 2026-05-27 13:16:40 +08:00
TerryM
565784b4bb Shrink non-media message bubbles to content 2026-05-27 12:58:35 +08:00
TerryM
1f3acca211 Unify message bubble width
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 21s
2026-05-27 12:46:31 +08:00
TerryM
902300933e Share media attachment download pill 2026-05-27 12:35:47 +08:00
TerryM
8120f6b05c feat: add expandable filter chips 2026-05-27 12:35:47 +08:00
TerryM
54841a4ed9 Show attachment download progress 2026-05-27 12:35:47 +08:00
TerryM
9453777dba Use backend endpoint for attachment downloads 2026-05-27 12:35:47 +08:00
TerryM
68cbce9cf1 style: make message cards fluid width 2026-05-27 12:35:47 +08:00
TerryM
7cd48f767e style: align and widen message stream cards 2026-05-27 12:35:47 +08:00
TerryM
3f0a395f40 feat: unify search with browse page 2026-05-27 12:35:47 +08:00
TerryM
f169144378 add swipeable banner slider with autoplay 2026-05-27 12:35:47 +08:00
80f79a3ace Merge branch 'main' into terry-staging 2026-05-27 04:25:39 +00:00
TerryM
2b1874ab01 implement figma mobile header 2026-05-27 11:00:52 +08:00
TerryM
7546faf15e remove wallet functionality 2026-05-27 10:40:02 +08:00
3ff3ce1468 Merge pull request 'terry-staging' (#3) from terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 56s
Reviewed-on: #3
2026-05-26 12:59:23 +00:00
1f89363b6d Merge branch 'main' into terry-staging 2026-05-26 12:59:11 +00:00
TerryM
292383f122 fix: replace mobile wallet nav item 2026-05-26 20:02:40 +08:00
TerryM
54f71c6ab3 feat: refine language menu and lightbox caption 2026-05-26 18:37:17 +08:00
TerryM
532f0112fd feat: polish message attachment downloads 2026-05-26 18:10:34 +08:00
TerryM
e0629c9df7 fix: allow copying message stream text 2026-05-26 18:10:34 +08:00
5a3568820e Merge pull request 'Update deploy.yml' (#2) from terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 56s
Reviewed-on: #2
2026-05-26 07:33:03 +00:00
625d9fbb42 Merge branch 'main' into terry-staging 2026-05-26 07:31:07 +00:00
TerryM
31b7d53b69 Update deploy.yml 2026-05-26 15:26:40 +08:00
efc41fbd2f Merge pull request 'terry-staging' (#1) from terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 56s
Reviewed-on: #1
2026-05-26 06:53:11 +00:00
TerryM
f6c0f30921 refactor: organize pages into folders 2026-05-26 14:46:05 +08:00
TerryM
78bdf73143 feat: enable real posts api by default 2026-05-26 14:07:10 +08:00
TerryM
d3c30795dc feat: wire public posts api 2026-05-26 12:07:13 +08:00
TerryM
f482a2ec38 fix: unify chinese language code as zh-CN 2026-05-26 10:03:12 +08:00
TerryM
f2e97c329e fix: map chinese language requests to zh-CN 2026-05-26 08:09:20 +08:00
TerryM
e7a5952d58 feat: align frontend languages with posts api 2026-05-26 07:36:53 +08:00
TerryM
453abfcec7 chore: ignore agent local state 2026-05-25 06:06:06 +08:00
TerryM
a784f159fe feat: add telegram-style resource stream 2026-05-25 05:25:57 +08:00
TerryM
aaebd7ccd1 chore: comment legacy api nginx route
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 47s
2026-05-24 00:43:40 +08:00
3f0a9f72d9 1
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 48s
2026-05-24 00:31:42 +08:00
769087ba4a Route same-origin API via /apnew/api to bypass ALB /api* rule.
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 53s
ALB sends /api/* to an unreachable backend target group (502 on apex).
Use VITE_API_PREFIX=/apnew with nginx proxy to backend-1 until the listener rule is removed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 17:56:38 +08:00
2c710e2e24 Same-origin API: empty VITE_API_URL with nginx proxy to backend-1.
Frontends call /api/ on ark-library.com; nginx forwards internally to 100.93.205.19.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 17:42:59 +08:00
TerryM
e6bc212c4e fix: align official recommendations behavior
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 47s
2026-05-19 00:34:29 +08:00
258 changed files with 21340 additions and 2882 deletions

View File

@@ -1,9 +1,6 @@
# API origin. Leave empty for same-origin/local Vite proxy.
VITE_API_URL=
# Reown / WalletConnect project id. Required for WalletConnect QR/mobile login.
VITE_WALLETCONNECT_PROJECT_ID=
# Public production deploy disables admin routes.
VITE_DISABLE_ADMIN=false
@@ -12,3 +9,11 @@ VITE_ADMIN_ONLY=false
# Optional admin UI base path. Leave empty to use default app behavior.
VITE_ADMIN_UI_PREFIX=
# Use mock Post data (Telegram-style resource stream) only when explicitly enabled.
# Default production/staging behavior should hit the real /api/posts API.
VITE_USE_MOCK_POSTS=false
# Reown/WalletConnect project ID used by RainbowKit fallback QR login
# for MetaMask/imToken. TokenPocket QR does not depend on this.
VITE_WALLETCONNECT_PROJECT_ID=

View File

@@ -0,0 +1,125 @@
name: Deploy Staging (terry-wallet-login)
on:
push:
branches:
- terry-wallet-login
jobs:
deploy:
runs-on: self-hosted
steps:
- name: Free disk space
run: |
set +e
echo "=== Disk before cleanup ==="
df -h
du -sh "$HOME/.cache/act" "$HOME/.npm" "$HOME/actions-runner/_work" 2>/dev/null
# DO NOT touch ~/.cache/act for the current job — only sweep dirs older than 60 min.
if [ -d "$HOME/.cache/act" ]; then
find "$HOME/.cache/act" -mindepth 1 -maxdepth 1 -type d -mmin +60 -exec rm -rf {} + 2>/dev/null
fi
# Wipe npm and setup-node caches (cache: npm will repopulate from registry).
rm -rf "$HOME/.npm/_cacache" "$HOME/.npm/_logs" 2>/dev/null
rm -rf "$HOME/.cache/setup-node" 2>/dev/null
# Old actions-runner workspaces (>6h)
if [ -d "$HOME/actions-runner/_work" ]; then
find "$HOME/actions-runner/_work" -mindepth 1 -maxdepth 2 -mmin +360 -exec rm -rf {} + 2>/dev/null
fi
# Docker aggressive prune (all dangling + unused, including volumes)
if command -v docker >/dev/null 2>&1; then
docker system prune -af --volumes 2>/dev/null
fi
# apt/yum cache
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get clean 2>/dev/null
fi
if command -v yum >/dev/null 2>&1; then
sudo yum clean all 2>/dev/null
fi
# /tmp leftovers (>30 min) other than active runner state
find /tmp -mindepth 1 -maxdepth 1 -mmin +30 \
-not -name 'runner*' -not -name 'act*' -not -name 'tmp.*' \
-exec rm -rf {} + 2>/dev/null
# journald logs vacuum to 100M
if command -v journalctl >/dev/null 2>&1; then
sudo journalctl --vacuum-size=100M 2>/dev/null
fi
echo "=== Disk after cleanup ==="
df -h
exit 0
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Install dependencies
run: npm ci
- name: Type check
run: npx tsc --noEmit
- name: Format check
run: npm run format:check
- name: Test
run: npm test
- name: Build
run: npm run build
env:
VITE_API_URL: ""
VITE_API_PREFIX: "/apnew"
VITE_DISABLE_ADMIN: "true"
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.STAGING_SSH_KEY }}" > ~/.ssh/staging_key
chmod 600 ~/.ssh/staging_key
ssh-keyscan -H ${{ secrets.STAGING_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
- name: Deploy to staging server
run: |
set -euo pipefail
HOST="${{ secrets.STAGING_HOST }}"
USER="${{ secrets.STAGING_USER }}"
echo ">>> 部署到 staging $USER@$HOST"
rsync -avz --delete \
-e "ssh -i ~/.ssh/staging_key -o StrictHostKeyChecking=no" \
dist/ \
"${USER}@${HOST}:/var/www/ark-library-staging/"
echo ">>> staging 部署完成"
- name: Verify staging server matches local build
run: |
set -euo pipefail
LOCAL=$(sha256sum dist/index.html | awk '{print $1}')
REMOTE=$(ssh -i ~/.ssh/staging_key -o StrictHostKeyChecking=no \
${{ secrets.STAGING_USER }}@${{ secrets.STAGING_HOST }} \
"sha256sum /var/www/ark-library-staging/index.html | awk '{print \$1}'")
echo "local: $LOCAL"
echo "staging: $REMOTE"
if [ "$REMOTE" != "$LOCAL" ]; then
echo "ERROR: staging 不是本次构建的版本"
exit 1
fi
echo "✓ staging 已经更新到本次构建的版本。"
- name: Cleanup SSH key
if: always()
run: rm -f ~/.ssh/staging_key

View File

@@ -10,6 +10,65 @@ jobs:
runs-on: self-hosted
steps:
- name: Ensure runner disk space
run: |
set -e
echo "=== Disk before resize ==="
df -h /
hostname || true
lsblk -o NAME,SIZE,TYPE,MOUNTPOINT,FSTYPE || true
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
echo "Root source: $ROOT_SOURCE ($ROOT_FSTYPE), disk: /dev/${DISK_NAME:-unknown}, partition: ${PART_NUM:-unknown}"
if [ -n "$DISK_NAME" ]; then
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
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
- name: Checkout code
uses: actions/checkout@v4
@@ -34,7 +93,8 @@ jobs:
- name: Build
run: npm run build
env:
VITE_API_URL: https://api.ark-library.com
VITE_API_URL: ""
VITE_API_PREFIX: "/apnew"
VITE_DISABLE_ADMIN: "true"
- name: Setup SSH key
@@ -47,6 +107,7 @@ jobs:
- name: Deploy to both servers
run: |
set -euo pipefail
deploy_to() {
local HOST=$1
echo ">>> 部署到 $HOST"
@@ -57,25 +118,37 @@ jobs:
echo ">>> $HOST 部署完成"
}
deploy_to "${{ secrets.FRONTEND_1_HOST }}" &
PID1=$!
deploy_to "${{ secrets.FRONTEND_2_HOST }}" &
wait
PID2=$!
FAIL=0
wait $PID1 || { echo "ERROR: frontend-1 部署失败"; FAIL=1; }
wait $PID2 || { echo "ERROR: frontend-2 部署失败"; FAIL=1; }
[ $FAIL -eq 0 ] || exit 1
echo "=== 两台都部署完成 ==="
- name: Verify both servers match
- name: Verify both servers match local build
run: |
set -euo pipefail
LOCAL=$(sha256sum dist/index.html | awk '{print $1}')
SUM1=$(ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
ec2-user@${{ secrets.FRONTEND_1_HOST }} \
"sha256sum /var/www/ark-library/index.html | awk '{print \$1}'")
SUM2=$(ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
ec2-user@${{ secrets.FRONTEND_2_HOST }} \
"sha256sum /var/www/ark-library/index.html | awk '{print \$1}'")
echo "local: $LOCAL"
echo "frontend-1: $SUM1"
echo "frontend-2: $SUM2"
if [ "$SUM1" != "$SUM2" ]; then
echo "ERROR: 两台 index.html 不一样!"
if [ "$SUM1" != "$LOCAL" ]; then
echo "ERROR: frontend-1 不是本次构建的版本"
exit 1
fi
echo "✓ 两台 checksum 一致,部署成功。"
if [ "$SUM2" != "$LOCAL" ]; then
echo "ERROR: frontend-2 不是本次构建的版本"
exit 1
fi
echo "✓ 两台都已经更新到本次构建的版本。"
- name: Cleanup SSH key
if: always()

9
.gitignore vendored
View File

@@ -30,3 +30,12 @@ pnpm-debug.log*
coverage/
.cache/
.vite/
# Agent local state / workflow noise
.oh-my-opencode-pi-*
.omc/
.unipi/ralph/
.unipi/logs/
# Visual brainstorming companion
.superpowers/

View File

@@ -0,0 +1,103 @@
---
title: "MetaMask Wallet Login Does Not Surface Address — Debug Report"
type: debug
date: 2026-06-02
severity: high
status: root-caused
---
# MetaMask Wallet Login Does Not Surface Address — Debug Report
## Summary
MetaMask QR login and mobile deeplink login can be approved in MetaMask, but the ARK frontend does not write the approved wallet address into the local wallet session; the mobile QR waiting text is also incorrectly TokenPocket-specific for all wallets.
## Expected Behavior
- Selecting MetaMask and approving the connection should result in the page showing the connected wallet address.
- Mobile MetaMask deeplink login should return/reconnect to the page and complete local no-signature login with `local-wallet:<address>`.
- QR login copy should be generic or absent; it should not say “waiting in TokenPocket” when the selected wallet is MetaMask or imToken.
## Actual Behavior
- Terry can approve MetaMask QR/deeplink login, but the web page does not show the authorized address.
- The QR panel uses TokenPocket-specific copy for any wallet on mobile, e.g. imToken QR login still shows a TokenPocket waiting message.
## Reproduction Steps
1. Open the public frontend and open the wallet login modal.
2. Select MetaMask.
3. Use either:
- QR login: scan the QR using MetaMask and approve, or
- Mobile app login: tap “Open wallet app”, approve in MetaMask, then return to the web page.
4. Observe that the page does not show the wallet address.
5. Select imToken QR login on mobile and observe that the QR panel displays TokenPocket-specific waiting text.
## Environment
- Project: Arkie Library Frontend (`ark-database-web`)
- Branch context: `terry-wallet-login`
- Stack: React 18, Vite, TypeScript, RainbowKit, Wagmi, WalletConnect/Reown
- Wallets involved: MetaMask Mobile, TokenPocket, imToken
- Backend auth endpoints intentionally not required for this flow; login is local no-signature wallet session.
## Root Cause Analysis
### Failure Chain
1. `WalletLoginModal` calls `wc.start(kind, mode)` for all wallet app/QR flows.
2. `useWalletConnectLogin.start()` currently chooses the first generic WalletConnect connector for non-injected flows:
- `connectors.find((item) => item.type === "walletConnect") ?? connectors.find((item) => item.id === "walletConnect")`
3. On MetaMask mobile, RainbowKits own MetaMask wallet definition intentionally uses Wagmis `metaMask()` / MetaMask SDK connector, not the generic WalletConnect connector.
4. The custom hook bypasses that MetaMask-specific connector on mobile, so MetaMask SDK deeplink/reconnect handling is not used.
5. The hook only calls `completeLogin(localWalletToken(address), address)` inside the awaited `connectAsync(...)` result path.
6. If MetaMask approval completes while the browser is backgrounded, after a page reload, or through a restored Wagmi connection, there is no `useAccount`/reconnect bridge that converts the Wagmi connected address into the apps local wallet session.
7. `RainbowWalletProvider` calls `useReconnect()`, but this only restores Wagmi connection state; it does not update `WalletProvider` unless `useWalletConnectLogin` observes the restored account and calls `completeLogin`.
8. Therefore MetaMask may be approved/connected at the wallet/Wagmi layer but the ARK UI still has no `local-wallet:<address>` token and shows logged-out/no address.
### Root Cause
The MetaMask flow is treated as a generic WalletConnect flow, but RainbowKit/Wagmi have MetaMask-specific mobile behavior. Additionally, local ARK wallet login is tied only to the synchronous `connectAsync` return path instead of being derived from Wagmi account state/reconnect events. This misses MetaMask connections that resolve via mobile app backgrounding, deep link return, QR approval, or page reload.
### Evidence
- File: `src/wallet/useWalletConnectLogin.ts` — connector selection prefers `item.type === "walletConnect"` for all wallets, so mobile MetaMask does not use the RainbowKit/Wagmi MetaMask connector.
- File: `src/wallet/useWalletConnectLogin.ts``completeLogin(localWalletToken(...), ...)` only runs after `await connectAsync(...)`; there is no `useAccount` effect to complete local login when Wagmi is already/reconnected.
- File: `src/wallet/RainbowWalletProvider.tsx``WalletReconnectOnMount` calls `useReconnect()`, but no downstream code maps the restored Wagmi account into `WalletProvider`.
- File: `node_modules/@rainbow-me/rainbowkit/dist/wallets/walletConnectors/chunk-BQHQU37S.js` — RainbowKit MetaMask wallet uses `metaMask()` connector on mobile and comments that “MetaMask mobile deep linking [is] handled by wagmi”. The custom hook bypasses this by selecting a generic WalletConnect connector.
- File: `src/wallet/WalletLoginModal.tsx` — QR text uses `mobileDevice ? t("walletTpWaiting") : t("walletQrUseAnotherDevice")` for every selected wallet, causing TokenPocket-specific copy for MetaMask/imToken.
- File: `src/locales/en.ts` and `src/locales/zh-CN.ts``walletQrUseAnotherDevice` also explicitly mentions TokenPocket, so even desktop/generic QR copy is wallet-specific.
## Affected Files
- `src/wallet/useWalletConnectLogin.ts` — connector choice, deeplink generation, QR URI generation, local-login completion.
- `src/wallet/WalletLoginModal.tsx` — misleading QR panel copy.
- `src/wallet/RainbowWalletProvider.tsx` — currently reconnects Wagmi but does not by itself complete local login.
- `src/locales/*.ts` — QR copy currently contains TokenPocket-specific text.
## Suggested Fix
Use the wallet-specific connector when a wallet is selected, especially MetaMask, and add a Wagmi account/reconnect bridge that completes the local wallet session whenever Wagmi has an address. Remove or generalize TokenPocket-specific QR waiting copy.
### Fix Strategy
1. In `useWalletConnectLogin.ts`, prefer a connector matching `preferredWallet` before falling back to generic WalletConnect:
- MetaMask: `id === "metaMask"` or `type === "metaMask"`
- imToken/TokenPocket: matching wallet id when present, otherwise generic WalletConnect
2. Add `useAccount()` inside `useWalletConnectLogin` and a `useEffect` that calls `completeLogin(localWalletToken(address), address)` when Wagmi reports `isConnected && address`.
3. For MetaMask QR, transform the displayed QR value with the same wallet-specific URI RainbowKit uses: `https://metamask.app.link/wc?uri=<encoded_wc_uri>` instead of always rendering raw `wc:`.
4. Remove the QR panel paragraph entirely, or replace it with generic copy such as “Please approve the connection in your wallet app.”
5. Re-test MetaMask separately for desktop QR scan, mobile Chrome deeplink return, and MetaMask in-app browser injected login.
### Risk Assessment
- Risk: Selecting MetaMasks SDK connector may behave differently from WalletConnect for QR mode. Mitigate by falling back to WalletConnect if the MetaMask connector does not emit a `display_uri` in QR mode.
- Risk: Auto-completing local login from any Wagmi connected account may log in a stale account after reconnect. Mitigate by only completing when modal/pending/login-in-progress context exists or by clearing stale flags.
- Risk: Changing QR value for MetaMask may affect wallets that scan raw WalletConnect URIs. Mitigate by only applying MetaMask app-link QR transformation for MetaMask.
## Verification Plan
1. Run `npx tsc --noEmit`.
2. Run `npm run format:check`.
3. Run `npm test`.
4. Desktop Chrome + MetaMask Mobile QR: Select MetaMask → QR login → scan/approve → confirm address and `local-wallet:<address>`.
5. Mobile Chrome + MetaMask app login: Select MetaMask → Open wallet app → approve → return/refresh browser → confirm address appears.
6. Regression test TokenPocket and imToken app login.
7. Confirm imToken/MetaMask QR login no longer displays TokenPocket-specific text.
## Related Issues
- TokenPocket was previously fixed by handling mobile return/reload behavior.
- imToken was previously fixed by adding in-app browser fallback and injected no-signature login.
- Existing local-memory context indicates MetaMask QR approval has been observed to not update frontend state.
## Notes
- This report is diagnosis only; no source fix was applied during `/unipi:debug`.
- The local no-signature session model means the frontend does not need backend wallet nonce/verify endpoints for this fix.
- The current debug UI showing `Wallet debug` may be useful during verification but should be removed or hidden before final production cleanup if no longer needed.

View File

@@ -0,0 +1,137 @@
---
title: "imToken opens in-app browser but cannot get wallet address — Debug Report"
type: debug
date: 2026-06-04
severity: high
status: needs-investigation
---
# imToken opens in-app browser but cannot get wallet address — Debug Report
## Summary
imToken can open the site in its in-app browser, but the frontend does not obtain a wallet address and the login flow stays at `connected: no`.
## Expected Behavior
1. User taps imToken from Chrome or from imToken's in-app browser.
2. The site detects the imToken injected wallet provider.
3. The frontend requests accounts via the injected provider.
4. The frontend receives a wallet address and completes login.
## Actual Behavior
The page opens inside imToken, but the wallet modal remains stuck with:
- `state: connecting`
- `connected: no`
- `address: -`
- `qr: -`
The supplied screenshot shows the page loaded at `192.168.1.187` and the imToken card selected.
## Reproduction Steps
1. Open the site in Chrome on mobile.
2. Tap wallet login.
3. Select imToken.
4. The app opens imToken's in-app browser.
5. Login does not complete and no wallet address appears.
6. Open the same page inside imToken's in-app browser and tap login again.
7. The wallet debug panel still shows `connected: no` and `address: -`.
## Environment
- Branch: `terry-wallet-login`
- Frontend URL shown in screenshot: `192.168.1.187`
- Wallet: imToken mobile in-app browser
- Network path: local LAN IP, likely non-HTTPS
## Root Cause Analysis
### Failure Chain
1. `WalletLoginModal` selects imToken and exposes the mobile actions.
2. `openWalletAppDirect(kind)` first checks `getInjectedWallet(kind)`.
3. If no injected provider is detected, the flow deep-links to imToken or falls through to `useWalletConnectLogin.start()` depending on which action is tapped.
4. `useWalletConnectLogin.start()` sets `state` to `connecting` before trying direct injected login.
5. The direct injected branch only runs when `getInjectedWallet(preferredWallet)` returns a provider.
6. The screenshot shows `state: connecting`, `connected: no`, `address: -`, and `qr: -`, which means no address was completed through either direct injected login or WalletConnect.
7. `AutoInjectedLogin` can auto-start imToken login without `?autoLogin=imToken` only if `isImTokenBrowser()` detects the imToken user agent; it still depends on `waitForInjected("imToken")`, which depends on `getInjectedWallet("imToken")`.
8. Therefore the app is not obtaining a usable imToken injected provider, or the provider is present but `eth_accounts` / `eth_requestAccounts` returns no valid address.
### Root Cause
The immediate root cause is failure to obtain a usable injected imToken provider/account. The most likely reason from the screenshot is that the page is running on raw LAN IP `192.168.1.187`, likely over HTTP. imToken may not inject its EIP-1193 provider into non-HTTPS/raw-IP pages, or its iOS in-app browser may not expose a user agent/provider shape that matches the current checks.
The code currently assumes at least one of these is true:
- URL contains `?autoLogin=imToken`, or
- `navigator.userAgent` matches `/imtoken/i`, and
- `window.ethereum` is present and accepted by `getInjectedWallet("imToken")`, and
- `eth_accounts` or `eth_requestAccounts` returns a valid `0x...` address.
The observed behavior shows that this assumption chain is breaking before a valid address is produced.
### Evidence
- File: `src/wallet/WalletLoginModal.tsx``openWalletAppDirect()` uses `getInjectedWallet(kind)` as the gate for direct injected login; if it is false, the flow navigates/deep-links instead of reading an address.
- File: `src/wallet/useWalletConnectLogin.ts``start()` sets `state` to `connecting`; the imToken direct local-session path only executes inside `if (mode === "deeplink" && preferredWallet && getInjectedWallet(preferredWallet))`.
- File: `src/wallet/AutoInjectedLogin.tsx` — auto-login picks imToken from the query parameter or `isImTokenBrowser()`, then waits for `getInjectedWallet("imToken")` before calling `connectInjectedWallet("imToken")`.
- File: `src/wallet/injected.ts``getInjectedWallet("imToken")` falls back to generic `window.ethereum` only when `isImTokenBrowser()` is true.
- Screenshot evidence: modal shows `state: connecting`, `connected: no`, `address: -`, `qr: -`, and URL `192.168.1.187`.
## Affected Files
- `src/wallet/WalletLoginModal.tsx` — user entry point for mobile imToken login.
- `src/wallet/AutoInjectedLogin.tsx` — auto-login effect for wallet in-app browsers.
- `src/wallet/useWalletConnectLogin.ts` — direct injected login vs WalletConnect fallback selection.
- `src/wallet/injected.ts` — provider/account detection and account request logic.
- `src/wallet/deepLinks.ts` — imToken in-app browser deeplink target.
## Suggested Fix
Do not guess blindly from `connected: no`; first make the runtime state visible. Add a temporary imToken diagnostic surface or alert that reports:
- `navigator.userAgent`
- `location.href`
- whether `window.ethereum` exists
- whether `window.ethereum.providers` exists and its length
- provider flags: `isImToken`, `isMetaMask`, `isTokenPocket`
- result/error of `eth_accounts`
- result/error of `eth_requestAccounts`
- current `isSecureContext`
Then apply one of these fixes based on the diagnostic result:
### Fix Strategy
1. If `window.ethereum` is missing on `192.168.1.187`, test and deploy through an HTTPS domain/tunnel because imToken is likely not injecting on the LAN IP origin.
2. If `window.ethereum` exists but `isImTokenBrowser()` is false, broaden imToken detection or allow a user-selected imToken flow to try generic `window.ethereum` before WalletConnect.
3. If `eth_accounts` is empty and `eth_requestAccounts` errors, surface that wallet error in the modal instead of leaving `state: connecting`.
4. If a valid address is returned, complete imToken login with the local-session path already used by `connectInjectedWallet()` + `localWalletToken()`.
5. Ensure the imToken mobile button never silently falls into WalletConnect when the user is already inside imToken and selected imToken; it should either get an address or show a clear injected-provider/account error.
### Risk Assessment
- Broadly accepting generic `window.ethereum` could pick the wrong provider in multi-wallet browsers. Mitigation: only do this for explicit user selection of imToken or when already inside imToken.
- Testing on LAN IP can produce false negatives. Mitigation: verify on the actual HTTPS domain or an HTTPS tunnel before judging imToken support.
- More diagnostics can expose wallet details on screen. Mitigation: keep diagnostics temporary or behind a debug flag.
## Verification Plan
1. Test in imToken in-app browser on the production HTTPS domain.
2. Test in imToken in-app browser on the current LAN IP to confirm whether `window.ethereum` is missing there.
3. Record diagnostic output for `userAgent`, `hasEthereum`, provider flags, `eth_accounts`, and `eth_requestAccounts`.
4. From Chrome, tap imToken and confirm the in-app browser receives either `?autoLogin=imToken` or the imToken browser fallback runs.
5. Inside imToken, tap wallet login and confirm it does not remain at `state: connecting` without an address.
## Related Issues
- `.unipi/docs/fix/2026-06-04-imtoken-injected-provider-detection-fix.md`
- `.unipi/docs/fix/2026-06-04-imtoken-restore-local-session-login-fix.md`
- `.unipi/docs/fix/2026-06-04-imtoken-auto-login-without-query-fix.md`
## Notes
The current screenshot strongly suggests that the problem is not backend verification or local token writing. The flow never reaches an address. The next useful step is to confirm whether imToken is injecting `window.ethereum` on the tested origin.

View File

@@ -0,0 +1,35 @@
---
title: "Category page stream layout mismatch — Quick Fix"
type: quick-fix
date: 2026-06-03
---
# Category page stream layout mismatch — Quick Fix
## Bug
After clicking a card on 资料分类 (`/categories`) and landing on `/category/:slug`, the resource bubbles render with narrower bubbles / different waterfall spacing than the 全部资料 page (`/browse`). Both pages use the same `MessageStream` component, but the page-level wrapper applies extra horizontal padding only to the category route.
## Root Cause
`src/layouts/PublicLayout.tsx` chooses the `<main>` padding using a flag named `footerInContentFlow`, defined as:
```ts
const footerInContentFlow = stripLangPrefix(pathname) === "/browse";
```
That flag selects the `px-0 ... md:px-9 xl:px-0` zero-mobile-padding branch — which is the layout `MessageStream` is designed for (it manages its own inner `max-w` and centers bubbles). All other routes fall through to the default `px-4 min-[440px]:px-5 sm:px-6 md:px-9 ...`, which on mobile inset the stream by 1624 px and shrunk each bubble's `max-w` proportionally. Because `/category/:slug` rendered the same `MessageStream`, that extra inset is exactly what made the category waterfall look "off" vs `/browse`.
## Fix
Extend the same zero-padding branch to also match `/category/<slug>`, so both routes share the wrapper that `MessageStream` was designed to live in.
### Files Modified
- `src/layouts/PublicLayout.tsx` — renamed the flag's derivation to also include `/category/*`. Kept the existing `BackToTop` and footer-in-content checks (`stripLangPrefix(pathname) === "/browse"`) untouched, since those are separate features the user did not ask to share with category pages.
## Verification
- `npx tsc --noEmit` — clean.
- `npm run format:check` — clean.
- `npm test` — 49/49 passing.
- Visual: opening `/categories` → tapping a category card now lands on a `/category/:slug` view whose `<main>` matches `/browse` (no extra mobile horizontal inset), so bubbles render with the same `max-w-[358px]` width.
## Notes
- The flag is still called `footerInContentFlow` for now even though it only controls padding, matching prior code; renaming would expand the change footprint beyond this fix.
- BackToTop and the `footerInContentFlow` footer slot remain `/browse`-only — those are independent of layout width and the user didn't ask to enable them on category pages.

View File

@@ -0,0 +1,55 @@
---
title: "FilterChips scroll position lost on filter click (remount) — Quick Fix"
type: quick-fix
date: 2026-06-03
---
# FilterChips scroll position lost on filter click (remount) — Quick Fix
## Bug
On mobile (and in general), scrolling the `FilterChips` horizontal bar to the right and then clicking a chip caused the bar to snap all the way back to the leftmost position. Earlier attempts to fix this with `useRef` + `useLayoutEffect` save/restore inside `FilterChips` did not work — the bar kept resetting.
## Root Cause
`PublicLayout` wraps the routed page in:
```tsx
<AnimatePresence mode="wait" initial={false}>
<m.div key={`${pathname}${search}`} variants={pageTransition} >
{outlet}
</m.div>
</AnimatePresence>
```
(`src/layouts/PublicLayout.tsx:761`)
When the user clicks a filter chip, `MessageStream`'s `updateParam("type", v)` mutates the `search` query (e.g. `?type=archive`). That changes the `m.div`'s `key`, so `AnimatePresence` **fully unmounts** the current page and **mounts a fresh one**. `FilterChips` is part of that page tree, so it is destroyed and re-created — every internal `useRef` resets to its initial value, which is why the previous in-component save/restore approach silently failed.
A secondary symptom (already present before this fix) was an iOS Safari quirk where the sibling `ScrollToTop`'s `window.scrollTo({top:0, left:0})` triggers a relayout of the sticky bar that asynchronously sets the inner `overflow-x` `scrollLeft` back to 0.
## Fix
Move the saved `scrollLeft` to a **module-level** variable so it survives the unmount/remount cycle, and restore it on first paint after every fresh mount. Combine with the existing user-input-only save logic and an iOS post-mount watch window to defeat the asynchronous reset.
Key points in the new `FilterChips`:
1. `let lastScrollLeft = 0;` declared at module scope.
2. `useLayoutEffect(() => { … }, [])` on mount: if `lastScrollLeft > 0`, restore `scrollLeft` synchronously before paint.
3. `useEffect(() => { … }, [])` saves `lastScrollLeft` only on `touchend` / `pointerup` / `wheel` (deferred by one rAF so iOS momentum has settled). We deliberately do **not** save on raw `scroll` events because iOS Safari's quirky 0-reset fires one of those too.
4. A second `useEffect(() => { … }, [])` runs a ~1.5 s post-mount watch: rAF tick plus a `scroll` listener that re-applies the saved value **only when `scrollLeft` snaps to 0**, and only when the user is not actively touching the bar (so a fresh scroll gesture in that window isn't yanked back).
### Files Modified
- `src/components/messageStream/FilterChips.tsx`
- Added module-level `lastScrollLeft`.
- Added `useLayoutEffect` to restore on mount.
- Kept the user-input-only save effect.
- Kept the ~1.5 s iOS-quirk watch (now keyed to mount instead of `[type]` since the component remounts anyway).
## Verification
- `npx tsc --noEmit` — clean.
- `npm run format:check` — clean.
- `npm test` — 49/49 passing.
- Expected behavior on device: scroll the filter bar to the right, tap any chip (e.g. 压缩包) — bar should stay exactly where the user left it, with the new active chip already highlighted in gold.
## Notes
- Module-level state is intentional and acceptable here: there is only ever one `FilterChips` mounted at a time, and the value semantically belongs to the user's session, not the component instance.
- If this `AnimatePresence` `key` strategy changes (or `FilterChips` is reused outside `MessageStream` in a context with multiple instances), revisit this — the module value would then need to be scoped per surface (e.g. via a Map keyed by surface id).
- Previous attempts using `useRef` inside `FilterChips` failed because they ignored the remount caused by `AnimatePresence`. That is now documented inline in the component.

View File

@@ -0,0 +1,35 @@
---
title: "FilterChips simplify — strip comments and remove 1.5s watch window"
type: quick-fix
date: 2026-06-03
---
# FilterChips simplify — strip comments and remove 1.5s watch window
## Bug
The previous `FilterChips` carried too much defensive machinery (long inline comments, a 1.5s post-mount `requestAnimationFrame` + `scroll`-event watch window that re-applied `lastScrollLeft` whenever the bar snapped to 0). The watch window could interfere with a fresh slide gesture right after a filter tap, and the surrounding prose made the component hard to read.
## Root Cause
Over-engineering: the iOS-quirk watch loop and its `touching` guard were added defensively but were not strictly needed once `lastScrollLeft` was lifted out to a module-level slot and restored synchronously in `useLayoutEffect`. The extra event listeners were also a source of friction when the user immediately slid the bar after tapping a chip.
## Fix
Reduce `FilterChips` to just the essentials:
- Module-level `lastScrollLeft` (kept) — survives the `AnimatePresence` remount that happens when `?type=…` changes.
- `useLayoutEffect` on mount (kept) — restores `scrollLeft = lastScrollLeft` before paint so the new instance starts where the user left off.
- Desktop wheel-to-horizontal handler (kept) — necessary for mice without horizontal wheels.
- Save effect on `touchend` / `pointerup` / `wheel` (kept) — captures the user's final scroll position, deferred by one rAF so iOS momentum settles before recording.
- Removed: the ~1.5s rAF + `scroll` watch loop and its `touching` flag.
- Removed: all explanatory inline comments — the code is short enough to be self-evident now.
### Files Modified
- `src/components/messageStream/FilterChips.tsx` — stripped the 1.5s watch effect and every prose comment; kept the mount-restore and user-input save flow.
## Verification
- `npx tsc --noEmit` — clean.
- `npm run format:check` — clean.
- `npm test` — 49/49 passing.
- Behavior expected on device: tapping a filter highlights it in gold; sliding the bar after the tap is no longer pulled back to the previous position.
## Notes
- If the iOS sticky/scroll-to-top quirk actually re-surfaces in production, the fallback would be to move the bar out of the `AnimatePresence`-keyed subtree (so it never unmounts), rather than re-introducing the watch loop.

View File

@@ -0,0 +1,29 @@
---
title: "Mobile menu drawer invisible — Quick Fix"
type: quick-fix
date: 2026-06-03
---
# Mobile menu drawer invisible — Quick Fix
## Bug
After redesigning the mobile menu to the full-screen Figma drawer (`4164-5336``ARK V2 - 導航菜單`), tapping the hamburger toggled the icon to `X` but the drawer overlay never appeared on screen. Page content stayed fully visible and the bottom nav stayed on top.
## Root Cause
The drawer was rendered as a child of `<header className="sticky top-0 z-40 …">`. A `position: sticky` element with a `z-index` creates its own stacking context, which traps the drawer's `position: fixed; z-50` inside that context. Globally, the drawer ends up bound to the header's `z-40` layer, while the unrelated bottom navigation (`<nav className="fixed inset-x-0 bottom-0 z-40 …">`) lives in the root stacking context at `z-40`. With equal global `z`, source order wins — the bottom nav paints later and the drawer never reaches the foreground.
## Fix
Move the drawer JSX out of `<header>` and render it as a sibling at the layout root, so its `fixed`/`z-50` positioning lives in the root stacking context and stacks above both the header and the bottom nav.
### Files Modified
- `src/layouts/PublicLayout.tsx` — relocated the `{open ? (…) : null}` mobile drawer block from inside `<header>` to immediately after `</header>`. Logic unchanged; the `menuRef`, click-outside handler, body scroll lock, and inner nav/CTA structure all keep working because they reference the element by ref/state, not by DOM position.
## Verification
- `npx tsc --noEmit` — clean.
- `npm run format` then `npm run format:check` — clean.
- `npm test` — 49/49 passing.
- Expected on device: tapping the hamburger now reveals the dark full-screen drawer with the 5 nav items, active item in gold, and the bottom `链接钱包` CTA (or the connected-wallet pill).
## Notes
- This is the same class of issue any future fullscreen overlay should avoid: do not nest `position: fixed` overlays inside a `position: sticky + z-index` ancestor. Either render them at the layout root or use a React Portal.
- `position: sticky` *without* `z-index` does not create a stacking context, but adding any `z-index` to it does. The header here uses both because it needs to sit above the content while scrolled.

View File

@@ -0,0 +1,30 @@
---
title: "Wallet CTA — swap lucide outline for Figma filled glyph"
type: quick-fix
date: 2026-06-03
---
# Wallet CTA — swap lucide outline for Figma filled glyph
## Bug
The 链接钱包 CTA in the mobile drawer (and in the header on desktop while logged out) was using the `Wallet` outline icon from `lucide-react`, which doesn't match the filled wallet glyph in Figma `4414:12829`.
## Root Cause
`WalletButton` imported `lucide-react`'s outline `Wallet` and rendered it with `strokeWidth={2.5}`. Figma's wallet glyph is a solid filled shape with a dot, not an outline.
## Fix
Created a local `WalletIcon` component from the exact Figma 24x24 path. The path uses `fill="currentColor"` so callers control the paint via Tailwind `text-…` utilities (currently `text-black` on the yellow CTA, matching Figma's `#08070C` fill).
### Files Modified
- `src/components/icons/WalletIcon.tsx` (new) — Figma 4414:12829 path as a React SVG component.
- `src/wallet/WalletButton.tsx` — drop the `Wallet` import from `lucide-react`, import `WalletIcon`, render it at `h-[18px] w-[18px]` to match the Figma 18x18 inner glyph size inside the 24x24 icon slot.
## Verification
- `npx tsc --noEmit` — clean.
- `npm run format` then `npm run format:check` — clean.
- `npm test` — 49/49 passing.
- Expected visual: yellow `链接钱包` CTA now shows the filled Figma wallet glyph in dark (`text-black` resolves `currentColor`), matching the design.
## Notes
- `currentColor` keeps the icon themable. If a future surface needs the wallet glyph in gold or white, the caller just changes the parent `text-…` utility.
- The lucide `Wallet` import was removed from `WalletButton.tsx`; `Heart` stays because the wallet dropdown still uses it for the favorites entry.

View File

@@ -0,0 +1,39 @@
---
title: "Desktop Favorites Header Blank Page — Quick Fix"
type: quick-fix
date: 2026-06-04
---
# Desktop Favorites Header Blank Page — Quick Fix
## Bug
Clicking the desktop header “我的收藏 / My Favorites” button could leave the page visually blank in the local browser. The provided screenshot showed DevTools Elements with an empty `<body>` and no React `#root` node after navigating to `/cn/favorites`.
## Root Cause
This was not a z-index overlay issue. The screenshot showed that React had not mounted at all because the current document had no `#root` element. In local Vite/HMR/browser state, client-side React Router navigation could land in a stale or broken document state. The favorites route itself was valid and returned the correct Vite HTML when requested directly.
There was also a possible same-page navigation edge case: clicking “我的收藏” while already on the favorites route would not necessarily trigger route scroll reset.
## Fix
The desktop header favorites button now uses React Router's `reloadDocument` so clicking it performs a full document navigation. This forces the browser/Vite dev server to return a fresh `index.html` with `<div id="root"></div>` instead of relying on a potentially stale client-side navigation state.
The route scroll reset was also made more robust by disabling browser scroll restoration and running the route scroll reset in `useLayoutEffect`, so a restored scroll position cannot leave the favorites page sitting in blank lower space before paint.
### Files Modified
- `src/layouts/PublicLayout.tsx` — added the desktop header favorites button and made it use `reloadDocument` plus top scroll reset.
- `src/components/ScrollToTop.tsx` — switched route scroll reset to `useLayoutEffect` and set `history.scrollRestoration = "manual"` while the app is mounted.
## Verification
- Ran `npx tsc --noEmit`.
- Ran `npm run format:check`.
- Used browser native to open `http://192.168.1.187:5173/cn/browse`, confirm the header “我的收藏” button is present, navigate to favorites, and inspect the resulting page.
- Verified with browser native eval that `http://192.168.1.187:5173/favorites` has `document.getElementById("root") === true`, title `My Favorites | ARK Library`, and `scrollY === 0`.
## Notes
No deploy was performed.

View File

@@ -0,0 +1,43 @@
---
title: "Favorite Other-Language Post Redirect — Quick Fix"
type: quick-fix
date: 2026-06-04
---
# Favorite Other-Language Post Redirect — Quick Fix
## Bug
When the user is on a UI language (e.g. Chinese) and clicks a favorited post that does not have a translation in that language, the post page silently redirected to `/browse` and the user could not see the post.
## Root Cause
`src/pages/PostRedirect/index.tsx` requested `GET /api/posts/{id}?lang=<ui-lang>`. The backend returns `404` when the post has no translation in the requested language. The redirect's `.catch` silently sent the user to `/browse`, hiding the post entirely.
## Fix
`PostRedirect` now retries without the `lang` parameter on failure. If the post exists in any language, the user is taken to the post anyway, and a toast tells them the post is shown in its original language because the selected language is unavailable. If the retry also fails (post truly missing), behavior is unchanged: redirect to `/browse`.
A new i18n key `postShownInOriginalLanguage` was added in all 7 locales.
### Files Modified
- `src/pages/PostRedirect/index.tsx` — added language fallback fetch, toast notice.
- `src/locales/zh-CN.ts` — added `postShownInOriginalLanguage`.
- `src/locales/en.ts` — added `postShownInOriginalLanguage`.
- `src/locales/ja.ts` — added `postShownInOriginalLanguage`.
- `src/locales/ko.ts` — added `postShownInOriginalLanguage`.
- `src/locales/vi.ts` — added `postShownInOriginalLanguage`.
- `src/locales/id.ts` — added `postShownInOriginalLanguage`.
- `src/locales/ms.ts` — added `postShownInOriginalLanguage`.
## Verification
- `npx tsc --noEmit`
- `npm run format:check`
- `npm test` (13 files, 49 tests)
- Staging curl confirmed: `GET /api/posts/{id}?lang=en` returns `404` for a Chinese-only post, while `GET /api/posts/{id}` returns `200` with the post in its source language.
## Notes
No deploy was performed.

View File

@@ -0,0 +1,41 @@
---
title: "Favorites Display Loading Blank Page — Quick Fix"
type: quick-fix
date: 2026-06-04
---
# Favorites Display Loading Blank Page — Quick Fix
## Bug
When clicking the desktop header “我的收藏” button, the favorites page could briefly show the no-favorites empty state and then appear blank. The correct behavior is to show the user's favorited posts after loading.
## Root Cause
Two issues combined:
1. The favorites page initialized with `loading=false` and `items=[]`. When the wallet was already logged in, React rendered the empty state once before the `useEffect` started the favorites request.
2. The desktop header favorites link had been changed to `reloadDocument` as a previous workaround. In the local Vite/dev-browser state this could force a full document reload and land in a broken empty document state instead of keeping the React app mounted.
## Fix
- Added an explicit `loaded` state to `src/pages/Favorites/index.tsx`.
- The favorites page now shows loading skeletons while logged-in favorites have not completed their first load, so the empty state only appears after a completed request returns zero items.
- Added a loading UI for `wallet.status === "loading"` so a persisted wallet token does not briefly show the logged-out prompt.
- Removed `reloadDocument` from the desktop header favorites link and kept client-side navigation with a top scroll reset.
### Files Modified
- `src/pages/Favorites/index.tsx` — tracks loaded state and gates empty-state rendering until favorites data has loaded.
- `src/layouts/PublicLayout.tsx` — removes hard document reload from the desktop header favorites link.
## Verification
- `npx tsc --noEmit`
- `npm run format:check`
- `npm test`
- Browser native: opened `http://192.168.1.187:5173/cn/browse`, clicked the desktop header “我的收藏”, and verified the resulting page URL is `/cn/favorites`, `document.getElementById("root")` exists, and `window.scrollY === 0`.
## Notes
No deploy was performed.

View File

@@ -0,0 +1,34 @@
---
title: "imToken in-app browser opens but does not log in — Quick Fix"
type: quick-fix
date: 2026-06-04
---
# imToken in-app browser opens but does not log in — Quick Fix
## Bug
imToken can be opened from Chrome into its in-app browser, but the site does not complete wallet login.
## Root Cause
`AutoInjectedLogin` only started when the URL contained `?autoLogin=imToken`. imToken's deeplink/in-app-browser navigation can open the page while dropping or not preserving that query string, so the auto-login effect never ran even though the page was inside imToken and an injected provider was available.
## Fix
Added an imToken browser fallback: if no explicit `autoLogin` query parameter exists, but the current user agent is imToken, `AutoInjectedLogin` treats it as an imToken direct-login session and runs the same injected login path.
### Files Modified
- `src/wallet/AutoInjectedLogin.tsx` — starts imToken direct login based on imToken in-app-browser detection when the deeplink query is missing.
- `src/wallet/injected.ts` — exports `isImTokenBrowser()` so the auto-login flow can reuse the imToken browser detection.
## Verification
- `npx tsc --noEmit`
- `npm run format:check`
- `npm test`
## Notes
This preserves the explicit `?autoLogin=` flow for TokenPocket and other wallets, while making imToken robust when the deeplink opens the page without the query parameter.

View File

@@ -0,0 +1,33 @@
---
title: "imToken in-app browser cannot connect after deeplink — Quick Fix"
type: quick-fix
date: 2026-06-04
---
# imToken in-app browser cannot connect after deeplink — Quick Fix
## Bug
After Chrome opens imToken's in-app browser, wallet login cannot complete and the wallet debug panel shows `connected: no`.
## Root Cause
The frontend looked for an injected wallet provider using the wallet-specific `isImToken` flag. Some imToken mobile versions inject a usable EIP-1193 `window.ethereum` provider but do not expose `isImToken`, so `getInjectedWallet("imToken")` returned `null`. That prevented the imToken direct-login path from using the injected provider and left the flow disconnected.
## Fix
Added a narrow imToken in-app-browser fallback: when the requested wallet is `imToken`, no provider has `isImToken`, but the user agent indicates imToken and `window.ethereum` exists, use the injected provider.
### Files Modified
- `src/wallet/injected.ts` — adds imToken user-agent fallback for injected provider detection.
## Verification
- `npx tsc --noEmit`
- `npm run format:check`
- `npm test`
## Notes
This fallback is limited to imToken browser user agents to avoid changing MetaMask or TokenPocket provider selection behavior.

View File

@@ -0,0 +1,38 @@
---
title: "Restore imToken direct injected login path — Quick Fix"
type: quick-fix
date: 2026-06-04
---
# Restore imToken direct injected login path — Quick Fix
## Bug
imToken could log in on the 2026-06-03 22:00 Malaysia-time-era build, but the current build can no longer log in after opening imToken's in-app browser.
## Root Cause
The working 2026-06-03 evening flow used `connectInjectedWallet()` and completed a local frontend session for TokenPocket/imToken direct in-app-browser login. Later changes switched injected direct login to `signInWithInjectedWallet()`, which requires backend nonce + `personal_sign` verification. imToken mobile appears incompatible or unstable with that newer signature-verification path in this flow.
## Fix
Restored the old local-session direct injected path for imToken only:
- imToken `?autoLogin=` in-app-browser flow now uses `connectInjectedWallet()` and `localWalletToken(address)`.
- imToken direct injected login from the wallet modal uses the same local-session path.
- TokenPocket still uses the newer backend signature verification path.
### Files Modified
- `src/wallet/AutoInjectedLogin.tsx` — restores imToken auto-login to the 2026-06-03 direct injected local-session behavior.
- `src/wallet/useWalletConnectLogin.ts` — restores imToken injected deeplink login to the local-session behavior.
## Verification
- `npx tsc --noEmit`
- `npm run format:check`
- `npm test`
## Notes
This is intentionally scoped to imToken to match the known-working Malaysia 10pm behavior without undoing the TokenPocket signature-verification work.

View File

@@ -0,0 +1,38 @@
---
title: "Language route prefixes — short ISO codes with legacy redirects"
type: quick-fix
date: 2026-06-04
---
# Language route prefixes — short ISO codes with legacy redirects
## Bug
Localized URLs used full English names (`/chinese`, `/japanese`, `/korean`, `/vietnamese`, `/indonesian`, `/malay`). Per the broadcast in WeChat (screenshot), they need to be short ISO-style codes (`/cn`, `/ja`, `/ko`, `/vi`, `/id`, `/ms`).
## Root Cause
The mapping is sourced from one place — `src/languageRoutes.ts` `localizedHomeRoutes` — and that array hard-coded the long names.
## Fix
- Rename every localized prefix in `localizedHomeRoutes` to its short code.
- Add `legacyLanguageRedirects` (the old → new map) and render client redirects in `App.tsx` so links previously shared (`/chinese`, `/malay/browse?post=42`, etc.) keep landing on the right destination. The redirect preserves sub-path, query string, and hash.
- Refresh doc-comment examples (`/malay/...`) in unrelated files so future readers don't get confused.
- Update `languageRoutes.test.ts` to assert the new mapping.
### Files Modified
- `src/languageRoutes.ts` — paths swapped to short codes; added `legacyLanguageRedirects`; refreshed doc-comment examples.
- `src/languageRoutes.test.ts` — expectations updated to short codes; test description renamed accordingly.
- `src/App.tsx` — added `LegacyLangRedirect` component (uses `useParams`/`useLocation`) and rendered `<Route>` pairs (`/old` and `/old/*`) for each entry in `legacyLanguageRedirects`.
- `src/i18n.tsx`, `src/components/FigmaBanner.tsx`, `src/layouts/PublicLayout.tsx`, `src/useLocalizedPath.ts` — doc-comment example paths updated for consistency.
## Verification
- `npx tsc --noEmit` — clean.
- `npm run format` then `npm run format:check` — clean.
- `npm test` — 49/49 passing (includes the updated `languageRoutes.test.ts`).
- Expected runtime behavior:
- `/cn`, `/ja`, `/ko`, `/vi`, `/id`, `/ms` (and their nested routes) resolve to localized pages.
- `/chinese`, `/japanese`, `/korean`, `/vietnamese`, `/indonesian`, `/malay` (and their nested routes) redirect via React Router `<Navigate replace />` to the new short-code equivalents, preserving `?query` and `#hash`.
## Notes
- Server-side considerations: this works for SPA navigation because BrowserRouter handles all paths client-side. Any reverse-proxy/CDN rule that hardcoded the long names should be reviewed (e.g. nginx rewrites, prerender configs). The `nginx.conf` and Gitea deploy workflow only reference `index.html`, so no server-side path rules to update here.
- If the SEO sitemap / canonical URLs are generated elsewhere, those should also pick up the new prefixes.
- `legacyLanguageRedirects` is intentionally kept distinct from `localizedHomeRoutes` so we can sunset it later by deleting the export and the corresponding routes block in `App.tsx`.

View File

@@ -0,0 +1,37 @@
---
title: "Remove wallet address verification popup — Quick Fix"
type: quick-fix
date: 2026-06-04
---
# Remove wallet address verification popup — Quick Fix
## Bug
The in-app-browser wallet address verification popup added friction and interfered with imToken login. The requested behavior is to remove that popup and keep the deeplink login flow direct.
## Root Cause
`AutoInjectedLogin` rendered a blocking confirmation dialog for `?autoLogin=` deeplink sessions before calling the injected wallet signature flow. That UI was unnecessary for the current wallet-login flow and could block or confuse imToken users.
## Fix
Removed the verification dialog and restored direct deeplink behavior: after the wallet in-app browser injects `window.ethereum`, the app calls `signInWithInjectedWallet()` and completes backend-verified login. Existing logged-in sessions still skip auto-login after stripping the deeplink parameter.
### Files Modified
- `src/wallet/AutoInjectedLogin.tsx` — removes the verification popup UI and auto-runs signature login for logged-out deeplink sessions.
- `src/wallet/injected.ts` — removes now-unused connected-address helper.
- `src/locales/zh-CN.ts` — removes unused verification popup copy.
- `src/locales/en.ts` — removes unused verification popup copy.
## Verification
- `rg -n "walletVerifyAddress|walletDetectedAddress|getConnectedInjectedAddress|wallet-verify" src || true`
- `npx tsc --noEmit`
- `npm run format:check`
- `npm test`
## Notes
The imToken injected-provider fallback remains in place; only the confirmation popup and its supporting copy/helper were removed.

View File

@@ -0,0 +1,34 @@
---
title: "TokenPocket direct login requires signature verification — Quick Fix"
type: quick-fix
date: 2026-06-04
---
# TokenPocket direct login requires signature verification — Quick Fix
## Bug
Mobile TokenPocket deeplink opened the site inside the wallet browser and completed login immediately after reading the injected wallet address. It did not trigger a password/signature verification step, so users did not get an explicit address verification prompt.
## Root Cause
`AutoInjectedLogin` used `connectInjectedWallet()` and then wrote a local frontend wallet token. The injected deeplink path in `useWalletConnectLogin` used the same address-only flow. Both paths skipped the existing backend nonce + `personal_sign` verification flow.
## Fix
Changed injected wallet direct login to use `signInWithInjectedWallet()`, which requests a backend nonce, asks the wallet to sign it, verifies the signature with the backend, and stores the verified backend JWT. If injected verification fails, the direct injected path now stops with an error instead of falling back to an unverified WalletConnect/local-token login.
### Files Modified
- `src/wallet/AutoInjectedLogin.tsx` — TP/imToken `?autoLogin=` deeplink now requires wallet signature verification before completing login.
- `src/wallet/useWalletConnectLogin.ts` — injected deeplink path now uses verified sign-in and does not bypass verification after a signature failure.
## Verification
- `npx tsc --noEmit`
- `npm run format:check`
- `npm test`
## Notes
WalletConnect QR fallback still uses the existing local-session behavior; this fix targets the TokenPocket/injected direct-login flow described in the bug report.

View File

@@ -0,0 +1,30 @@
---
title: "Wallet No Account Message — Quick Fix"
type: quick-fix
date: 2026-06-04
---
# Wallet No Account Message — Quick Fix
## Bug
When a wallet provider was detected but returned no account, the wallet login UI displayed the raw internal error key `walletNoAccount`.
## Root Cause
`connectInjectedWallet` throws `Error("walletNoAccount")`, but the modal and toast paths rendered `error.message` directly. The locale dictionaries also did not define a friendly `walletNoAccount` message.
## Fix
Translate wallet error keys before rendering them, and add user-facing English and Simplified Chinese text for `walletNoAccount`.
### Files Modified
- `src/wallet/WalletLoginModal.tsx` — translate wallet error messages before showing modal errors.
- `src/wallet/WalletProvider.tsx` — translate wallet error messages before showing toast errors.
- `src/locales/en.ts` — added English `walletNoAccount` copy.
- `src/locales/zh-CN.ts` — added Simplified Chinese `walletNoAccount` copy.
## Verification
- `npx tsc --noEmit`
- `npm run format:check`
- `npm test`
## Notes
The underlying login behavior is unchanged. This only replaces the raw internal key with a user-friendly explanation to unlock/select a wallet account and retry.

View File

@@ -0,0 +1,39 @@
---
title: "Wallet verification popup blocks already logged-in TP browser — Quick Fix"
type: quick-fix
date: 2026-06-04
---
# Wallet verification popup blocks already logged-in TP browser — Quick Fix
## Bug
After Chrome opens TokenPocket's in-app browser through the wallet deeplink, users who are already logged in inside the TP browser still see the new address verification popup. The popup sits on top of an already-authenticated page and blocks normal use.
## Root Cause
The `?autoLogin=` handler in `AutoInjectedLogin` showed the verification prompt as soon as the deeplink parameter existed. It did not wait for `WalletProvider` to finish loading the existing wallet session, and it did not skip the prompt when `status === "loggedIn"`.
## Fix
The auto-login handler now waits while wallet status is `loading`. Once the status is known:
- `loggedIn` strips the deeplink parameter and does not show the verification popup.
- `loggedOut` strips the deeplink parameter and shows the manual address verification prompt.
### Files Modified
- `src/wallet/AutoInjectedLogin.tsx` — only shows the verification gate for logged-out deeplink sessions; already logged-in TP sessions are not blocked.
- `src/wallet/injected.ts` — supports reading a connected injected address without requesting wallet permission.
- `src/locales/zh-CN.ts` — verification popup copy.
- `src/locales/en.ts` — verification popup copy.
## Verification
- `npx tsc --noEmit`
- `npm run format:check`
- `npm test`
## Notes
This preserves the manual verification gate for logged-out Chrome → TokenPocket handoff, while avoiding a blocking popup for users who already have a valid wallet session in TokenPocket's in-app browser.

View File

@@ -0,0 +1,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

@@ -0,0 +1,556 @@
---
title: "钱包登录与收藏功能 UI 设计需求(极简版)"
type: design-brief
date: 2026-06-01
scope: 登录弹窗、钱包入口、收藏按钮、我的收藏页面
---
# 钱包登录与收藏功能 UI 设计需求(极简版)
## 1. 设计目标
这份文档给 UI 设计师使用,目的是重新设计 Arkie Library 的「钱包登录」和「我的收藏」体验。
核心原则:**不要给用户太多选择。**
用户只需要理解:
1. 连接钱包。
2. 签名验证地址。
3. 收藏资源。
4. 在「我的收藏」里管理收藏。
钱包登录只用于验证地址:
- 不会发起交易。
- 不会产生 gas。
- 不会读取资产。
- 不需要切换链。
## 2. 最重要的设计决策
### 桌面端只显示「浏览器钱包」
桌面端登录弹窗只需要一个主要操作:
```text
使用浏览器钱包登录
```
适用:
- MetaMask 浏览器插件
- 其他浏览器注入钱包
原因:
- 电脑端用户主要使用浏览器插件钱包。
- 不要在桌面端同时展示 TokenPocket、MetaMask、imToken、扫码备用等多个入口。
- 过多选择会让用户觉得重复和困惑。
### 手机端显示「打开钱包 App」
手机端可以跳转钱包 App因此手机端可以显示
```text
打开 TokenPocket
打开 MetaMask
打开 imToken
```
如果用户已经在钱包内置浏览器中打开网站,则显示:
```text
使用当前钱包登录
```
### QR / Reown 不作为主设计
TokenPocket QR、Reown / WalletConnect QR 可以作为技术备用方案存在,但**不要作为默认主 UI 平铺展示**。
如果必须保留,可以放在:
```text
其他登录方式
```
或折叠项中。
默认设计不要同时展示:
- 浏览器钱包
- TokenPocket QR
- 打开 TokenPocket
- 打开 MetaMask
- 打开 imToken
- MetaMask / imToken QR 备用
这样会显得重复。
## 3. 需要设计的页面/组件
1. Header 钱包入口
2. Mobile menu 钱包入口
3. 钱包登录弹窗:桌面版
4. 钱包登录弹窗:手机版
5. 收藏按钮
6. 资源卡片上的收藏按钮位置
7. 我的收藏页面:未登录状态
8. 我的收藏页面:已登录列表状态
9. 我的收藏页面:空状态
10. 我的收藏页面:加载状态
11. 我的收藏页面:错误状态
12. 我的收藏页面:资源不可用状态
## 4. Header 钱包入口
### 未登录
显示:
```text
Connect Wallet
连接钱包
```
桌面端:
- 放在 Header 右侧。
- 是一个清楚的主按钮。
- 不需要在 Header 展示钱包品牌。
移动端:
- 放在 menu 中。
- 点击后关闭 menu再打开登录弹窗。
### 已登录
显示短地址:
```text
0x12...ab34
```
点击后显示 dropdown
- 完整钱包地址
- Disconnect / 断开连接
## 5. 钱包登录弹窗:桌面版
### 5.1 桌面版目标
桌面版只服务一个主要场景:
> 用户用浏览器插件钱包登录。
### 5.2 桌面版结构
建议结构:
1. 标题:连接钱包
2. 简短说明:签名仅用于验证地址,不会产生交易或 gas
3. 一个主按钮:使用浏览器钱包登录
4. 辅助说明:请确认浏览器已安装钱包插件
5. 关闭按钮
### 5.3 桌面版不要展示
默认不要展示:
- TokenPocket QR 登录
- Open TokenPocket
- Open MetaMask
- Open imToken
- MetaMask / imToken QR fallback
- WalletConnect / Reown 说明
这些对桌面用户来说会造成选择过多。
### 5.4 桌面版文案建议
标题:
```text
连接钱包
```
说明:
```text
签名验证钱包地址,不会发起交易,也不需要 Gas。
```
按钮:
```text
使用浏览器钱包登录
```
辅助说明:
```text
请使用已安装钱包插件的浏览器,例如 MetaMask。
```
## 6. 钱包登录弹窗:手机版
### 6.1 手机版目标
手机版主要服务两个场景:
1. 用户在普通手机浏览器打开网站,需要跳转钱包 App。
2. 用户已经在钱包内置浏览器打开网站,可以直接使用当前钱包。
### 6.2 手机版结构
建议结构:
1. 标题:连接钱包
2. 简短说明:签名仅用于验证地址,不会产生交易或 gas
3. 如果检测到当前浏览器已有钱包:显示「使用当前钱包登录」
4. 否则显示「选择钱包 App 打开」
5. 钱包 App 按钮列表
6. 关闭按钮
### 6.3 手机版钱包按钮
显示三个按钮:
- TokenPocket
- MetaMask
- imToken
设计建议:
- 使用列表或大按钮。
- 每个按钮只展示钱包名称和图标。
- 不需要额外解释每个钱包的技术路径。
### 6.4 手机版文案建议
标题:
```text
连接钱包
```
说明:
```text
请在钱包 App 中打开本站并签名登录。
```
当前钱包按钮:
```text
使用当前钱包登录
```
钱包 App 分组标题:
```text
打开钱包 App
```
按钮:
```text
TokenPocket
MetaMask
imToken
```
## 7. QR / 备用方式处理
如果产品仍希望保留 QR 备用能力,设计上应弱化处理。
建议:
- 不默认展开。
- 放在底部小字链接:`其他登录方式`
- 点击后才显示 QR / WalletConnect 相关内容。
但第一版 UI redesign 可以不设计 QR 主流程。
如果必须设计,注意:
- TokenPocket QR 是中国用户较稳定路径。
- MetaMask / imToken QR 依赖 Reown / WalletConnect在部分中国网络可能不稳定。
- 这些说明不应占据主弹窗视觉中心。
## 8. 收藏按钮设计
### 状态
收藏按钮需要这些状态:
1. 未收藏
2. 已收藏
3. 加载中
4. 禁用/请求中
### 视觉建议
未收藏:
- 空心心形
- 低对比背景
已收藏:
- 实心心形
- 品牌金色
加载中:
- spinner 或轻量 loading
### 行为
未登录用户点击收藏:
- 打开钱包登录弹窗。
- 登录成功后自动完成收藏。
已登录用户点击收藏:
- 立即反馈状态变化。
- 失败时恢复原状态并提示。
### 摆放要求
收藏按钮会出现在:
- 推荐资源卡片
- 最新资源卡片
- 热门列表
- 资源内容流
- 我的收藏页面卡片
设计上需要避免:
- 挡住主要内容。
- 和下载/预览按钮混淆。
- 点击收藏时误触进入详情页。
## 9. 我的收藏页面
页面路径:
```text
/favorites
```
### 9.1 未登录状态
用户未连接钱包时,页面显示引导。
需要包含:
- 收藏图标或插画
- 标题:我的收藏 / My Favorites
- 说明:连接钱包后可以查看和管理收藏资源
- 主按钮Connect Wallet / 连接钱包
### 9.2 已登录状态
页面需要包含:
1. 页面标题
2. 搜索框
3. 排序
4. 分类筛选
5. 收藏资源列表
6. 分页
7. 清除筛选按钮
### 9.3 搜索/筛选区
支持:
- 搜索收藏内容
- 按分类筛选
- 排序
排序选项:
- 最近收藏
- 最近发布
- 热门
桌面端:
- 搜索、排序、分类可以一行展示。
移动端:
- 纵向堆叠。
- 不要太密。
### 9.4 收藏资源卡片
每个收藏资源卡片建议展示:
- 封面图
- 标题
- 简短描述
- 分类
- 类型
- 更新时间
- 收藏数
- 收藏按钮
点击行为:
- 可用资源:点击卡片进入详情。
- 不可用资源:不能进入详情,但可以移除收藏。
### 9.5 不可用资源状态
用户收藏过的资源可能之后被下架或隐藏。
这种资源仍然要显示在收藏列表里。
设计要求:
- 显示 unavailable / 不可用标签。
- 降低视觉权重。
- 不显示可点击详情行为。
- 保留移除收藏按钮。
### 9.6 空状态
空状态包括:
1. 用户还没有收藏。
2. 搜索/筛选没有结果。
需要显示:
- 空状态图标
- 简短说明
- 如果是筛选无结果,需要提供清除筛选入口
### 9.7 加载状态
需要设计:
- skeleton card
- 或列表 loading placeholder
要求:
- 不要让布局大幅跳动。
### 9.8 错误状态
需要设计:
- 加载失败提示
- 重试或刷新建议
## 10. 响应式要求
### Desktop
- Header 显示完整导航。
- 钱包入口在右侧。
- 登录弹窗只显示浏览器钱包登录。
- 我的收藏页面内容宽度适中。
- 搜索/筛选尽量横向排列。
### Mobile
- Header 使用 menu。
- 钱包入口在 menu 中。
- 登录弹窗显示当前钱包登录或钱包 App 跳转。
- 我的收藏页面单列展示。
- 搜索/筛选纵向排列。
- 收藏按钮容易点击。
## 11. 视觉方向
当前网站视觉基调:
- 深色背景
- 金色品牌色
- 圆角卡片
- 半透明/轻玻璃质感
- 移动端偏 App 化体验
UI redesign 可以优化:
- 登录弹窗更简单
- 桌面端只给一个主操作
- 手机端强调打开钱包 App
- 收藏按钮更清楚
- 我的收藏页面筛选区更轻量
- 空状态更友好
## 12. 多语言注意事项
UI 需要支持:
- 繁体中文
- 简体中文
- 英文
- 韩文
- 日文
- 越南文
- 印尼文
- 马来文
设计时需要预留文字长度差异。
尤其注意:
- 英文按钮可能较长。
- 越南文/印尼文/马来文文本可能比中文长。
- 移动端按钮不要因为文本过长而溢出。
## 13. 设计验收清单
### 钱包登录
- [ ] 桌面 Header 有 Connect Wallet。
- [ ] 手机 menu 有 Connect Wallet。
- [ ] 桌面登录弹窗只有一个主操作:使用浏览器钱包登录。
- [ ] 手机登录弹窗可以打开 TokenPocket / MetaMask / imToken。
- [ ] 签名无交易、无 gas 的说明清楚。
- [ ] 已登录状态能显示短地址。
- [ ] 用户可以断开连接。
### 收藏功能
- [ ] 收藏按钮状态清楚。
- [ ] 未收藏和已收藏容易区分。
- [ ] loading 状态明确。
- [ ] 收藏按钮不会和卡片点击冲突。
- [ ] 未登录点击收藏会引导连接钱包。
### 我的收藏页面
- [ ] 未登录状态有明确 CTA。
- [ ] 已登录页面有搜索、排序、分类筛选。
- [ ] 收藏资源卡片信息足够。
- [ ] 不可用资源状态清楚且可移除。
- [ ] 空状态、加载状态、错误状态完整。
- [ ] Desktop 和 mobile 都有设计稿。
## 14. 设计交付建议
建议 UI 交付这些画面:
1. Desktop Header未登录
2. Desktop Header已登录 dropdown
3. Mobile menu未登录
4. Desktop wallet modal只显示浏览器钱包登录
5. Mobile wallet modal打开钱包 App
6. Favorites page未登录状态
7. Favorites page已登录有列表
8. Favorites page空状态
9. Favorites page不可用资源卡片
10. Mobile Favorites page
11. Favorite button 状态组件

View File

@@ -0,0 +1,210 @@
---
title: "Telegram-style Resource Stream — Implementation Plan"
type: plan
date: 2026-05-25
workbranch: feat/telegram-stream
specs:
- .unipi/docs/specs/2026-05-25-telegram-style-resource-stream-design.md
---
# Telegram-style Resource Stream — Implementation Plan
## Overview
实现 `/browse``/category/:slug` 的 Telegram-style 消息流重构。前端先用 mock data 完成全部视觉与交互,等后端 `/api/posts` 系列接口 ready 后切换。同时收尾删除收藏功能与 `ResourceDetail` 详情页。
分支:`feat/telegram-stream`(在当前目录新建,不走 worktree。完成后由 Terry 显式确认才 merge。
## Sequencing
```
Task 1 (基础类型 + mock + utils)
Task 2 (hooks) ─────────────────┐
Task 3 (overlays) ─────┐ │
↓ │
Task 4 (bubbles)
Task 5 (stream 容器)
Task 6 (页面改写)
Task 7 (清理收藏 / 详情页) ── 可与 1-5 并行,但合并到 Task 6 之前完成
Task 8 (验证 + 文档 + API 契约) ── 最后
```
依赖关键路径1 → 2/3 → 4 → 5 → 6 → 8。Task 7 独立,建议早做以减少 imports 残留。
## Tasks
- unstarted: Task 0 — 创建分支
- Description: 在当前目录创建并切到 `feat/telegram-stream` 分支
- Dependencies: 无
- Acceptance Criteria: `git branch --show-current` 输出 `feat/telegram-stream``git status` 干净
- Steps:
1. `git status --short --branch` 确认无未提交改动
2. `git checkout -b feat/telegram-stream`
3. 确认 `.unipi/docs/specs/2026-05-25-telegram-style-resource-stream-design.md``.unipi/docs/plans/2026-05-25-telegram-style-resource-stream-plan.md` 已在该分支
- unstarted: Task 1 — 类型定义 + Mock data + 纯函数 utils
- Description: 建立 Post / Attachment 类型,写覆盖 7 种 bubble 的 mock 样本,写 3 个无依赖纯函数 util
- Dependencies: Task 0
- Acceptance Criteria:
- `src/types/post.ts` 导出 `Post``Attachment``PostListResponse``PostScope`
- `src/mocks/mockPosts.ts` 至少 12 条 Post 覆盖2 图片当文档 + 2 PDF/AI + 2 纯文本+链接 + 1 视频 + 2 单图 + 1 图+文 + 1 三图相册 + 1 七图相册publishedAt 跨 ≥3 个不同日期
- `formatBytes(3549239)``"3.4 MB"`unit tests pass
- `autolink("点 https://x.com 看")` 返回 React 节点数组,链接处为 `<a target="_blank" rel="noopener noreferrer">`unit tests pass
- `fileIcon({ mime, filename })` 返回 `{ Icon, color }`PDF 红、AI 橙、PPT 红、图片走 thumbnail 不返回 Icon
- `npx tsc --noEmit` 通过
- Steps:
1.`src/types/post.ts`
2.`src/components/messageStream/utils/formatBytes.ts` + `.test.ts`
3.`src/components/messageStream/utils/autolink.tsx` + `.test.tsx`
4.`src/components/messageStream/utils/fileIcon.ts`
5.`src/mocks/mockPosts.ts`(图片用 picsum.photos 占位,视频用公开 sample mp4 + 一张占位 poster
6.`npm test``npx tsc --noEmit`
- unstarted: Task 2 — Stream hooksusePostStream + useGroupedByDay
- Description: 数据层抽象mock/real 双模式 + 按日期分组
- Dependencies: Task 1
- Acceptance Criteria:
- `usePostStream({ scope, type, language })``VITE_USE_MOCK_POSTS !== "false"` 时从 `MOCK_POSTS` 过滤 + 倒序 + cursor 切片(每页 20+ 200ms 假延迟
- 真接口分支调用 `getJSON</api/posts?...>`(占位即可,后端未 ready 时不会走到)
- 返回 `{ items, isLoading, error, hasMore, loadMore, reset }`
- `useGroupedByDay(posts, lang)` 返回 `Array<{ dayLabel: string; items: Post[] }>`按本地时区日期分组dayLabel 通过 `Intl.DateTimeFormat` 按 lang 切换zh-TW/zh-CN/en
- 单元测试useGroupedByDay 在跨天数据上分出正确组数
- Steps:
1.`src/components/messageStream/hooks/usePostStream.ts`
2.`src/components/messageStream/hooks/useGroupedByDay.ts` + `.test.ts`
3.`.env.example``VITE_USE_MOCK_POSTS=true` 注释
4.`npm test``npx tsc --noEmit`
- unstarted: Task 3 — Overlay 基础设施ImageLightbox + VideoPlayer
- Description: 全屏画廊与视频播放器portal 渲染,单一入口 context
- Dependencies: Task 1
- Acceptance Criteria:
- `<ImageLightboxProvider>` 包在 App 根,暴露 `useLightbox()``openLightbox(images, startIndex)`
- `ImageLightbox` 支持:左右切换(按钮 + 键盘 ← → + 触屏左右滑、ESC/点遮罩关闭、右上角下载按钮
- `VideoPlayer` 支持:全屏遮罩 + `<video controls autoPlay>`、接 `currentTime` 参数避免重新加载、ESC/点遮罩关闭
- 两个 overlay 在手机端不溢出、不被底部 nav 遮挡
- `npx tsc --noEmit` 通过
- Steps:
1.`src/components/messageStream/overlays/ImageLightbox.tsx` + Provider/Context
2.`src/components/messageStream/overlays/VideoPlayer.tsx`
3.`App.tsx` / 根布局挂 Provider
4. 手动验证:在 dev console 临时调用 `openLightbox` 看是否正确呈现
- unstarted: Task 4 — Bubble 子组件6 个 + 分发器)
- Description: 按截图实现 6 种气泡 + `MessageBubble` 分发
- Dependencies: Task 1, Task 3
- Acceptance Criteria:
- `FileDocBubble` 处理 `kind: "document"`
-`mime.startsWith("image/")` → 左侧用 `thumbnailUrl` 缩略 + ↓ 覆盖;右侧 filename.ext + size截图 1
- 否则 → 左侧蓝圆 ↓ 图标(按 mime 取色)+ 右侧 filename + size截图 2
- `TextBubble` 渲染 `text`,调 `autolink`(截图 3
- `VideoBubble` 初始显示 posterUrl + ▶️ + 时长,第一次点 inline `<video controls autoPlay>`,第二次点(已播放)调 `openVideoPlayer`(截图 4
- `ImageBubble` 单张图,点击调 `openLightbox([att], 0)`(截图 5
- `ImageWithTextBubble` 单图 + 下方文本autolink截图 6
- `AlbumBubble` 2-4 格 grid间距 2pxattachments.length > 4 时第 4 格 `bg-black/45 backdrop-blur-sm` 覆盖 `+N`;点任一格调 `openLightbox(images, index)`(截图 7
- `MessageBubble` 实现 spec §4 的 `pickBubble` 分发,右下角绝对定位时间戳 `text-[11px] text-neutral-500`
- 所有 bubble 容器:`rounded-2xl bg-ark-panel p-3`(文本气泡 `px-4 py-2.5`),左对齐,无头像
- `npx tsc --noEmit` 通过
- Steps:
1.`MessageBubble.tsx`(含 pickBubble
2.`bubbles/TextBubble.tsx`
3.`bubbles/FileDocBubble.tsx`
4.`bubbles/ImageBubble.tsx`
5.`bubbles/ImageWithTextBubble.tsx`
6.`bubbles/AlbumBubble.tsx`
7.`bubbles/VideoBubble.tsx`
8. 在 dev 中临时挂一个 demo route 跑 MOCK_POSTS 全量渲染,目视检查 7 张截图对照
- unstarted: Task 5 — Stream 容器 + FilterChips + DaySeparator
- Description: 顶层组件接管 fetch、分组、无限滚动、sticky 筛选
- Dependencies: Task 2, Task 4
- Acceptance Criteria:
- `FilterChips`sticky top横向滚动 `overflow-x-auto whitespace-nowrap`,类型 chipsall/image/video/ppt/pdf/text/link/archive沿用 `typeFilterLabel`+ 语言 chipsall/zh-TW/zh-CN/en改变时 reset cursor 并同步 URL `?type=&language=`
- `DaySeparator`:胶囊样式,居中
- `MessageStream`
-`scope: { kind: "all" } | { kind: "category", slug: string }`
- 调用 `usePostStream` + `useGroupedByDay`
-`IntersectionObserver` 监听底部 sentinel触发 `loadMore`loadingRef 守护避免重复)
- 容器:手机 `max-w-full px-3 mx-auto`md+ `max-w-[640px] mx-auto`
- 空状态用 `t("noResults")`,错误用红色 inline 横幅 + 重试按钮
- `npx tsc --noEmit` 通过
- Steps:
1.`FilterChips.tsx`
2.`DaySeparator.tsx`
3.`MessageStream.tsx`
4. 在 dev demo route 挂 `<MessageStream scope={{ kind: "all" }} />` 验证滚动 + 筛选 + 分组
- unstarted: Task 6 — 重写 CategoryPage 与 Browse
- Description: 两个页面瘦身为单一组件调用
- Dependencies: Task 5, Task 7
- Acceptance Criteria:
- `src/pages/CategoryPage.tsx`:仅渲染分类标题 + `<MessageStream scope={{ kind: "category", slug }} />`,行数 < 30
- `src/pages/Browse.tsx`:仅渲染页面标题 + `<MessageStream scope={{ kind: "all" }} />`,行数 < 30不再读 `sort` / `tag` / `page` 参数
- 排序 tabs 全部去掉
- 移除对 `ResourceCard` / `ResourceListFooter` 的 import
- `App.tsx` 路由保持 `/browse``/category/:slug` 不变
- `npx tsc --noEmit` 通过
- Steps:
1. 改写 `CategoryPage.tsx`
2. 改写 `Browse.tsx`
3. 跑 dev server 在 `/browse``/category/<slug>` 验证
- unstarted: Task 7 — 移除收藏功能 + ResourceDetail
- Description: 整套清理
- Dependencies: Task 0可与 Task 1-5 并行)
- Acceptance Criteria:
- 删除文件:`src/pages/FavoritesPage.tsx``src/pages/ResourceDetail.tsx``src/components/ResourceCard.tsx``src/components/ResourceListFooter.tsx`
- `App.tsx` 移除 `/favorites` 路由;`/r/:id` 改为新组件 `PostRedirect`mock 模式下从 `MOCK_POSTS` 找 slug找不到 → navigate `/browse`
- `src/api.ts` 移除 `postFavoriteDelta`
- 全代码无 `postFavoriteDelta` / `FavoritesPage` / `ResourceDetail` / `ResourceCard` / `ResourceListFooter` 引用
- Home 中的 `/favorites` 入口(如有)移除
- `src/i18n.tsx` 移除 `favorites` / `addFavorite` / `removeFavorite` 等收藏 key三语言同步
- `npx tsc --noEmit` 通过(无 unused / 未引用错误)
- Steps:
1. `grep -rn "postFavoriteDelta\|FavoritesPage\|ResourceDetail\|ResourceCard\|ResourceListFooter\|/favorites\|/r/:id" src/` 列出所有引用点
2.`src/pages/PostRedirect.tsx`,挂到 `/r/:id` 路由
3. 删除 4 个文件 + 修改 `App.tsx` + `api.ts` + `i18n.tsx`
4. 再 grep 一次确认清零
5. `npx tsc --noEmit`
- unstarted: Task 8 — 验证 + 文档 + API 契约抽出
- Description: 全量验证 + 给后端的接口文档
- Dependencies: Task 6, Task 7
- Acceptance Criteria:
- `npx tsc --noEmit` 通过
- `npm run format:check` 通过(若不通过先 `npm run format`
- `npm test` 全绿
- `npm run build` 成功
- 手动视觉验证Chrome DevTools iPhone 14 Pro 视口逐一对照 7 张参考截图
- 新增 `.unipi/docs/specs/2026-05-25-posts-api-contract.md`:从主 spec §1-§2 抽出后端需要的所有内容Post/Attachment schema、6 个 endpoint、删除清单、迁移要求
- 更新 `README.md` 增加 `VITE_USE_MOCK_POSTS` 段落
- 不 commit、不 push等 Terry 显式确认)
- Steps:
1. 跑全套检查命令
2. dev server 手机视口检查
3. 写 API 契约文档
4. 改 README
5. 报告完成,等 Terry 审阅与 commit 指令
## Risks
- **mock 数据视觉与真实数据偏差**mock 用 picsum 占位图可能掩盖真实图片不同宽高比的边界情况。缓解mockPosts 中包含横图 / 竖图 / 接近正方形三种比例样本。
- **video poster 在 mock 模式不易获取**:用一张本地 SVG 占位即可,避免依赖外部链接。
- **i18n 删除收藏 key 后未使用的引用**tsconfig 的 `noUnusedLocals` 不覆盖 i18n key 的字符串引用,需手动 grep。
- **`PostRedirect` 在真接口模式下的实现**:当前先写 mock 分支,真接口分支 TODO 注释标明等 `/api/posts/:id` ready 后补。
- **infinite scroll + URL 同步**:用户改 filter chip 时既要 reset cursor 又要更新 URL注意避免 `setSearchParams` 触发额外 effect 循环。
- **后端最终 schema 与本 spec 偏差**:如有偏差,必须先回 spec 改契约,再调前端类型,避免散点修改。
## Out of Scope本 plan 不涵盖,遵循 spec
- Home 页布局调整
- Admin 后台 Post 编辑器
- 真实后端 API 实现 + 数据迁移
- 长按菜单 / 评论 / Reaction
- 桌面端多列布局
- SEO 优化

View File

@@ -0,0 +1,38 @@
---
title: "MessageBubble footer — timestamp + favorite + (file) download"
type: quick-work
date: 2026-06-03
---
# MessageBubble footer — timestamp + favorite + (file) download
## Task
Implement the 全部资料 card layout from Figma `4206-6509`:
- Each card shows a bottom row with the publish timestamp on the left and action buttons on the right.
- Image / album / video / text / link bubbles → 1 button (FavoriteButton).
- File-document bubbles (mp3, pptx, pdf, zip, …) → 2 buttons (FavoriteButton + Download).
## Changes
- `src/components/messageStream/BubbleAttachmentDownloadButton.tsx` (new) — small circular download button visually matched to `FavoriteButton` (sm). Handles its own download/loading state and surfaces the `SaveToAlbumGuide` toast for media kinds.
- `src/components/messageStream/MessageBubble.tsx`
- Removed the absolute-positioned FavoriteButton for the default variant.
- Removed the right-aligned `<time>` block for the default variant.
- Added a new flex footer: timestamp on the left, FavoriteButton (+ optional `BubbleAttachmentDownloadButton`) on the right.
- File-doc detection is based on `pickBubble(post) === FileDocBubble` and the primary attachment `post.attachments[0]`.
- `variant === "latest"` paths are left untouched (latest masonry cards keep the bottom-right absolute FavoriteButton and the existing right-aligned timestamp because `LatestFileCard` already renders its own footer).
- `src/components/messageStream/bubbles/FileDocBubble.tsx`
- Removed the inline per-row download button from `AttachmentRow` in the default variant (download now lives in the bubble footer).
- Trimmed the now-unused state and handlers from `AttachmentRow`; imports remain because `LatestFileCard` still uses them.
## Verification
- `npx tsc --noEmit` — clean.
- `npm run format` then `npm run format:check` — clean.
- `npm test` — 49/49 passing.
- Visual check pending on device — expected to match Figma `4206-6509`:
- timestamp + bookmark on image/album/video/text/link cards
- timestamp + bookmark + download on file cards
## Notes
- For posts with multiple file attachments, the footer download button currently targets `attachments[0]` only (matches the Figma single-attachment cards). If a multi-attachment file post needs per-attachment download, revisit `AttachmentRow` and re-add a small inline download or expose a list in an overflow menu.
- The new download button mirrors `FavoriteButton`'s sm style (h-9 w-9, same border / bg / hover treatment) so the two sit on the same baseline and share visual weight.
- The home page's "latest" masonry variant is unaffected — that path renders `LatestFileCard` which already has its own footer.

View File

@@ -0,0 +1,36 @@
---
title: "官方物料 cards — bottom-left filename overlay"
type: quick-work
date: 2026-06-03
---
# 官方物料 cards — bottom-left filename overlay
## Task
In every content card classified as 官方物料 (`categorySlug === "official-assets"`), display the source filename at the bottom-left of the card — image filename for image bubbles, video filename for video bubbles.
## Data Source — Confirmed
- Endpoint: `/api/posts` (list, called by `src/components/messageStream/hooks/usePostStream.ts:89`) and `/api/posts/:id` (single, used by `MessageStream` deep-links).
- Field: `Post.attachments[*].filename` (`src/types/post.ts`).
- Category gate: `Post.categorySlug === "official-assets"` (also referenced in `src/lib/categorySvgSlug.ts:13`, `src/pages/Categories/index.tsx:23`, `src/pages/Home/index.tsx:33`).
- No additional API call is needed — `filename` already ships with the post payload.
## Changes
- `src/components/messageStream/AttachmentFilenameLabel.tsx` (new) — small dark pill overlay using `filenameWithExtension(att.filename, att.mime)`, positioned `absolute bottom-2 left-2`, with `pointer-events-none`, `max-w-[calc(100%-1rem)]`, `truncate`, and a `title` attribute so the full filename is available on hover. Style mirrors the `AttachmentDownloadPill` (`bg-black/80` + `ring-white/20` + `backdrop-blur-md`) for visual consistency.
- `src/components/messageStream/bubbles/SingleImageFrame.tsx` — accepts new `showFilename?: boolean` prop; when true, renders `AttachmentFilenameLabel` alongside the existing top-left download pill.
- `src/components/messageStream/bubbles/ImageBubble.tsx` — passes `showFilename={post.categorySlug === "official-assets"}`.
- `src/components/messageStream/bubbles/ImageWithTextBubble.tsx` — same gate, threaded through `SingleImageFrame`.
- `src/components/messageStream/bubbles/AlbumBubble.tsx` — when category matches, renders the filename label inside each visible tile (skipping the `+N` overflow tile so its overlay stays clean).
- `src/components/messageStream/bubbles/VideoBubble.tsx``VideoAttachmentCard` accepts `showFilename`, threaded into both the multi-video grid layout and the single-video layout. Skipped on the `+N` overflow tile.
## Verification
- `npx tsc --noEmit` — clean.
- `npm run format` then `npm run format:check` — clean.
- `npm test` — 49/49 passing.
- Visual confirmation pending on a posts feed that contains `official-assets` content.
## Notes
- Reuses existing `filenameWithExtension` util so attachments without an extension still get a sensible label from their MIME type.
- The label is `pointer-events-none` so it never blocks the bubble's own tap target (open lightbox / play video).
- Only single/album/video bubbles surface this. `FileDocBubble` already renders the filename inline, so no overlay is needed there.
- If a future requirement adds more "show filename" categories, switch `showFilename` from a boolean gate to a derived util (e.g. `shouldShowAttachmentFilename(post)`).

View File

@@ -0,0 +1,592 @@
---
title: "ARK Library Frontend — Backend API Requirements"
type: api-requirements
date: 2026-05-25
audience: backend
status: draft
---
# ARK Library Frontend — Backend API Requirements
这份文档列出前端接下来需要后端提供的**全部接口**。重点是新的 Telegram-style 资料流;旧 `resources` 接口可作为过渡,但最终建议统一到 `posts` 模型。
## 0. 通用约定
- API base前端通过 `VITE_API_URL` 指向后端;本地可同源 `/api`
- 上传文件可通过 `/uploads/...` 或完整 URL 返回;前端会用 `assetUrl()` 处理相对路径。
- 所有时间字段使用 ISO 8601 字符串,例如 `2026-05-24T14:42:00.000Z`
- 语言字段:`zh-CN` / `en` / `ja` / `ko` / `vi` / `id` / `ms`;默认语言为 `en`。中文只有简体 `zh-CN`,没有繁体中文。
- 错误格式:非 2xx + text/plain 或 JSON 均可;前端会显示错误文本。
- Admin 接口需要 `Authorization: Bearer <token>`
## 1. 核心数据模型
### 1.1 Category
```ts
type Category = {
id: number;
slug: string; // 用于 /category/:slug 和 GET /api/posts?category=<slug>
name: string; // 已按 lang 返回本地化名称
description?: string;
iconKey: string; // folder/calendar/megaphone/video/image 等,前端已有 icon map
sortOrder: number;
};
```
### 1.2 Post新资料流核心
```ts
type AttachmentKind = "image" | "video" | "document";
type Attachment = {
id: string;
kind: AttachmentKind;
url: string; // 原始文件或可访问文件 URL
mime: string; // image/jpeg, application/pdf, video/mp4, ...
filename: string;
sizeBytes: number;
width?: number; // image/video 建议提供
height?: number;
durationSec?: number; // video 建议提供
posterUrl?: string; // video preview
thumbnailUrl?: string; // image/document preview
};
type Post = {
id: string;
categoryId: number;
categorySlug: string;
language: "zh-CN" | "en" | "ja" | "ko" | "vi" | "id" | "ms";
text?: string;
attachments: Attachment[];
isRecommended: boolean;
publishedAt: string;
updatedAt: string;
};
type PostListResponse = {
items: Post[];
nextCursor?: string;
};
```
### 1.3 Post 显示规则(后端必须按这个模型返回)
- 纯文字/链接:`attachments: []``text` 非空。
- 单张图片:`attachments.length === 1``kind: "image"`
- 图片 + 文字:`kind: "image"` + `text`
- 视频:`kind: "video"`,建议提供 `posterUrl` / `durationSec`
- 文件:`kind: "document"`,前端显示下载卡。
- 图片当文件上传:`kind: "document"``mime` 是 image前端会显示缩略图 + 下载按钮。
- 多图:
- 2 / 3 / 4 张图:前端会独立纵向显示每张图,同一个 Post 只显示一次时间。
- 超过 4 张图:前端显示 2×2 合并格,第 4 格模糊并显示 `+N`
## 2. Public API前台用户
### 2.1 分类列表
```http
GET /api/categories?lang=en
```
Response:
```ts
Category[]
```
用途Home 资料分类、CategoryPage 标题、Admin 表单分类选择。
---
### 2.2 全部资料 / 分类资料流
```http
GET /api/posts?lang=en&limit=20&cursor=<cursor>&type=all&language=&category=<slug>
```
Query:
| 参数 | 必填 | 说明 |
| ---------- | ---: | ------------------------------------------- |
| `lang` | 是 | UI 语言 |
| `limit` | 否 | 默认 20最大建议 50 |
| `cursor` | 否 | 后端返回的不透明 cursor |
| `category` | 否 | 不传 = 全部资料;传 slug = 单分类 |
| `type` | 否 | `all/image/video/ppt/pdf/text/link/archive` |
| `language` | 否 | 资料语言:`zh/en/ja/ko/vi/id/ms` |
Response:
```ts
PostListResponse;
```
排序:`publishedAt DESC`
用途:
- `/browse`:不传 `category`
- `/category/:slug`:传 `category=<slug>`
---
### 2.3 Home 推荐资料
```http
GET /api/posts/recommended?lang=en&limit=12
```
Response:
```ts
{ items: Post[] }
```
用途Home「官方推荐」section。按 `sortOrder ASC` + `publishedAt DESC` 或后端自定义推荐顺序。
> 过渡期:当前前端 Home 仍可接受旧 `/api/resources/recommended`,但建议后端新做 `posts/recommended` 后前端再切换。
---
### 2.4 Home 最新资料
```http
GET /api/posts/latest?lang=en&limit=8
```
Response:
```ts
{ items: Post[] }
```
用途Home「最新更新」section。按 `publishedAt DESC`
> 过渡期:当前前端 Home 仍可接受旧 `/api/resources/latest`。
---
### 2.5 单条 Post旧链接落地
```http
GET /api/posts/:id
```
Response:
```ts
Post;
```
用途:旧 `/resource/:id` 前端重定向:拿 `categorySlug` 后跳到 `/category/<slug>#post-<id>`
---
### 2.6 搜索
建议新接口:
```http
GET /api/posts/search?q=<keyword>&lang=en&type=all&language=&cursor=&limit=20
```
Response:
```ts
PostListResponse;
```
搜索范围建议:`text``filename``categoryName`、tags。
过渡期当前前端仍使用:
```http
GET /api/resources?q=<keyword>&lang=&type=&language=&limit=50
```
Response:
```ts
{ items: Resource[] }
```
---
### 2.7 搜索日志
```http
POST /api/search-log
Content-Type: application/json
{ "query": "ARK" }
```
Response204 或 `{ ok: true }`
用途:记录用户搜索词;失败不阻断用户体验。
---
### 2.8 下载统计(可选)
文件下载目前前端可直接打开 `Attachment.url`。如果后端需要统计下载,提供:
```http
POST /api/posts/:postId/attachments/:attachmentId/download
```
Response204 或 `{ ok: true }`
> 过渡期旧 Home 推荐卡还可能调用 `POST /api/resources/:id/download`。
## 3. Filter 语义
`GET /api/posts``type` 参数建议按以下规则命中:
| type | 命中条件 |
| --------- | ----------------------------------------------------------------- |
| `all` | 全部 |
| `image` | 任一 attachment `kind === "image"` |
| `video` | 任一 attachment `kind === "video"``mime.startsWith("video/")` |
| `pdf` | 任一 attachment 扩展名 `pdf``mime === "application/pdf"` |
| `ppt` | 任一 attachment 扩展名 `ppt/pptx/key` 或 mime 含 `presentation` |
| `archive` | 任一 attachment 扩展名 `zip/rar/7z/tar/gz` |
| `text` | `text` 非空 |
| `link` | `text` 中包含 `https?://` |
## 4. Wallet Auth API
### 4.1 取得签名 nonce/message
```http
POST /api/auth/wallet/nonce
Content-Type: application/json
{ "address": "0x..." }
```
Response:
```ts
{
message: string;
}
```
### 4.2 验证签名并签发 token
```http
POST /api/auth/wallet/verify
Content-Type: application/json
{
"address": "0x...",
"message": "...",
"signature": "0x..."
}
```
Response:
```ts
{
token: string;
}
```
### 4.3 验证当前 wallet session
```http
GET /api/auth/wallet/me
Authorization: Bearer <wallet-token>
```
Response:
```ts
{
wallet: string;
}
```
## 5. Admin API
### 5.1 Admin 登录
```http
POST /api/admin/login
Content-Type: application/json
{ "username": "...", "password": "..." }
```
Response:
```ts
{
token: string;
}
```
---
### 5.2 Admin dashboard
```http
GET /api/admin/dashboard
Authorization: Bearer <admin-token>
```
Response:
```ts
type AdminDashboard = {
totalResources: number; // 若迁移到 Post可理解为 totalPosts
published: number;
todayNew: number;
totalViews: number;
totalDownloads: number;
totalFavorites: number; // 收藏下线后可返回 0避免旧 admin UI 崩
totalShares: number;
hotResources: {
id: string;
title: string;
downloads: number;
favorites: number; // 可返回 0
views: number;
}[];
};
```
---
### 5.3 文件上传
```http
POST /api/admin/upload
Authorization: Bearer <admin-token>
Content-Type: multipart/form-data
file=<File>
```
最低 Response:
```ts
{
url: string;
}
```
建议 Response更方便前端自动建 Attachment
```ts
{
url: string;
mime: string;
filename: string;
sizeBytes: number;
width?: number;
height?: number;
durationSec?: number;
thumbnailUrl?: string;
posterUrl?: string;
}
```
---
### 5.4 Admin Post 列表
```http
GET /api/admin/posts?limit=25&page=1&status=&category=&type=&q=
Authorization: Bearer <admin-token>
```
Response:
```ts
{
items: AdminPost[];
total: number;
}
type AdminPost = Post & {
isPublic: boolean;
sortOrder: number;
status: "draft" | "published" | "archived";
viewCount?: number;
downloadCount?: number;
};
```
---
### 5.5 Admin Post 详情
```http
GET /api/admin/posts/:id
Authorization: Bearer <admin-token>
```
Response`AdminPost`
---
### 5.6 创建 Post
```http
POST /api/admin/posts
Authorization: Bearer <admin-token>
Content-Type: application/json
```
Request:
```ts
type UpsertPostPayload = {
categoryId: number;
language: "zh-CN" | "en" | "ja" | "ko" | "vi" | "id" | "ms";
text?: string;
attachments: Attachment[];
isPublic: boolean;
isRecommended: boolean;
sortOrder: number;
status: "draft" | "published" | "archived";
publishedAt?: string;
tags?: string[];
};
```
Response`AdminPost`
---
### 5.7 更新 Post
```http
PUT /api/admin/posts/:id
Authorization: Bearer <admin-token>
Content-Type: application/json
```
Request`UpsertPostPayload`
Response`AdminPost`
---
### 5.8 删除 Post
```http
DELETE /api/admin/posts/:id
Authorization: Bearer <admin-token>
```
Response204 或 `{ ok: true }`
---
### 5.9 Admin 搜索日志
```http
GET /api/admin/search-logs?limit=300
Authorization: Bearer <admin-token>
```
Response:
```ts
{
items: {
id: string;
query: string;
createdAt: string;
count?: number;
}[];
}
```
## 6. 过渡期旧 Resource API如果 admin 尚未迁移)
当前部分前端/admin 代码仍可能使用旧接口。后端可以短期保留,或前端后续再统一切到 Post
```http
GET /api/resources?lang=&limit=&page=&sort=&q=&type=&language=&tag=
GET /api/resources/recommended?lang=&limit=
GET /api/resources/latest?lang=&limit=
POST /api/resources/:id/download
GET /api/admin/resources?limit=&page=
GET /api/admin/resources/:id
POST /api/admin/resources
PUT /api/admin/resources/:id
```
`Resource` shape
```ts
type Resource = {
id: string;
title: string;
description?: string;
type: string;
language: string;
categoryId: number;
categorySlug: string;
categoryName: string;
coverImage?: string;
fileUrl?: string;
previewUrl?: string;
externalUrl?: string;
bodyText?: string;
badgeLabel?: string;
isDownloadable: boolean;
isRecommended: boolean;
publishedAt?: string;
updatedAt: string;
tags?: string[];
};
```
## 7. 已下线 / 不需要实现
- 用户收藏:`/favorites` 页面已移除。
- `POST /api/resources/:id/favorite` 不需要。
- Reaction / 点赞 / 评论不需要。
- Telegram 管理员标签、头像、群组名不需要。
## 8. 前后端切换计划
1. 后端先实现 `/api/posts``/api/posts/:id``/api/categories`
2. 前端 staging 设置:`VITE_USE_MOCK_POSTS=false`
3. 确认 `/browse``/category/:slug` 正常拉真数据。
4. 再实现 `/api/posts/recommended``/api/posts/latest`,前端把 Home 从旧 resources 切到 posts。
5. 最后迁移 Admin 从 `/api/admin/resources``/api/admin/posts`
## 9. 最小可上线优先级
### P0前台资料流必需
- `GET /api/categories`
- `GET /api/posts`
- `GET /api/posts/:id`
- `POST /api/admin/upload`
- `POST /api/admin/posts`
- `PUT /api/admin/posts/:id`
- `GET /api/admin/posts`
- `GET /api/admin/posts/:id`
### P1首页/搜索/统计)
- `GET /api/posts/recommended`
- `GET /api/posts/latest`
- `GET /api/posts/search`
- `POST /api/search-log`
- `GET /api/admin/dashboard`
- `GET /api/admin/search-logs`
### P2账户
- Wallet nonce / verify / me

View File

@@ -0,0 +1,176 @@
---
title: "Posts API Contract (for backend)"
type: api-contract
date: 2026-05-25
audience: backend
status: draft
---
# Posts API Contract
> 这份文档是从 `2026-05-25-telegram-style-resource-stream-design.md` §1§2 抽出,供后端实现使用。前端已用 mock data 完成视觉;上线时把 `VITE_USE_MOCK_POSTS=false` 即可切真接口。
## 1. 数据模型
```ts
type AttachmentKind = "image" | "video" | "document";
type Attachment = {
id: string; // 唯一 id
kind: AttachmentKind; // 三大类,前端按此分支渲染
url: string; // 原始文件地址
mime: string; // image/jpeg, application/pdf, video/mp4, ...
filename: string; // 显示用文件名,含扩展名
sizeBytes: number; // 字节数;前端格式化为 "3.5 MB"
width?: number; // image/video 用于占位比例CLS 优化)
height?: number;
durationSec?: number; // video 专用
posterUrl?: string; // video 海报缩略图
thumbnailUrl?: string; // image 缩略,列表用减少流量
};
type Post = {
id: string;
categoryId: number;
categorySlug: string;
language: string; // "zh-CN" | "en" | "ja" | "ko" | "vi" | "id" | "ms"
text?: string; // 可选,纯文本/图说;前端做 https → 链接自动识别
attachments: Attachment[]; // 0~Ntext-only post 时为 []
isRecommended: boolean;
publishedAt: string; // ISO 8601用于排序 + 日期分组
updatedAt: string;
};
type PostListResponse = {
items: Post[];
nextCursor?: string; // 不透明 cursorundefined = 没有下一页
};
```
### 关键约定
- **图片当文档**(在前端显示为「文件下载卡」):`kind === "document"``mime.startsWith("image/")`。Admin 上传时通过开关决定走 image 还是 document 通道。
- **图片当图片**(前端显示为图片预览):`kind === "image"`
- **多图相册**:一个 Post 带多个 `kind === "image"` 的 attachments。前端会在 2-4 grid 中渲染attachments.length > 4 时第 4 格模糊 + `+N`
- **图片 + 文字**Post 同时有 `text` 与 attachments。
- **纯文本 / 链接**Post 仅有 `text``attachments: []`
- **视频**`kind === "video"` 单 attachment。`posterUrl` 用于预览,`durationSec` 用于角标。
- Attachment 内不携带任何「上传者头像 / 管理员标签」等社交字段(前端已下线)。
## 2. Endpoints
### 2.1 列表(核心)
```
GET /api/posts
```
Query 参数:
| 参数 | 必填 | 说明 |
| ---------- | ---- | ---------------------------------------------------------------------------------- |
| `lang` | 是 | UI 语言;后端可据此选择不同语言版本的 `text` |
| `category` | 否 | category slug不传 = 全部分类 |
| `type` | 否 | `all` / `image` / `video` / `pdf` / `ppt` / `text` / `link` / `archive`;语义见 §3 |
| `language` | 否 | 资源语言:`zh-CN` / `en` / `ja` / `ko` / `vi` / `id` / `ms` |
| `cursor` | 否 | 上一次返回的 `nextCursor`;不传 = 第一页 |
| `limit` | 否 | 默认 20最大 50 |
返回:`PostListResponse`
排序:`publishedAt DESC`
### 2.2 Home 用聚合接口(可选,沿用现状)
```
GET /api/posts/recommended?lang=&limit=
GET /api/posts/latest?lang=&limit=
```
返回:`{ items: Post[] }`(不分页)
### 2.3 单条(用于老链接 301 落地)
```
GET /api/posts/:id
```
返回:`Post`(或 404
前端 `/resource/:id` 现在是轻量重定向:拿到 `categorySlug``/category/<slug>#post-<id>` 锚点滚动。
### 2.4 分类(不变)
```
GET /api/categories?lang=
```
返回:现有 `Category[]`
### 2.5 Admin CRUD
```
POST /api/admin/posts
PUT /api/admin/posts/:id
DELETE /api/admin/posts/:id
GET /api/admin/posts?... (含未发布草稿)
```
需求:
- 支持多附件上传(一次 multipart 或先 `POST /api/admin/upload` 拿到 url 再创建 Post
- Admin UI 需要一个开关:「图片以图片形式呈现 / 以文档形式呈现」,对应 attachment.kind 的 image vs document。
- 支持发布/隐藏、置顶/官方推荐。
> Admin UI 改造单独建 spec / plan本契约仅说明后端必须支持这些字段。
## 3. `type` 参数语义
一个 Post 命中某个 `type`,规则:
| type | 命中条件 |
| --------- | -------------------------------------------------------------------------- |
| `all` | 全部 |
| `image` | `attachments` 中至少一个 `kind === "image"``mime.startsWith("image/")` |
| `video` | 至少一个 `kind === "video"``mime.startsWith("video/")` |
| `pdf` | 至少一个 `mime === "application/pdf"` 或扩展名为 `pdf` |
| `ppt` | 至少一个扩展名为 `ppt` / `pptx` / `key` 或 mime 含 `presentation` |
| `archive` | 至少一个扩展名为 `zip` / `rar` / `7z` / `tar` / `gz` |
| `text` | `text` 非空 |
| `link` | `text` 非空且匹配 `https?://` |
前端 mock 已按此规则过滤,便于切真接口时口径一致。
## 4. 删除 / 废弃
| 项 | 处理 |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /api/resources/:id/favorite` | 删除 |
| `GET /api/favorites` / 收藏列表 | 删除(前端 `/favorites` 路由已移除) |
| `/r/:id` 老前端路由 | 已合并到 `/resource/:id` 重定向 |
| 老 `/api/resources*` 系列 | 后端可保留过渡期。建议提供数据迁移脚本:每个老 Resource → 一个 Post带 1 个 attachment 或 text-only`isRecommended` / `language` / `categorySlug` 字段迁移;`favorite count` 字段丢弃。 |
| Resource.coverImage 与 Resource.fileUrl 二选一 | 转为 attachments[0]kind 由后端判断 image vs document |
## 5. Search
`GET /api/resources?q=...` 当前仍被 SearchPage 使用(在新 schema 上线前过渡)。后端可视情况:
- 短期:保留旧接口
- 长期:新增 `GET /api/posts/search?q=...` 返回 `PostListResponse`,前端再切
## 6. 错误格式
沿用现状HTTP 状态码 + 文本 body。前端 `getJSON` 会把非 2xx 当作 `Error(text)` 抛出,`MessageStream` 显示红色横幅 + 重试按钮。
## 7. 兼容性 / 灰度
后端 ready 时步骤:
1. 把示例数据导入到 Posts 表
2. `/api/posts` 在 staging 通过
3. 前端 staging 部署设 `VITE_USE_MOCK_POSTS=false`,跑通后再 prod
4. 前端代码层面:删除 `src/mocks/mockPosts.ts``usePostStream.ts` 中的 mock 分支,或保留 mock 用于本地离线开发
## 8. 联系
前端Terry。Spec 主文档:`.unipi/docs/specs/2026-05-25-telegram-style-resource-stream-design.md`

View File

@@ -0,0 +1,327 @@
---
title: "Telegram-style Resource Stream资料分类查看全部 UI 重构)"
type: brainstorm
date: 2026-05-25
---
# Telegram-style Resource Stream
## Problem Statement
当前"查看全部"打开的资料列表(`/browse``/category/:slug`)使用统一的卡片网格,无法表达"admin 上传的内容本质是不同类型的消息"(一张图、一段文字+链接、一个视频、一个 PDF、4+ 张图相册等)。手机端用户体验偏弱,缺少 Telegram 那种按类型差异化呈现的直观感受。
本次重构目标:把 `/browse``/category/:slug` 改成**单列、按时间倒序、按日期分组、按上传类型差异化渲染**的 Telegram-style 消息流,手机优先,保留 ARK 既有色系(深底 + 金色高亮)。
> 后端 endpoint 尚未实现。本次前端先用 mock data 完成视觉与交互,验收后再交接 API 契约给后端。
## Context
### 当前实现
- `src/pages/Home.tsx`:分类 section 头部"查看全部" → `/browse`;分类卡片 → `/category/<slug>`
- `src/pages/Browse.tsx`221 行):含排序 tabs最新/推荐/热门/发布)+ 类型 chips + 语言 chips + tag 过滤 + 分页(每页 24
- `src/pages/CategoryPage.tsx`156 行):含类型 chips + 语言 chips + 分页。
- `src/pages/ResourceDetail.tsx`229 行):`/r/:id` 资源详情独立路由。
- `src/pages/FavoritesPage.tsx` + `postFavoriteDelta`:收藏功能。
- `src/components/ResourceCard.tsx`:统一卡片,不区分资源类型。
- `Resource` schema`src/api.ts`扁平1 个 resource = 1 个文件(`coverImage` / `fileUrl`),无 attachments 数组。
### 设计参考(用户提供 7 张 Telegram 截图)
1. 图片当文档上传:缩略图 + ↓ + filename.ext + size + 右下时间,无头像、无 reaction。
2. PDF / AI / PPT 等文档:蓝圆 ↓ + filename + size。
3. 纯文本 + 链接:`https://...` 自动识别为可点链接。
4. 视频:海报 + ▶️,第一次点 inline 播放预览,第二次点全屏;下方可有 admin 写的说明文字。
5. 单张图片:直接显示,点全屏。
6. 图片 + 文字:图片上方/下方文字,文字内链接 autolink。
7. 4+ 张图相册1-4 格 grid第 4 张模糊 + `+N`;点开后全屏画廊。
## Chosen Approach
**方案 A自建 `MessageStream` + 多态 `MessageBubble` 家族 + Mock-data layer**
- 新建一个 `MessageStream` 容器组件,被 `Browse.tsx``CategoryPage.tsx` 共用,差异通过 `scope` props 注入。
- `MessageBubble` 内根据 `Post.attachments` 的形状分发到 6 个子组件FileDoc / Text / Video / Image / ImageWithText / Album
- 全屏交互(图片画廊 / 视频全屏)走 portal overlay。
- 数据层用 `usePostStream` hook 抽象,默认走 `src/mocks/mockPosts.ts`(受 `VITE_USE_MOCK_POSTS` 控制),后端 ready 后切换为真 API。
- 同时**收尾**收藏功能与详情页:删除 `/favorites``/r/:id`、heart 按钮、`postFavoriteDelta`
## Why This Approach
### 拒绝方案 B聊天 UI 库 `@chatscope/chat-ui-kit-react` 等)
- 默认主题与 ARK 深底+金色严重冲突,改主题成本 ≈ 自己写。
- 不支持"4+ 图相册 +N 模糊"自定义。
- 增加包体积与灰盒 bug 风险。
### 拒绝方案 C重样 `ResourceCard`
- 当前卡片统一渲染,无法满足"按类型差异化"(视频内嵌播放器 vs 多图相册 vs 文本+链接)。
- 改动表面但不达 Telegram 风格。
### 拒绝其他子选项
- **保留排序 tabs**Telegram 流天然按时间倒序,多余 tabs 破坏隐喻。Home 页仍保留"官方推荐 / 最新更新" section 作为入口。
- **保留收藏功能在列表/详情页**:用户明确要求"不需要 reaction",且收藏与 Telegram 隐喻不符;整体下线最干净。
- **保留 `ResourceDetail` 作 fallback**:所有交互(下载 / 全屏 / 链接)都能就地完成,独立详情页冗余;老 `/r/:id` 改 301 重定向到 `/category/<slug>#post-<id>` 锚点。
- **`kind` 枚举铺开**:后端枚举膨胀难维护;前端按 `mime` / 文件后缀做细分图标更灵活。
## Design
### 1. 数据模型(前端使用 + 后端接口契约)
```ts
type Post = {
id: string;
categoryId: number;
categorySlug: string;
language: string; // zh-TW | zh-CN | en
text?: string; // 可选;纯文本/图说,前端自动识别 https 链接
attachments: Attachment[]; // 0~Ntext-only post 时为 []
isRecommended: boolean;
publishedAt: string; // ISO用于排序 + 日期分组
updatedAt: string;
};
type Attachment = {
id: string;
kind: "image" | "video" | "document";
url: string;
mime: string; // image/jpeg | application/pdf | video/mp4 | ...
filename: string; // "ARK项目一图读懂-01.jpg"
sizeBytes: number;
width?: number;
height?: number;
durationSec?: number; // video 专用
posterUrl?: string; // video 海报
thumbnailUrl?: string; // image 缩略
};
```
关键约定:
- `kind: "document" + mime.startsWith("image/")` = 图片当文档上传(截图 1
- `kind: "image"` = 图片当图片呈现(截图 5、6、7。该开关在 admin 上传 UI 决定,传到后端落库。
- 多图相册 = 一个 Post 带多个 `kind: "image"` 的 attachments。
- 图片+文字 = Post 同时有 `text` 与 attachments。
- 纯文本+链接 = Post 仅有 `text``attachments: []`
### 2. 后端 API 契约(移交给后端)
| 方法 | 路径 | 用途 |
|---|---|---|
| GET | `/api/posts?category=<slug>&lang=&type=&language=&cursor=&limit=20` | 分类内消息流 |
| GET | `/api/posts?lang=&type=&language=&cursor=&limit=20` | 全部消息流(`/browse` |
| GET | `/api/posts/recommended?lang=&limit=` | Home 推荐 section |
| GET | `/api/posts/latest?lang=&limit=` | Home 最新 section |
| GET | `/api/posts/:id` | 单条(用于老 `/r/:id` 301 重定向落地,前端拿到 `categorySlug` 后跳锚点) |
| GET | `/api/categories` | 不变 |
| POST/PUT/DELETE | `/api/admin/posts/...` | Admin CRUD支持多附件 + 文本 + "图片是否以文档呈现"开关 |
废弃:
- `/api/resources/:id/favorite`
-`/api/resources*` 系列保留过渡期,由后端写迁移脚本:每个老 Resource → 一个 Post。
返回结构:`{ items: Post[], nextCursor?: string }`cursor 由后端不透明字符串提供。
`type` 参数语义:`all` / `image` / `video` / `pdf` / `ppt` / `text` / `link` / `archive`。一个 Post 命中条件 = `attachments[*].mime``text` 满足;具体由后端定义。
### 3. 组件结构
```
src/pages/
CategoryPage.tsx ← 重写:<MessageStream scope={{ kind:'category', slug }} />
Browse.tsx ← 重写:<MessageStream scope={{ kind:'all' }} />
src/components/messageStream/
MessageStream.tsx 顶层fetch + 无限滚动 + 日期分组 + sticky filter chips
FilterChips.tsx 类型 + 语言 chips横向滚动sticky top
DaySeparator.tsx "2 月 27 日" 胶囊
MessageBubble.tsx 单条 Post 容器:决定子组件 + 右下角时间戳
bubbles/
FileDocBubble.tsx 截图 1 + 2文档图片当文档 / pdf / ai / ppt / docx
TextBubble.tsx 截图 3纯文本 + autolink
VideoBubble.tsx 截图 4海报 + ▶️,先 inline 后全屏
ImageBubble.tsx 截图 5单张图片
ImageWithTextBubble.tsx 截图 6图片 + 文本 + autolink
AlbumBubble.tsx 截图 72-4 格 grid4+ 时第 4 格模糊 + `+N`
overlays/
ImageLightbox.tsx 全屏画廊(左右滑、缩放、关闭、下载)
VideoPlayer.tsx 全屏视频播放器
hooks/
usePostStream.ts cursor 分页 + IntersectionObservermock/real 切换
useGroupedByDay.ts 按 publishedAt 本地日期分组
utils/
autolink.tsx 文本中 https?://... → <a target="_blank" rel="noopener">
fileIcon.ts 按 mime/扩展名返回图标 + 颜色
formatBytes.ts 3,549,239 → "3.5 MB"
src/mocks/
mockPosts.ts 覆盖 7 种 bubble 类型的样本数据(图片用 picsum 占位或本地)
```
### 4. Bubble 分发逻辑
```ts
function pickBubble(post: Post) {
const a = post.attachments;
if (a.length === 0) return TextBubble;
if (a.length >= 2 && a.every(x => x.kind === "image")) return AlbumBubble;
const only = a[0];
if (only.kind === "video") return VideoBubble;
if (only.kind === "image") {
return post.text ? ImageWithTextBubble : ImageBubble;
}
return FileDocBubble; // document含图片当文档内部用 thumbnail 替代蓝圆图标)
}
```
### 5. 移动端布局规范
- 容器宽度:手机 `max-w-full px-3`md+ `max-w-[640px] mx-auto`。桌面端不做多列,保持单列聊天流(左右大留白)。
- 气泡:`rounded-2xl bg-ark-panel`,左对齐,无头像,内边距 `p-3`(文本 `px-4 py-2.5`)。
- 时间戳:右下角 `text-[11px] text-neutral-500`,绝对定位。
- 文档下载按钮:圆形 36×36金色 `bg-ark-gold` + 黑色 ↓。
- Day separator胶囊 `rounded-full bg-ark-panel/70 backdrop-blur px-3 py-1 text-xs text-neutral-400`居中、sticky 在 FilterChips 下。
- 多图 grid宽度 100%2×2间距 2px4+ 时第 4 格 `relative``bg-black/45 backdrop-blur-sm` + `+N` 居中文字。
- FilterChips 容器:`sticky top-0 z-10 bg-ark-bg/90 backdrop-blur` + 横向滚动 `overflow-x-auto whitespace-nowrap`
### 6. 交互
| 交互 | 行为 |
|---|---|
| 点击文档下载按钮 | `window.open(attachment.url, "_blank")` 触发浏览器下载 |
| 点击单张图片 | 打开 `ImageLightbox`(单图) |
| 点击相册任一图 / `+N` | 打开 `ImageLightbox`,可左右切换 |
| 点击视频海报 | 第一次bubble 内 `<video controls autoPlay>` inline 播放 |
| 点击播放中的视频 | 打开 `VideoPlayer` 全屏 overlay |
| 文本中的链接 | `target="_blank" rel="noopener noreferrer"` 新标签打开 |
| 滚动到底部 | IntersectionObserver 触发下一页 cursor 拉取 |
| 筛选 chips 变化 | 重置 cursor重新拉取同步 URL `?type=&language=` |
| 长按气泡 | 暂不做,列入 Open Questions |
### 7. Mock data 层
`src/mocks/mockPosts.ts` 导出 `MOCK_POSTS: Post[]`,至少包含:
- 2 条"图片当文档"(不同 mimejpg、png
- 2 条文档pdf、ai
- 2 条纯文本+链接(含中文 + 多链接 + emoji
- 1 条视频(带 posterUrl + duration
- 2 条单图(不同宽高比)
- 1 条图+文字
- 1 条 3 图相册
- 1 条 7 图相册(验证 `+N` 行为)
- 跨多天的 `publishedAt`,验证 DaySeparator
`usePostStream` 行为:
```ts
const useMock = import.meta.env.VITE_USE_MOCK_POSTS !== "false";
if (useMock) {
// 1. 按 scope.slug / type / language 过滤 MOCK_POSTS
// 2. 按 publishedAt 倒序
// 3. 按 cursor数字 offset 字符串)切 20 条
// 4. setTimeout 200ms 模拟延迟
// 5. 返回 nextCursor = offset+20 或 undefined
} else {
// fetch /api/posts?... 真接口
}
```
切真接口时只需在部署环境设 `VITE_USE_MOCK_POSTS=false`(或干脆删 mock 分支)。
### 8. 锚点 + 分享
- 每个 bubble 渲染为 `<article id="post-${post.id}">`
- 老路由 `/r/:id` 改为一个轻量重定向组件fetch `/api/posts/:id` 拿到 `categorySlug``navigate(/category/${slug}#post-${id}, { replace: true })``scrollIntoView`
- Mock 模式下从 `MOCK_POSTS` 找。
### 9. 移除清单
文件:
- `src/pages/ResourceDetail.tsx`
- `src/pages/FavoritesPage.tsx`
- `src/components/ResourceCard.tsx`
- `src/components/ResourceListFooter.tsx`
代码:
- `postFavoriteDelta` 及所有调用点
- i18n keys`favorites`, `addFavorite`, `removeFavorite` 等收藏相关
- Home 中的 `/favorites` 入口
路由:
- `/favorites`:删除
- `/r/:id`:保留为轻量重定向
### 10. 测试 / 验证策略
- 视觉验证:本地 `npm run dev`手机模拟器Chrome DevTools iPhone 14 Pro 视口)逐一对照 7 张截图。
- 单元测试:`pickBubble` 分发逻辑、`autolink` 正则、`formatBytes``useGroupedByDay`
- 类型检查:`npx tsc --noEmit`(项目 strict
- 格式化:`npm run format`
- 删除后回归:确认 `/favorites``/r/:id` 老链接不报 404 而是合理跳转或 410。
### 11. 风险与缓解
- **真接口 schema 与 mock 不一致**spec 是合同;后端实现时若需偏离,必须先回来改 spec。前端 hook 内 `Post` 类型从 `src/types/post.ts` 单一来源导入。
- **`+N` 相册和单图 lightbox 的状态管理混乱**:用 React Context`<ImageLightboxProvider>`)暴露 `openLightbox(images, startIndex)` 单一入口,所有 bubble 调它。
- **视频 inline → 全屏切换的状态丢失**:全屏 overlay 接 `currentTime` 参数,避免重新加载。
- **scroll restoration**cursor 分页页内来回滑动时 IntersectionObserver 容易重复触发;用 `loadingRef` 守护。
## Implementation Checklist
> 全部项已被 `.unipi/docs/plans/2026-05-25-telegram-style-resource-stream-plan.md` 覆盖。
- [x] 定义 `src/types/post.ts``Post``Attachment``PostListResponse`
- [x] 创建 `src/mocks/mockPosts.ts`:覆盖 7 种 bubble 类型 + 跨日期样本
- [x] 创建 `src/components/messageStream/hooks/usePostStream.ts`mock + real 双模式 + cursor 分页 + IntersectionObserver
- [x] 创建 `src/components/messageStream/hooks/useGroupedByDay.ts`
- [x] 创建 `src/components/messageStream/utils/autolink.tsx`
- [x] 创建 `src/components/messageStream/utils/fileIcon.ts`
- [x] 创建 `src/components/messageStream/utils/formatBytes.ts`
- [x] 创建 `FilterChips.tsx`sticky + 横向滚动)
- [x] 创建 `DaySeparator.tsx`
- [x] 创建 `MessageBubble.tsx`(含 `pickBubble` 分发)
- [x] 创建 `bubbles/FileDocBubble.tsx`(图片当文档 + pdf/ai/ppt
- [x] 创建 `bubbles/TextBubble.tsx`autolink
- [x] 创建 `bubbles/VideoBubble.tsx`inline 播放 + 全屏触发)
- [x] 创建 `bubbles/ImageBubble.tsx`
- [x] 创建 `bubbles/ImageWithTextBubble.tsx`
- [x] 创建 `bubbles/AlbumBubble.tsx`2-4 grid + `+N`
- [x] 创建 `overlays/ImageLightbox.tsx` + `ImageLightboxProvider` context
- [x] 创建 `overlays/VideoPlayer.tsx`
- [x] 创建 `MessageStream.tsx` 顶层组件
- [x] 重写 `src/pages/CategoryPage.tsx``<MessageStream scope={{ kind:'category', slug }} />`
- [x] 重写 `src/pages/Browse.tsx``<MessageStream scope={{ kind:'all' }} />`
- [x] 删除 `src/pages/ResourceDetail.tsx`,将 `/r/:id` 改为重定向组件mock 模式下从 `MOCK_POSTS` 查)
- [x] 删除 `src/pages/FavoritesPage.tsx``src/components/ResourceCard.tsx``src/components/ResourceListFooter.tsx`
- [x] 移除 `postFavoriteDelta` 及全部调用点
- [x] 移除 `App.tsx``/favorites` 路由 + Home 入口
- [x] 清理 i18n favorites 相关 keys
- [x] 单元测试:`pickBubble``autolink``formatBytes``useGroupedByDay`
- [x] 视觉对照 7 张参考截图iPhone 14 Pro 视口)
- [x] 运行 `npx tsc --noEmit && npm run format:check && npm test`
- [x] 文档:在 README 注明 `VITE_USE_MOCK_POSTS` 用法
- [x] 交付后端 API 契约文档(本 spec 的 §2 部分单独抽出 markdown 给后端)
## Open Questions
- **长按 / 右键菜单**:是否需要"复制链接"、"举报"、"分享"v2 决定。
- **`type` 筛选语义边界**:一个 Post 含多种 attachment 时(图+视频混合,目前 mock 不出现),`type=video` 命中规则由后端定,前端按返回展示即可。
- **空状态文案**:消息流为空时显示什么?目前沿用 `t("noResults")`
- **错误重试**:网络失败时是否提供"重试"按钮?建议下方加一个 inline 重试条。
- **视频自动暂停**:滚出视口时是否自动暂停?建议做,体验更顺。
- **i18n 时间戳格式**:是否需要适配繁体/简体/英文不同的日期分组格式?沿用 `Intl.DateTimeFormat``lang` 切换。
- **SEO**:删除 `/r/:id` 详情页后,搜索引擎抓取深度受影响吗?目前站点未做强 SEO可忽略如需保留可让 `/r/:id` 渲染服务端可解析的 `<noscript>` 摘要后再 JS 重定向。
- **Admin 上传 UI 改造**本次只覆盖前台浏览端admin 端 Post 编辑器(多附件 + 文本 + 图片呈现方式开关)需要单独的 spec / 任务。
## Out of Scope
- Home 页面布局调整(分类卡片网格、推荐/最新 section 保持不变)
- Admin 后台 UI 改造(单独 spec
- 真实 API 实现(后端工作)
- 后端数据迁移脚本
- 长按菜单、举报、分享等社交功能
- 评论 / Reaction
- 离线缓存 / Service Worker
- 桌面端多列布局

View File

@@ -0,0 +1,157 @@
---
title: "Browse 页全部资料Figma 1:1 视觉对齐"
type: brainstorm
date: 2026-05-28
---
# Browse 页全部资料Figma 1:1 视觉对齐
## Problem Statement
`/browse` 路由(「全部资料」页)的视觉与 Figma 设计稿node `4206-6051`)有两处显著落差,需要对齐:
1. **筛选条 (FilterChips)** 当前是椭圆 pill chip + 边框 + 溢出折叠按钮Figma 是下划线 tab 风格(文字 + 橘色底线,无边框、无背景)。
2. **mobile 底部导航第三格**目前是「官方推荐」(连到 `/browse?sort=recommended`Figma 是「我的收藏」。
页面标题、Header、讯息流 bubble 已对齐,不需要再动。
## Context
- 当前 FilterChips 实现:`src/components/messageStream/FilterChips.tsx`,含 9 种 typeall/image/video/music/ppt/pdf/text/link/archive`SlidersHorizontal` overflow expand 按钮。
- Figma 只展示 mobile viewport且只露 6 种 type全部/图片/视频/音乐/PPT/PDF
- 现有 mobile 底部 nav`src/layouts/PublicLayout.tsx` 内的 `BottomNavIcon`4 格home / document / heart / update。
- 资产已经存在 `heart-active.svg` / `heart-inactive.svg`,不需要新增图示档。
- 「我的收藏」功能本身**不实作**,只做 stub「即将推出」页。等用户系统建立后再做真功能。
- text / link / archive 三种 type 现在确实有资料可筛,不能直接砍掉。
## Chosen Approach
**Approach A + 全 viewport responsive**
- 筛选条改成下划线 tab 风格,**所有 viewport** 统一使用mobile / tablet / desktop
- 9 种 type 全部保留,溢出改成「水平横向滚动」(无 expand 按钮,自由滑),符合 Telegram 风格 + Figma 风格。
- mobile 底部 nav 第 3 格:图示沿用 heartlabel 改 `t("favorites")`,连结改 `/favorites`
- 新增 `/favorites` routestub 页内容为「我的收藏 — 即将推出」+ 返回首页 CTA。
- 桌机顶部 nav **不动**(「官方推荐」入口保留在 desktop top nav
## Why This Approach
- **为什么所有 viewport 统一筛选条样式**Terry 明确要求 responsive across screen sizes同一组件维护两套样式是不必要的复杂度下划线 tab 在桌机宽度下也清爽(更胜 pill chip
- **为什么水平滚动而非「⋯」展开**Figma 没画出展开行为标准的「Telegram filter bar」模式就是横滑少一个状态机本专案的 `MessageStream` 已经是无限滚动列表,再加一个 expand 反而割裂体验。
- **为什么不砍掉 text/link/archive**:这些是真有资料的 type砍掉会让桌机用户失去筛选能力横滑设计已经能容纳全部 9 种。
- **为什么 favorites 做 stub 不做完整功能**Terry 选 Q2 选项 3等用户系统再做明确不留 localStorage 技术债。
- **为什么沿用 heart 图示**资产已经存在Figma 视觉风格与现有 heart 相容;不增加设计审查负担。
### 已拒绝的替代方案
- **B桌机也加「我的收藏」顶部 nav 入口)**:会让桌机 nav 多一个空壳连结,不必要。
- **C移除 text/link/archive type 入口)**:损失既有功能,不符合「视觉对齐 ≠ 砍功能」原则。
- **localStorage 收藏功能**Terry 选 Q2 选项 3明确不做。
- **「⋯」expand 按钮Figma-faithful**Figma 没明确画出展开行为,是我推测的;横滑更直接。
## Design
### 1. FilterChips 重做
**文件**`src/components/messageStream/FilterChips.tsx`(改写,非新增)
**新视觉规范**
- 容器:水平横向滚动 (`overflow-x-auto`),隐藏 scrollbarsticky top 保留
- 每个 tab纯文字按钮无 border、无 background、无 rounded
- inactive`text-neutral-400`hover `text-ark-gold/80`
- active`text-ark-gold` + 底部 `2px` 橘色下划线(`border-b-2 border-ark-gold`inactive 底部 `2px` 透明 border占位防跳动
- 间距tab 之间 `gap-5`(或 `gap-6`,实作时微调)
- padding每个 tab 上下 `py-3`,左右无(让 text 自然贴齐 underline
- 字号:`text-sm``text-[15px]`,依照 Figma 比例
- 移除 `SlidersHorizontal` expand 按钮与 `expanded` state
- 移除 measure 隐藏元素 + `ResizeObserver`(不再需要侦测溢出)
**type 顺序保持不变**all / image / video / music / ppt / pdf / text / link / archive
**响应性**
- 因为是水平滚动所有宽度都能容纳mobile 极窄屏自然横滑,桌机宽屏会自然撑开置左
### 2. 新增 `/favorites` Stub 页
**文件**`src/pages/Favorites/index.tsx`(新增)
**结构**
- 居中容器(`flex items-center justify-center min-h-[60vh]`
- 心形 icon用 lucide `Heart`size 48px`text-ark-gold/70`
- 标题:`t("favorites")` → 「我的收藏」
- 副标:`t("favoritesComingSoon")` → 「功能即将推出」
- 描述:`t("favoritesComingSoonDesc")` → 简短说明(一行)
- CTA「返回首页」按钮 → `Link to="/"`,使用既有 `ark-gold` 风格
**响应性**
- 单栏居中,所有 viewport 都用同一份 layout
- 文字 `text-base` 起跳md 以上放大
### 3. App.tsx 加 route
**文件**`src/App.tsx`
- 在公开 routes 区段加入 `<Route path="/favorites" element={<Favorites />} />`
- 透过 `PublicLayout` Outlet 渲染(继承 Header + 底部 nav
### 4. PublicLayout 底部 nav 第 3 格改写
**文件**`src/layouts/PublicLayout.tsx`
- 第 3 个 `BottomNavIcon`
- `to="/browse?sort=recommended"``to="/favorites"`
- `label={t("official")}``label={t("favorites")}`
- `active={...recommended}``active={pathname === "/favorites"}`
- icon 保持 `heart`(资产已存在)
### 5. i18n 增加 key
**文件**`src/i18n.tsx`
- 三语言zh-TW / zh-CN / en各加
- `favorites`:「我的收藏」/「My Favorites」
- `favoritesComingSoon`:「即将推出」/「Coming Soon」
- `favoritesComingSoonDesc`:一行说明
- `backToHome`:「返回首页」/「Back to Home」如不存在
### Data Flow
- FilterChips 的 prop API`type` / `onTypeChange`**不变**,纯视觉重做,使用方(`MessageStream` 等)无需调整。
- `/favorites` stub 无任何资料请求,纯静态页。
### Error Handling
- `/favorites` 是纯静态,无错误状态。
- FilterChips 横滑容器:测试在 `overflow-x: hidden` 的 ancestor 中是否仍可滚动;既有 `global_overflow_x_hidden_mobile_2026_05_27` 的全域规则需要确认不会卡住此处的 horizontal scroll可能需要 `overflow-x-auto` 强制覆盖)。
### Testing
- 既有 `npm test` 应该全过API 没变)
- 视觉测试需要mobile (375px) / tablet (768px) / desktop (1280px) 三档手测
- TypeScript: `npx tsc --noEmit`
- Format: `npm run format:check`
## Implementation Checklist
- [ ] FilterChips.tsx 重写:移除 pill 样式与 expand 按钮,改为下划线 tab + 水平滚动
- [ ] 新增 `src/pages/Favorites/index.tsx` stub 页(含 Heart icon、标题、副标、返回首页 CTA
- [ ] `src/App.tsx` 加入 `/favorites` route 与 import
- [ ] `src/layouts/PublicLayout.tsx` 底部 nav 第 3 格改 label / route / active 判断
- [ ] `src/i18n.tsx` 三语加入 `favorites` / `favoritesComingSoon` / `favoritesComingSoonDesc` / `backToHome`
- [ ] 验证 FilterChips 横滑在 `global overflow-x-hidden` mobile 规则下仍正常
- [ ]`npx tsc --noEmit` + `npm run format:check` + `npm test`
- [ ] mobile / tablet / desktop 三档视觉手测
## Open Questions
- 「我的收藏」stub 页的副标描述文字具体怎么写?(建议:「登入功能开发中,敬请期待」之类,可在实作时定)
- FilterChips 的字号要 `text-sm` 还是 `text-[15px]`?需要量一下 Figma 才能 1:1实作时对图调整
- 桌机顶部 nav 的「官方推荐」是否要在未来 phase 一起处理?(本 spec 暂不动)
## Out of Scope
- 真正的「我的收藏」功能(依赖未来用户系统,独立 spec
- 桌机顶部 nav 调整(包括「官方推荐」入口存废)
- 讯息流 bubble 本身的视觉调整(既有已对齐)
- 移除 text / link / archive 三种 type
- 任何后端改动

View File

@@ -0,0 +1,367 @@
---
title: "China-Friendly Wallet Login"
type: brainstorm
date: 2026-06-01
---
# China-Friendly Wallet Login
## Problem Statement
ARK Library needs wallet-based login so users can later access account-bound features such as favorites. The login must work for China-based users without requiring VPN access where possible. The goal is not to perform on-chain reads or transactions; it is only to verify wallet address ownership through message signing and bind that address to a backend session/JWT.
The practical problem is that mobile users may open the DApp in different environments: desktop browser with extension, wallet DApp browser, or a normal mobile browser. A normal mobile browser cannot directly talk to a wallet app unless there is a bridge such as WalletConnect/Reown, a wallet-specific SDK, or a wallet-specific callback/deep-link flow.
## Context
Existing backend wallet authentication is partially available:
- `POST /api/auth/wallet/nonce`
- `POST /api/auth/wallet/verify`
- `GET /api/auth/wallet/me`
Backend findings:
- Wallet nonce currently expires after 15 minutes.
- Wallet JWT currently lasts 30 days.
- There is no refresh-token mechanism.
- There is no user-bound favorites API yet.
- Existing `/api/resources/{id}/favorite` only changes global favorite count and does not bind favorites to a wallet address.
Frontend findings:
- There is currently no wallet login implementation.
- Favorites page is currently a placeholder.
- Wallet login should be designed separately from the full favorites feature.
Research findings:
- RainbowKit is a wallet UI layer. Its generic scan/login flow usually depends on WalletConnect/Reown.
- Reown/WalletConnect relay was previously found unstable for China access.
- RainbowKit can appear stable when users are actually connecting through injected providers (`window.ethereum`) in extensions or wallet DApp browsers; that stability does not prove WalletConnect/Reown scan stability.
- TokenPocket supports stable external-browser flows through `tpoutside://pull.activity` deep links and callback URLs.
- TokenPocket `tp-js-sdk` only works inside TokenPocket's DApp browser, so it is not the external-browser QR bridge by itself.
- imToken QR login generally uses WalletConnect-style bridging.
- MetaMask QR login can use WalletConnect/RainbowKit-style bridging or MetaMask SDK. For this project, MetaMask QR is handled through RainbowKit as a fallback rather than a China-stable primary path.
- OKX Connect SDK was considered but rejected because OKX Wallet is not a target wallet for this product.
## Chosen Approach
Use a hybrid wallet login approach:
1. **Stable primary path:** custom injected-provider login plus TokenPocket QR/callback login.
2. **Compatibility fallback:** RainbowKit/Reown QR login for MetaMask and imToken users who want scan-login from a separate device.
Supported wallets for this design:
1. TokenPocket
2. MetaMask
3. imToken
Supported login paths:
| Wallet | Injected / DApp browser login | Click / deep-link login | QR login back to current browser |
|---|---:|---:|---:|
| TokenPocket | Yes | Yes | Yes, via TokenPocket callback |
| MetaMask | Yes | Yes | Yes, via RainbowKit/Reown fallback |
| imToken | Yes | Yes | Yes, via RainbowKit/Reown fallback |
The UI must not imply that all QR methods are equally stable in China. It should distinguish:
- **TokenPocket QR login** — recommended China-stable QR path.
- **MetaMask / imToken QR login** — compatibility fallback powered by RainbowKit/Reown; may fail or be slow depending on network environment.
## Why This Approach
This approach balances China stability with the user's requirement that MetaMask and imToken also have QR login options.
Accepted trade-offs:
- TokenPocket gets the primary QR login because it provides a direct callback mechanism that avoids Reown/WalletConnect relay instability.
- MetaMask and imToken get QR login through RainbowKit/Reown, but this is explicitly treated as a fallback and not the recommended China-stable path.
- The frontend will include heavier wallet dependencies for the fallback path: RainbowKit, wagmi, viem, and Reown/WalletConnect configuration.
- A WalletConnect/Reown project ID is required through environment configuration.
- Full favorites behavior is out of scope for this spec and should be designed separately.
Rejected alternatives:
1. **RainbowKit/Reown for all wallets including TokenPocket**
- Rejected because it would make the China-stable TokenPocket flow depend on the relay that was already found unstable.
2. **TokenPocket-only QR login**
- Rejected because the desired product behavior now includes MetaMask and imToken QR login, even if those QR paths are less reliable in China.
3. **OKX Connect SDK**
- Rejected because OKX Wallet is not a target wallet for the current product requirement.
4. **MetaMask SDK separate integration**
- Rejected for now because RainbowKit/Reown gives a broader compatibility fallback for both MetaMask and imToken with one integration.
5. **Favorites in the same plan**
- Rejected because wallet login and user-bound favorites are separate subsystems. Favorites needs its own backend endpoints and product decisions.
## Design
### Architecture
The login feature should be split into small units:
1. **Wallet auth API client**
- Requests nonce.
- Verifies signed messages.
- Fetches current wallet session.
- Stores and clears JWT.
2. **Wallet session provider**
- Owns login state: loading, logged out, logged in, error.
- Exposes wallet address, shortened address, token status, login actions, logout action.
- Restores session through `/api/auth/wallet/me` when a token exists.
3. **Injected provider adapter**
- Uses `window.ethereum` when available.
- Requests accounts.
- Signs nonce via `personal_sign`.
- Sends address/signature to backend verify endpoint.
- Covers desktop extensions and wallet DApp browsers.
4. **TokenPocket QR adapter**
- Creates a server-recognized `actionId` / login request.
- Gets or constructs a TokenPocket `tpoutside://pull.activity` login/sign deep link.
- Displays it as QR code.
- Frontend polls backend for callback result.
- Once backend has the address/signature result, frontend finalizes login through normal wallet verification.
- This is the recommended QR path for China users.
5. **RainbowKit QR fallback adapter**
- Configures RainbowKit/wagmi/WalletConnect for MetaMask and imToken QR login.
- Uses `VITE_WALLETCONNECT_PROJECT_ID` for the Reown project ID.
- After wallet connection, requests a backend nonce and asks the connected wallet to sign the nonce.
- Sends address/signature to `/api/auth/wallet/verify` and stores the returned JWT.
- UI copy must label this as a fallback that may be unstable on some China networks.
6. **Wallet deep-link helper**
- Provides buttons for TokenPocket, MetaMask, and imToken.
- Opens the current URL in the selected wallet's DApp browser when no injected provider is available or when the user chooses the DApp-browser path.
7. **Wallet login modal**
- Shows wallet options.
- Shows TokenPocket QR as the recommended scan option.
- Shows MetaMask/imToken QR via RainbowKit as an alternate scan option.
- Shows device-specific copy:
- Desktop TokenPocket QR: "Use TokenPocket on your phone to scan this QR code."
- Mobile TokenPocket QR: "Use TokenPocket on another device to scan this QR code."
- RainbowKit fallback: "MetaMask / imToken QR uses WalletConnect/Reown and may be unstable on some networks. If it fails, open this site inside your wallet app."
### User Flow: Injected Login
1. User clicks Connect Wallet.
2. Frontend detects `window.ethereum`.
3. Frontend requests wallet accounts.
4. Frontend requests nonce from backend.
5. User signs nonce through wallet.
6. Frontend sends address and signature to backend verify endpoint.
7. Backend verifies signature and returns JWT.
8. Frontend stores JWT and updates UI to shortened address.
### User Flow: TokenPocket QR Login
1. User opens login modal and chooses TokenPocket QR.
2. Frontend creates a TokenPocket QR login request with a unique `actionId`.
3. Frontend displays a QR code for TokenPocket.
4. User scans QR with TokenPocket on another device.
5. TokenPocket asks user to sign the login message.
6. TokenPocket sends result to backend callback URL.
7. Frontend polls backend for `actionId` result.
8. Frontend receives address/signature result and completes verify flow.
9. Frontend stores JWT and updates UI.
### User Flow: MetaMask / imToken QR Fallback
1. User chooses MetaMask/imToken QR login.
2. Frontend opens RainbowKit's connection flow with WalletConnect/Reown configured.
3. User scans the QR using MetaMask or imToken.
4. WalletConnect/Reown establishes the session.
5. Frontend requests a backend nonce.
6. User signs the nonce through the connected wallet.
7. Frontend sends address/signature to `/api/auth/wallet/verify`.
8. Frontend stores JWT and updates UI.
9. If connection fails or times out, UI recommends TokenPocket QR or opening the site inside the wallet DApp browser.
### User Flow: MetaMask / imToken Deep Link
1. User clicks MetaMask or imToken button.
2. If injected provider exists, use injected login.
3. If no injected provider exists, open current site URL in the selected wallet's DApp browser using that wallet's deep-link/universal-link format.
4. The login completes inside the wallet DApp browser using injected login.
### Logged-In UI
- Desktop/header should show shortened address such as `0x12...ab34`.
- Clicking the address opens a small menu with Disconnect.
- Disconnect clears the local JWT/session and returns UI to logged-out state.
- No ENS lookup is required.
- No remote avatar lookup is required.
### Backend API Contract
Existing wallet auth endpoints should remain the canonical verification path:
```http
POST /api/auth/wallet/nonce
POST /api/auth/wallet/verify
GET /api/auth/wallet/me
```
The frontend needs exact request/response contracts confirmed before implementation. Expected shape:
```http
POST /api/auth/wallet/nonce
Content-Type: application/json
{ "address": "0x..." }
```
```json
{ "nonce": "message to sign" }
```
```http
POST /api/auth/wallet/verify
Content-Type: application/json
{ "address": "0x...", "signature": "0x..." }
```
```json
{ "token": "jwt", "address": "0x..." }
```
For TokenPocket QR login, backend needs additional endpoints or equivalent behavior:
```http
POST /api/auth/wallet/tp-login-request
```
Creates a short-lived login request and returns data needed to render the QR.
Expected output:
```json
{
"actionId": "unique-id",
"message": "message to sign",
"qrUrl": "tpoutside://pull.activity?param=...",
"expiresAt": "ISO timestamp"
}
```
```http
POST /api/auth/wallet/tp-callback
```
Called by TokenPocket after user signs. Backend validates the callback payload shape, stores the result by `actionId`, and expires it quickly.
```http
GET /api/auth/wallet/tp-result?actionId=...
```
Frontend polls this endpoint until result is pending, completed, expired, or failed.
Expected states:
```json
{ "status": "pending" }
```
```json
{
"status": "completed",
"address": "0x...",
"signature": "0x..."
}
```
```json
{ "status": "expired" }
```
The final JWT should still come from `/api/auth/wallet/verify` so all wallet-login paths share one verification endpoint.
RainbowKit/Reown QR fallback does not require new backend endpoints beyond the canonical nonce/verify/me endpoints, but it does require frontend environment configuration:
```env
VITE_WALLETCONNECT_PROJECT_ID=...
```
### Error Handling
- No wallet detected: show wallet choices, TokenPocket QR login, and RainbowKit QR fallback.
- User rejects signature: show a clear retryable error.
- Nonce expired: request a fresh nonce and retry.
- TokenPocket QR expired: generate a new QR.
- TokenPocket callback never arrives: show timeout and retry option.
- RainbowKit/Reown connection fails: explain that scan login may be blocked or slow on this network; recommend TokenPocket QR or wallet DApp browser.
- Invalid signature: show login failed and do not store token.
- `/me` fails with expired/invalid token: clear token and return to logged-out state.
### Testing
Frontend testing should cover:
- Session provider restores logged-in state when `/me` succeeds.
- Session provider clears state when `/me` fails.
- Injected adapter signs and verifies through mocked provider/API.
- TokenPocket QR polling handles pending, completed, expired, failed, and timeout states.
- RainbowKit fallback handles connected, rejected, timeout/failure, and signed-message states.
- Login modal copy correctly distinguishes TokenPocket QR from RainbowKit/Reown fallback QR.
- Logout clears token and resets UI.
Backend testing should cover:
- TokenPocket login request creates short-lived action IDs.
- Callback stores exactly one completed result per action ID.
- Expired action IDs cannot be completed.
- Polling endpoint returns correct states.
- Verify endpoint still validates signatures and returns JWT.
## Implementation Checklist
- [ ] Confirm exact existing wallet auth request/response shapes with backend.
- [ ] Confirm TokenPocket callback payload fields from official docs or a sandbox callback test.
- [ ] Confirm WalletConnect/Reown project ID ownership and add `VITE_WALLETCONNECT_PROJECT_ID` to env docs.
- [ ] Add backend TokenPocket login request endpoint.
- [ ] Add backend TokenPocket callback endpoint.
- [ ] Add backend TokenPocket polling/result endpoint.
- [ ] Add frontend install plan for RainbowKit, wagmi, viem, and required query provider dependency.
- [ ] Add frontend wallet auth API client and token storage helpers.
- [ ] Add frontend wallet session provider/hook.
- [ ] Add injected provider login adapter.
- [ ] Add TokenPocket QR login adapter and polling flow.
- [ ] Add RainbowKit/Reown QR fallback for MetaMask and imToken.
- [ ] Add wallet deep-link helpers for TokenPocket, MetaMask, and imToken.
- [ ] Add wallet login modal and header logged-in state UI.
- [ ] Wire logout to clear token and session state.
- [ ] Add frontend tests for session, injected login, TP QR polling, RainbowKit fallback, and logout.
- [ ] Add backend tests for TokenPocket request/callback/result behavior.
- [ ] Document the backend API contract for Louis/backend implementation.
## Open Questions
1. What exact message format should users sign? It should include domain, wallet address, nonce, issued-at time, and purpose such as "Sign in to ARK Library".
2. Should JWT remain 30 days, or should backend add refresh tokens later?
3. What exact TokenPocket callback payload will be received for EVM personal-sign login?
4. Which public icon URL should TokenPocket QR metadata use for ARK Library?
5. Should login UI appear only in the header and favorites flow, or also in mobile menu?
6. Should favorites trigger wallet login immediately when clicked, or should that be decided in the separate favorites design?
7. Which Reown/WalletConnect project ID should production use, and who owns that Reown project?
8. Should RainbowKit fallback be hidden or visually de-emphasized for China users, or simply shown with warning copy?
## Out of Scope
- Full user-bound favorites implementation.
- Favorites page real list UI.
- Favorites database schema.
- OKX Connect SDK.
- MetaMask SDK separate integration.
- ENS names, ENS avatars, or chain data reads.
- On-chain transactions.

View File

@@ -0,0 +1,504 @@
---
title: "User Favorites"
type: brainstorm
date: 2026-06-01
---
# User Favorites
## Problem Statement
ARK Library needs a real user-level favorites feature tied to wallet login. Users should be able to save resources for later, see their own saved resources on `/favorites`, and use favorites as a personal library rather than just incrementing a public counter.
The current implementation only has a public `POST /api/resources/{id}/favorite` counter endpoint. It does not know who favorited a resource, does not prevent duplicate favorites, and cannot power a "My Favorites" page. The frontend `/favorites` page is currently a placeholder.
This feature should support user-bound favorites while preserving existing popularity/favorite count behavior for rankings and admin metrics.
## Context
Existing backend/frontend facts:
- Backend `resources.favorite_count` exists and is used in popularity ordering/admin stats.
- Backend currently exposes `POST /api/resources/{id}/favorite` with `{ add: true/false }`, but it is unauthenticated and only changes a global counter.
- Backend wallet auth exists through `/api/auth/wallet/nonce`, `/api/auth/wallet/verify`, and `/api/auth/wallet/me`.
- Wallet login is being designed separately in `2026-06-01-china-friendly-wallet-login-design.md`.
- Frontend `src/pages/Favorites/index.tsx` is a "Coming Soon" page.
- Resource list endpoints return paginated public resources and support filters such as `q`, `category`, and `sort`.
Product decisions from brainstorming:
- Favorites are user-level and keyed by wallet address.
- Favorite target is only `resources.id`, not posts, collections, or arbitrary entities.
- Favorite buttons appear both on resource cards/lists and resource detail/post pages.
- If an unauthenticated user clicks favorite, the wallet login modal opens; after successful login, the original favorite action completes automatically.
- Favorites page supports sortable, filterable, searchable favorites.
- Sort options: favorited time, resource published time, and hot/popular.
- Favorites page supports category filter and keyword search.
- If a favorited resource later becomes unavailable, the favorites page still shows it as unavailable and lets the user remove it.
- Existing favorite counts should be preserved as historical heat rather than reset.
## Chosen Approach
Use **user favorites + batch favorite state + favorites-page query API**.
Backend adds an authenticated `user_favorites` table and `/api/me/favorites` endpoints. Frontend adds a shared favorite state layer, reusable favorite button, batch status lookup for lists, and a real `/favorites` page.
The old unauthenticated favorite counter endpoint should be deprecated or changed so public users cannot freely mutate `favorite_count` without a wallet identity.
## Why This Approach
This approach balances user experience, backend clarity, and future extensibility.
Accepted trade-offs:
- A batch-state endpoint is added so list pages can show filled/unfilled hearts without N requests.
- Favorites page gets its own query API because it needs wallet scoping, sort, category filter, search, pagination, and unavailable-resource handling.
- Favorite counts remain materialized for ranking/admin performance, but backend must maintain them consistently when user favorites change.
- A pending favorite action must survive the wallet-login modal flow so users do not need to click favorite twice.
Rejected alternatives:
1. **Global counter only**
- Rejected because it cannot power "My Favorites" and can be spammed.
2. **Minimal add/remove/list only**
- Rejected because resource lists would not know current favorite state efficiently.
3. **Collections/folders**
- Rejected as out of scope. The current need is simple resource saving, not multi-folder organization.
4. **Polymorphic favorites (`target_type`, `target_id`)**
- Rejected because only `resources.id` is needed now. Simpler schema is easier to index and reason about.
5. **Reset all historical favorite counts**
- Rejected because current counts may already contribute to heat/ranking. Preserve them as historical base values.
## Design
### Backend Data Model
Add a wallet-scoped favorites table:
```sql
CREATE TABLE user_favorites (
wallet_address TEXT NOT NULL,
resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (wallet_address, resource_id)
);
CREATE INDEX idx_user_favorites_wallet_created
ON user_favorites (wallet_address, created_at DESC);
CREATE INDEX idx_user_favorites_resource
ON user_favorites (resource_id);
```
Wallet addresses should be stored in canonical checksum form if the backend already normalizes wallet auth to checksum addresses. Queries should compare using the same normalized representation.
To preserve historical favorites, add a base count field or equivalent migration strategy:
```sql
ALTER TABLE resources
ADD COLUMN favorite_base_count INT NOT NULL DEFAULT 0;
UPDATE resources SET favorite_base_count = favorite_count;
```
Then define the visible favorite count as:
```text
visibleFavoriteCount = favorite_base_count + count(user_favorites for resource)
```
Implementation options:
1. Keep `resources.favorite_count` materialized and update it on add/remove:
- Migration sets `favorite_base_count = favorite_count`.
- `favorite_count` starts as the existing historical value.
- Each new user favorite increments/decrements `favorite_count` exactly once.
- Fast reads, but backend must maintain consistency carefully.
2. Compute `favorite_base_count + COUNT(user_favorites)` in queries:
- Most accurate by construction.
- May require careful indexing or view/materialization for popular sorting.
Recommended: keep `resources.favorite_count` materialized for existing popularity/admin queries, but make all future changes go through authenticated user-favorite endpoints. Add a periodic or admin-only consistency check later if needed.
### Backend API Contract
All `/api/me/favorites*` endpoints require wallet JWT:
```http
Authorization: Bearer <wallet-jwt>
```
The backend identifies the wallet address from the JWT, not from request body.
#### List current user's favorites
```http
GET /api/me/favorites?sort=favorited_at&page=1&limit=24&category=project-ppt&q=ark&includeUnavailable=true
```
Query params:
| Param | Values | Default | Notes |
|---|---|---|---|
| `page` | positive integer | `1` | Page number |
| `limit` | `1..100` | `24` | Page size |
| `sort` | `favorited_at`, `published_at`, `hot` | `favorited_at` | `hot` uses popularity score |
| `category` | category slug | none | Optional category filter |
| `q` | text | none | Search title/description/tag/body as appropriate |
| `includeUnavailable` | `true`, `false` | `true` | Whether to include unpublished/private/deleted-like resources still referenced by favorites |
| `lang` | UI language code | optional | Category display language, matching existing resources endpoints |
Response:
```json
{
"items": [
{
"favoritedAt": "2026-06-01T12:00:00Z",
"resource": {
"id": "uuid",
"title": "ARK resource title",
"description": "...",
"type": "video",
"language": "zh-TW",
"categoryId": 1,
"categorySlug": "project-ppt",
"categoryName": "項目資料PPT",
"coverImage": "/uploads/cover.png",
"fileUrl": "/uploads/file.pdf",
"previewUrl": "/uploads/preview.mp4",
"externalUrl": null,
"isDownloadable": true,
"isRecommended": false,
"publishedAt": "2026-05-01T12:00:00Z",
"updatedAt": "2026-05-02T12:00:00Z",
"tags": ["官方推薦"],
"favoriteCount": 12,
"availability": "available"
}
}
],
"page": 1,
"limit": 24,
"total": 1
}
```
Unavailable resources should return enough metadata for the favorites page to show the item and allow removal. Suggested shape:
```json
{
"favoritedAt": "2026-06-01T12:00:00Z",
"resource": {
"id": "uuid",
"title": "Previously favorited resource",
"categoryName": "...",
"updatedAt": "2026-05-02T12:00:00Z",
"favoriteCount": 12,
"availability": "unavailable",
"unavailableReason": "unpublished"
}
}
```
For `sort=hot`, use the same general popularity concept as existing popular resources, for example:
```sql
(download_count + favorite_count + share_count) DESC, updated_at DESC
```
For `sort=published_at`:
```sql
published_at DESC NULLS LAST, updated_at DESC
```
For `sort=favorited_at`:
```sql
user_favorites.created_at DESC
```
#### Batch favorite status
```http
GET /api/me/favorites/ids?resourceIds=id1,id2,id3
```
Returns which of the provided resource IDs are favorited by the authenticated wallet.
Response:
```json
{
"ids": ["id1", "id3"]
}
```
Rules:
- `resourceIds` may be comma-separated.
- Backend should cap number of IDs, e.g. max 100.
- Unknown IDs are ignored.
- Requires wallet JWT.
#### Add favorite
```http
POST /api/me/favorites/{resourceId}
```
Response:
```json
{
"ok": true,
"resourceId": "uuid",
"favorited": true,
"favoritedAt": "2026-06-01T12:00:00Z",
"favoriteCount": 13
}
```
Rules:
- Requires wallet JWT.
- Idempotent: if already favorited, return success without double incrementing count.
- Should allow favoriting only existing resources.
- Product preference: favoriting unavailable/private resources from public UI should not normally happen; backend may reject unavailable resources for new favorites with `404` or `409`.
#### Remove favorite
```http
DELETE /api/me/favorites/{resourceId}
```
Response:
```json
{
"ok": true,
"resourceId": "uuid",
"favorited": false,
"favoriteCount": 12
}
```
Rules:
- Requires wallet JWT.
- Idempotent: if not favorited, return success without decrementing count.
- If resource is unavailable but favorite row exists, removal should still work.
#### Legacy counter endpoint
Existing endpoint:
```http
POST /api/resources/{id}/favorite
```
Should be deprecated for public use. Options:
1. Return `410 Gone` or `405 Method Not Allowed` once the new feature ships.
2. Keep it temporarily but route authenticated requests to `POST/DELETE /api/me/favorites/{resourceId}` semantics.
3. Keep only for backwards compatibility during deploy, then remove from docs.
Recommended: deprecate it in docs and stop frontend usage. Do not allow unauthenticated clients to mutate user-visible favorite counts.
### Frontend Components and State
Add a shared favorites layer:
1. **Favorites API client**
- `listFavorites(params, token)`
- `getFavoriteIds(resourceIds, token)`
- `addFavorite(resourceId, token)`
- `removeFavorite(resourceId, token)`
2. **Favorites state/provider or hook**
- Tracks favorite IDs for currently visible resources.
- Provides `isFavorite(resourceId)`.
- Provides `toggleFavorite(resourceId)`.
- Handles pending actions while wallet login is in progress.
- Clears state on wallet logout.
3. **FavoriteButton**
- Reusable heart button for cards and detail pages.
- Supports states: idle, favorited, loading, disabled/unavailable.
- Has localized accessible labels:
- Add to favorites
- Remove from favorites
- Login to favorite
4. **Favorites page**
- Replaces Coming Soon placeholder.
- Shows list/grid of favorited resources.
- Supports sort tabs/dropdown: favorited time, published time, hot.
- Supports category filter.
- Supports search input scoped to current user's favorites.
- Shows empty states:
- Not logged in: prompt to connect wallet.
- Logged in but no favorites: prompt to browse resources.
- Filter/search no results: prompt to clear filters.
- Shows unavailable items with clear badge and remove action.
### Frontend Data Flow
#### Resource list pages
```text
Resource list endpoint returns items
If wallet logged in, call /api/me/favorites/ids with visible resource IDs
FavoriteButton receives favorited state
User toggles favorite
Optimistically update UI
POST/DELETE backend
On success, reconcile favoriteCount if returned
On failure, rollback and show error
```
#### Unauthenticated favorite click
```text
User clicks FavoriteButton while logged out
Store pending action: { type: "favorite", resourceId }
Open wallet login modal
Wallet login succeeds
Run pending favorite action with new token
Update button state and count
```
If login is cancelled, the pending action is cleared and no favorite is added.
#### Favorites page
```text
User opens /favorites
If logged out, show login prompt
If logged in, call /api/me/favorites with sort/filter/search/page
Render resources with favorited=true
Removing an item updates list immediately
```
### Localization
New UI copy must be added to all supported locale files:
- `zh-CN`
- `en`
- `ko`
- `ja`
- `vi`
- `id`
- `ms`
Suggested keys:
- `favoriteAdd`
- `favoriteRemove`
- `favoriteLoginRequired`
- `favoriteAdded`
- `favoriteRemoved`
- `favoritesEmptyTitle`
- `favoritesEmptyDesc`
- `favoritesFilterAllCategories`
- `favoritesSortFavoritedAt`
- `favoritesSortPublishedAt`
- `favoritesSortHot`
- `favoritesSearchPlaceholder`
- `favoritesUnavailable`
- `favoritesClearFilters`
### Error Handling
- `401` from favorites API: clear wallet session or prompt re-login.
- `404` add favorite: resource no longer available; show message and refresh list.
- Network error during toggle: rollback optimistic state and show retryable error.
- Login cancelled after favorite click: do nothing and keep resource un-favorited.
- Batch favorite IDs fails on list pages: leave buttons unfilled but clickable; clicking can still prompt login or retry.
- Remove unavailable favorite fails: keep item visible and show retryable error.
### Testing
Frontend tests should cover:
- FavoriteButton renders add/remove/loading states.
- Unauthenticated click opens wallet login and completes pending favorite after login.
- Toggle favorite performs optimistic update and rollback on error.
- Batch favorite IDs marks visible resources correctly.
- Favorites page handles logged-out, empty, results, unavailable, filtered, and error states.
- Logout clears favorite state.
Backend tests should cover:
- Add favorite creates exactly one row and increments count once.
- Re-adding existing favorite is idempotent and does not double-count.
- Remove favorite deletes row and decrements count once.
- Removing missing favorite is idempotent and does not decrement.
- Batch IDs returns only IDs favorited by the current wallet.
- Favorites list respects wallet scoping, sort, category, search, pagination, and includeUnavailable.
- Legacy public counter endpoint no longer allows unauthenticated count manipulation.
## Implementation Checklist
- [ ] Confirm backend wallet JWT middleware can protect `/api/me/*` routes.
- [ ] Add backend migration for `user_favorites` and favorite count preservation.
- [ ] Decide exact count maintenance strategy: materialized `resources.favorite_count` vs computed count.
- [ ] Add `GET /api/me/favorites` with sort/filter/search/pagination/unavailable support.
- [ ] Add `GET /api/me/favorites/ids` batch status endpoint.
- [ ] Add `POST /api/me/favorites/{resourceId}` idempotent add endpoint.
- [ ] Add `DELETE /api/me/favorites/{resourceId}` idempotent remove endpoint.
- [ ] Deprecate or disable unauthenticated `POST /api/resources/{id}/favorite`.
- [ ] Update backend API docs for favorites and legacy endpoint behavior.
- [ ] Add frontend favorites API client.
- [ ] Add frontend favorites state/hook with pending post-login action support.
- [ ] Add reusable `FavoriteButton` component.
- [ ] Add favorite buttons to resource cards/list components.
- [ ] Add favorite button to detail/post page UI.
- [ ] Replace `/favorites` Coming Soon page with real favorites list UI.
- [ ] Add sorting, category filter, and scoped search to favorites page.
- [ ] Add unavailable-resource display and remove action.
- [ ] Add localized copy for all supported languages.
- [ ] Add frontend tests for favorite button, pending login action, batch state, and favorites page states.
- [ ] Add backend tests for add/remove/list/batch/count/deprecated endpoint behavior.
## Open Questions
1. Should unavailable resources expose title/category only, or also old cover/description if still present in the database?
2. Should newly adding a favorite be allowed for draft/private resources if a logged-in user somehow knows the ID? Recommendation: no.
3. Should favorite counts update immediately in all visible lists after toggle, or only the clicked card? Recommendation: clicked card immediately; other instances can update when state is shared.
4. Should wallet address casing be stored as checksum exactly or lowercase canonical form? It must match wallet auth claims consistently.
5. Should the legacy public favorite endpoint be removed immediately or kept temporarily during deploy for backwards compatibility?
## Out of Scope
- Wallet login implementation details.
- TokenPocket/RainbowKit login flows.
- Collections/folders for favorites.
- Sharing favorites publicly.
- Admin editing of user favorites.
- Import/export of favorites.
- Notifications when favorited resources update.
- Translating backend-returned resource content.

View File

@@ -6,7 +6,7 @@ This file is the first-stop context for AI coding agents working in this repo.
- Project: Arkie Library Frontend / ARK database web UI.
- Package name: `ark-database-web`.
- Stack: React 18, TypeScript, Vite, Tailwind CSS, React Router, RainbowKit/Wagmi.
- Stack: React 18, TypeScript, Vite, Tailwind CSS, React Router.
- Backend API is expected at `/api`; uploaded assets under `/uploads`.
## Branch rules

View File

@@ -1,11 +1,9 @@
FROM node:20-alpine AS build
WORKDIR /app
ARG VITE_WALLETCONNECT_PROJECT_ID=
ARG VITE_API_URL=
ARG VITE_ADMIN_UI_PREFIX=
ARG VITE_ADMIN_ONLY=
ARG VITE_DISABLE_ADMIN=
ENV VITE_WALLETCONNECT_PROJECT_ID=$VITE_WALLETCONNECT_PROJECT_ID
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_ADMIN_UI_PREFIX=$VITE_ADMIN_UI_PREFIX
ENV VITE_ADMIN_ONLY=$VITE_ADMIN_ONLY

View File

@@ -1,6 +1,6 @@
# Arkie Library Frontend
React + Vite frontend for the ARK Library / ARK database site. The app serves public resource browsing, search, favorites, wallet login UI, and an optional admin UI for resource management.
React + Vite frontend for the ARK Library / ARK database site. The app serves public resource browsing, search, favorites, and an optional admin UI for resource management.
## Tech stack
@@ -8,7 +8,6 @@ React + Vite frontend for the ARK Library / ARK database site. The app serves pu
- Vite 5
- React Router
- Tailwind CSS
- RainbowKit / Wagmi / Viem for wallet connection
- Gitea Actions deploy workflow on `main`
## Quick start
@@ -52,12 +51,17 @@ npm test
Create a local `.env` only when needed. Do not commit secrets. See `.env.example` for a template.
| Variable | Purpose |
| --- | --- |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `VITE_API_URL` | API/upload origin. Empty means same-origin and Vite dev proxy handles local `/api` and `/uploads`. Production deploy currently uses `https://api.ark-library.com`. |
| `VITE_WALLETCONNECT_PROJECT_ID` | Reown / WalletConnect project id. Needed for QR/mobile wallet connection. |
| `VITE_DISABLE_ADMIN` | When set to `"true"`, public build redirects admin routes away. Production public deploy sets this to `"true"`. |
| `VITE_ADMIN_ONLY` | When set to `"true"`, builds the admin-only app entry instead of the public app. |
| `VITE_ADMIN_UI_PREFIX` | Optional admin UI base path. If absent in admin-only mode, code uses the secret prefix from `src/adminPaths.ts`. |
| `VITE_USE_MOCK_POSTS` | Telegram-style resource stream (`/browse`, `/category/:slug`) uses mock posts from `src/mocks/mockPosts.ts` only when set to `"true"`. Leave unset or set to `"false"` to hit the real `/api/posts` API. See `.unipi/docs/specs/2026-05-25-posts-api-contract.md`. |
| `VITE_WALLETCONNECT_PROJECT_ID` | Reown/WalletConnect project ID used by the RainbowKit QR fallback for MetaMask/imToken. TokenPocket QR login does not use this. Required before testing or deploying the fallback scan flow. |
## Wallet login notes
Wallet login is used to attach a wallet address to user favorites. The frontend connects an injected wallet (`window.ethereum`), sends the selected address to `POST /api/auth/wallet/login`, and stores the returned wallet JWT in `localStorage` as a simple MVP session mechanism. This keeps the implementation small, but any future XSS vulnerability could expose a wallet session. A more secure future iteration should move wallet sessions to backend-set `httpOnly` cookies or shorten the token lifetime with refresh-token support.
## Project layout
@@ -67,7 +71,7 @@ src/
App.tsx # public app + optional admin routes
AppAdminOnly.tsx # admin-only app entry
api.ts # fetch helpers and shared API types
i18n.tsx # zh-TW / zh-CN / en copy dictionary
i18n.tsx # zh-CN / en / ja / ko / vi / id / ms dictionary
adminPaths.ts # admin UI prefix logic
adminRouteTree.tsx # admin routes
components/ # reusable public components

View File

@@ -0,0 +1,105 @@
# Shared SPA locations. Browser calls same-origin /apnew/api/ (VITE_API_PREFIX=/apnew).
# /apnew/api/ avoids ALB listener rule that sends /api/* to an unreachable backend target group.
# Nginx proxies internally to ark-library-backend-1 (Tailscale); Host header for backend TLS.
# Legacy /api/ locations are commented below for reference only.
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_types text/plain text/css application/javascript application/json application/xml image/svg+xml;
gzip_min_length 256;
location ^~ /apnew/api/admin {
return 404;
}
# Legacy same-origin /api admin block. Disabled while production uses /apnew/api.
# location ^~ /api/admin {
# return 404;
# }
location ^~ /admin {
return 404;
}
location ^~ /apnew/api/ {
proxy_pass https://100.93.205.19/api/;
proxy_http_version 1.1;
proxy_ssl_server_name on;
proxy_set_header Host api.ark-library.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 512m;
client_body_timeout 600s;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
# Legacy same-origin /api proxy. Disabled while production uses /apnew/api.
# location ^~ /api/ {
# proxy_pass https://100.93.205.19/api/;
# proxy_http_version 1.1;
# proxy_ssl_server_name on;
# proxy_set_header Host api.ark-library.com;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# client_max_body_size 512m;
# client_body_timeout 600s;
# proxy_read_timeout 600s;
# proxy_send_timeout 600s;
# }
location ^~ /uploads/ {
proxy_pass https://100.93.205.19/uploads/;
proxy_http_version 1.1;
proxy_ssl_server_name on;
proxy_set_header Host api.ark-library.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /health {
default_type text/plain;
return 200 "ok\n";
}
location = /healthz {
default_type text/plain;
return 200 "ok\n";
}
location = /index.html {
try_files $uri =404;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
}
# Exact `/` so the HTML shell is never edge-cached without validators (avoids stale index.html → 404 on hashed /index-*.js or /assets/*).
location = / {
try_files /index.html =404;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
}
location = /assets/logo-primary.webp {
try_files $uri =404;
add_header Cache-Control "public, max-age=3600, stale-while-revalidate=86400" always;
}
location ^~ /assets/ark-library/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=86400, stale-while-revalidate=604800" always;
}
location ^~ /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# Hashed entry chunk at /index-[hash].js (Vite entryFileNames). Do not 308 to /assets — file lives here.
location ~* ^/index-[A-Za-z0-9_-]+\.(js|mjs)$ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location / {
try_files $uri $uri/ /index.html;
}

View File

@@ -0,0 +1,18 @@
# Native system nginx (not Docker). SPA root: /var/www/ark-library
# Snippet: /etc/nginx/snippets/ark-library-frontend.inc
# ALB terminates TLS; apex uses X-Forwarded-Proto so we do not 301-loop behind the LB.
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name ark-library.com www.ark-library.com;
if ($http_x_forwarded_proto != "https") {
return 301 https://$host$request_uri;
}
root /var/www/ark-library;
index index.html;
include /etc/nginx/snippets/ark-library-frontend.inc;
}

View File

@@ -0,0 +1,161 @@
# 后端需求与核对文档:钱包登录 + 收藏
> 面向后端(`Arkie-Library-Backend`)。
> 本文是在前端审计「登录 + 收藏」bug 与 UI 重设计时,对后端现有实现的逐条核对,以及由此产生的后端待办。
>
> **核心结论:后端目前几乎已经满足前端全部需求。本次重设计与 bug 修复基本是纯前端工作。后端真正需要新增的只有少量「可选项」,外加几处需要确认的契约。请不要把已完成的功能再派一遍。**
日期2026-06-022026-06-02 更新:新增 §0.5 部署阻塞)
相关分支:`terry-wallet-login`
---
## 0.5 🔴 关键阻塞:线上后端是旧版本,收藏与 TokenPocket 端点未部署
> 源码(`Arkie-Library-Backend`)里这些端点都有;但**当前线上 / dev 代理指向的后端**`https://ark-library.com/apnew`)是**旧构建**,下列路由全部 404。
> **前端已就绪,但收藏与 TokenPocket 扫码登录在生产上无法工作,直到后端把含这些路由的版本部署上线。** 这正是用户实测「收藏用不了、扫码登录用不了」的根因。
curl 实证(经 vite dev 代理打到线上后端2026-06-02
| 端点 | 方法 | 实测状态 | 结论 |
|---|---|---|---|
| `/api/auth/wallet/nonce` | POST | **200** | ✅ 已部署 |
| `/api/auth/wallet/verify` | POST | **400**(入参错误)| ✅ 已部署 |
| `/api/auth/wallet/me` | GET | **401**(需鉴权)| ✅ 已部署 |
| `/api/auth/wallet/tp-login-request` | POST | **404** | ❌ 未部署 |
| `/api/auth/wallet/tp-result` | GET | **404** | ❌ 未部署 |
| `/api/auth/wallet/tp-callback` | POST | **404** | ❌ 未部署 |
| `/api/me/favorites` | GET | **404**(应为 401| ❌ 未部署 |
| `/api/me/favorites/ids` | GET | **404** | ❌ 未部署 |
| `/api/me/favorites/{id}` | POST | **404** | ❌ 未部署 |
**后端动作(必做,按优先级最高):**
1. 部署包含 favorites`internal/handlers/favorites.go`+ TokenPocket`internal/handlers/wallet_tp.go`)路由的后端版本(`cmd/server/main.go` 已注册这些路由)。
2. 为 TokenPocket 登录设置 `PUBLIC_BASE_URL``buildTokenPocketSignURL` 需要它生成回调 URL否则 tp-login-request 会 500
3. 确保 `wallet_tp_login_requests` / `user_favorites` 等表已迁移(`EnsureWalletAuthSchema` 会建表)。
> 复测命令:`curl -s -o /dev/null -w "%{http_code}" -X POST https://ark-library.com/apnew/api/auth/wallet/tp-login-request -d '{}'` 应返回 200且 body 的 `qrUrl` 形如 `tpoutside://pull.activity?param=...`)。
---
## 0. 一句话给后端
> 钱包认证、TokenPocket 扫码、收藏列表/筛选/分页/可用性,**都已实现且符合前端契约**。
> 下面 §1 是「已完成、勿动」的核对§2 是「真正可能要后端做的事」§3 是「前端会改但与后端无关,别误接」。
---
## 1. 已实现并符合前端契约(✅ 无需改动)
逐条核对自后端源码(`internal/handlers/wallet_auth.go``wallet_tp.go``favorites.go``public.go``cmd/server/main.go`)。
### 1.1 钱包认证
| 端点 | 状态 | 说明 |
|---|---|---|
| `POST /api/auth/wallet/nonce` | ✅ | 返回 `{nonce, message}`message 含一次性码,写入 `wallet_auth_nonces`TTL 15 分钟 |
| `POST /api/auth/wallet/verify` | ✅ | EIP-191 `personal_sign` 验签恢复地址,签发 JWT |
| `GET /api/auth/wallet/me` | ✅ | Bearer JWT → `{wallet, role:"user"}` |
关键事实(对前端 bug 很重要):
- **验签完全链无关。** `recoverPersonalSign` 只做 EIP-191 文本哈希恢复,不校验任何 chainId。签名消息文案是
`"ARK Database — wallet sign-in … Sign this message to log in. No transaction or gas fee."`**不引用任何链**。
→ 因此前端登录时强制切到 BNB 链(`ensureBnbChain`)是**多余的**,删除它**不影响后端**。这是一项纯前端修复。
- JWTHS256**有效期 30 天**`SignUserWallet(..., 30*24h)`),无状态。
- nonce 用后即删,过期自动清理。
### 1.2 TokenPocket 扫码登录
| 端点 | 状态 |
|---|---|
| `POST /api/auth/wallet/tp-login-request` | ✅ 生成 actionId/nonce/message/qrUrl`wallet_tp_login_requests` |
| `POST /api/auth/wallet/tp-callback` | ✅ 钱包回调写入签名,校验 `callbackToken` |
| `GET /api/auth/wallet/tp-result?actionId=` | ✅ 轮询返回 `pending/completed/expired/failed` |
→ 前端把扫码从「手机端」挪到「桌面端」只是 UI 位置调整,**后端无需改动**。
### 1.3 收藏
| 端点 | 状态 | 支持的能力 |
|---|---|---|
| `GET /api/me/favorites` | ✅ | `q`title/description/body_text/tag ILIKE`category`(slug)、`sort`(`favorited_at`/`published_at`/`hot`)、`includeUnavailable`(默认 true)、`page`/`limit`(≤100)、返回 `total`、tags、`favoriteCount``availability` |
| `GET /api/me/favorites/ids?resourceIds=` | ✅ | 批量查询收藏状态 |
| `POST /api/me/favorites/{id}` | ✅ | 加收藏,返回 `{ok,resourceId,favorited,favoriteCount}` |
| `DELETE /api/me/favorites/{id}` | ✅ | 取消收藏,`favorite_count` 不低于 `favorite_base_count` |
关键事实:
- **下架资源可用性已支持。** `scanFavoriteItem` 会把 `status!='published' 或 is_public=false` 的资源标为
`availability:"unavailable"`,且默认 `includeUnavailable=true` 仍返回。→ 前端「不可用资源卡片」逻辑后端已就绪。
- `sort=hot` 定义 = `download_count + favorite_count + share_count` 降序。
- 鉴权失败统一返回 **401**
---
## 2. 真正可能需要后端做的事
按优先级。除 2.1 外多为**可选/按产品决定**。
### 2.1 【需确认】CORS 允许前端源 + Authorization 头
前端通过 `apiBase``/api/me/favorites`,并带 `Authorization: Bearer <jwt>`
若前端与 API 不同源,需确认 CORS 允许:
- 来源:前端正式域名(及预览/本地开发源)
- 方法:`GET, POST, DELETE`
- 请求头:`Authorization, Content-Type`
**动作**:确认现有 CORS 配置覆盖以上;若 `apiBase` 同源则可忽略。
### 2.2 【可选】服务端登出 / Token 失效
现状JWT 无状态,前端「断开连接」只清本地 localStorage旧 token 在 30 天内仍有效。
若产品需要「真正的远程登出 / 失效被盗 token」后端需引入二选一
- token 版本号(用户级 `token_version`,签发与校验时比对);或
- token 黑名单jti 撤销表)
**默认建议**:第一版**不做**,保持无状态。仅在有安全需求时再做。
### 2.3 【可选】缩短或可配置 JWT 有效期
现为固定 30 天。若希望更安全或可配置,可将 TTL 提为环境变量(如 `USER_JWT_TTL`)。
**默认建议**30 天对「只验证地址、无资产操作」的场景可接受,可暂不动。
### 2.4 【按产品决定】MetaMask / imToken 扫码兜底WalletConnect/Reown
如果前端最终保留 WalletConnect 扫码路径:**后端无需任何改动**——`/verify` 接受任何 `personal_sign` 签名,与连接方式无关。
此项列出只为说明「即便前端接了 WalletConnect也不产生后端工作」。
### 2.5 【可选打磨】收藏列表 `q` 搜索性能
当前 `q` 用多列 `ILIKE '%..%'`,数据量大时无法走索引。量级变大后可考虑 `pg_trgm` 或全文索引。
**默认建议**:当前数据量下不必做,记录备查。
---
## 3. 前端会改、但与后端无关(请勿误派给后端)
这些是本次 bug/重设计的主体,**全部在前端完成,不涉及后端**
1. 删除登录时强制切 BNB 链(`ensureBnbChain`)—— 验签链无关(见 §1.1)。
2. 桌面登录弹窗简化为「使用浏览器钱包登录」单一主操作;扫码降级为「其他方式」。
3. 扫码从手机端挪到桌面端。
4. 手机端「打开钱包 App」死路修复反馈、未安装兜底
5. 移除/收敛未被登录流程使用的 RainbowKit/WalletConnect 装配(纯前端依赖与体积问题)。
6. 全站「我的收藏」入口缺失(导航 / 手机菜单 / 钱包下拉加入口)。
7. 收藏按钮状态视觉、收藏页筛选区移动端密度、空/错误状态打磨。
8. token 过期时前端自动登出并引导重新登录(消费后端已返回的 401无需后端改
---
## 4. 给后端的「确认清单」
- [ ] §2.1 CORS 是否已允许前端源 + `Authorization` 头?(唯一可能的必做项)
- [ ] 是否需要 §2.2 服务端登出/撤销?(默认否)
- [ ] 是否需要 §2.3 可配置 JWT TTL默认否维持 30 天)
- [ ] 知悉§3 全部为前端工作,无需后端介入。

View File

@@ -0,0 +1,333 @@
# Backend fixes required for Wallet Login + Favorites production readiness
Date: 2026-06-04
Environment tested: `https://arkie-library-stag.com/apnew/api`
## Summary
Frontend has been updated to the new backend contract:
- Wallet login: `POST /api/auth/wallet/login` with `{ address }`
- Wallet session check: `GET /api/auth/wallet/me` with `Authorization: Bearer <token>`
- Favorites list/status: `GET /api/favorites` and `GET /api/favorites?ids=...`
- Favorite mutation: `POST /api/posts/{id}/favorite` with `{ add: true|false }`
Staging confirms the new login endpoint works, but favorite mutation currently accepts an invalid Bearer token. This must be fixed before production trust.
---
## Priority 0 — Fix favorite mutation authentication
### Current staging behavior
The following request currently returns `200 OK` even with an invalid token:
```bash
curl -i -X POST \
"https://arkie-library-stag.com/apnew/api/posts/8f4a571c-3477-4b05-91be-d85907048de5/favorite" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer invalid-token" \
--data '{"add":true}'
```
Observed response:
```http
HTTP 200
{"ok":true}
```
### Required behavior
`POST /api/posts/{id}/favorite` must require a valid wallet JWT.
Invalid, missing, expired, malformed, or unverifiable tokens must return:
```http
HTTP 401 Unauthorized
```
Recommended response body:
```json
{
"error": "unauthorized"
}
```
### Acceptance tests
#### Missing token
```bash
curl -i -X POST \
"https://arkie-library-stag.com/apnew/api/posts/{postId}/favorite" \
-H "Content-Type: application/json" \
--data '{"add":true}'
```
Expected:
```http
HTTP 401
```
#### Invalid token
```bash
curl -i -X POST \
"https://arkie-library-stag.com/apnew/api/posts/{postId}/favorite" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer invalid-token" \
--data '{"add":true}'
```
Expected:
```http
HTTP 401
```
#### Valid token
```bash
TOKEN=$(curl -s -X POST \
"https://arkie-library-stag.com/apnew/api/auth/wallet/login" \
-H "Content-Type: application/json" \
--data '{"address":"0x0000000000000000000000000000000000000001"}' \
| jq -r .token)
curl -i -X POST \
"https://arkie-library-stag.com/apnew/api/posts/{postId}/favorite" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
--data '{"add":true}'
```
Expected:
```http
HTTP 200
```
Response should include at least:
```json
{
"ok": true,
"favorited": true
}
```
Then cancel:
```bash
curl -i -X POST \
"https://arkie-library-stag.com/apnew/api/posts/{postId}/favorite" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
--data '{"add":false}'
```
Expected:
```json
{
"ok": true,
"favorited": false
}
```
---
## Priority 1 — Confirm wallet login security model
### Current contract
```http
POST /api/auth/wallet/login
Content-Type: application/json
{
"address": "0x..."
}
```
Response:
```json
{
"token": "<jwt>",
"wallet": "0x..."
}
```
This is what the frontend now uses.
### Production risk
This flow does not prove wallet ownership. Any client can submit any wallet address and receive a token for that address.
If wallet identity is only used for low-risk favorites, this may be acceptable as an MVP. If wallet identity will be used for user identity, permissions, membership, rewards, asset ownership, admin behavior, or anything security-sensitive, backend should require signature verification.
### Recommended secure production flow
If stronger security is required, backend should use nonce + signature:
1. `POST /api/auth/wallet/nonce` with `{ address }`
2. Backend returns a one-time message / nonce.
3. Frontend asks wallet to sign the message.
4. `POST /api/auth/wallet/verify` with `{ address, message, signature }`
5. Backend verifies recovered address equals requested address.
6. Backend issues JWT.
If backend decides to keep the simplified `{ address }` login, please explicitly confirm that this is an accepted production risk.
---
## Priority 2 — Normalize favorites response contract
Frontend currently supports the staging response shape, but the response must be made explicit and self-sufficient. The frontend renders favorites as plain strings and does not perform per-resource translation, slug-to-name lookup, category fetching, or localization fallback.
### `lang` semantics
`?lang=<ui-lang>` on `GET /api/favorites` is a **display resolution hint**, not a filter. It must NOT filter favorites by post language. A user who favorited Chinese and English posts must see both regardless of `lang`. `lang` only tells the backend which language to resolve display strings into.
**Current staging behavior is wrong**: sending `?lang=en` on staging returns zero items for users whose favorites are Chinese posts, and vice versa. Because of this, the frontend currently does NOT send `lang` on `GET /api/favorites`. Once the backend treats `lang` as a resolve hint instead of a filter, the frontend will send `lang` again so resolved strings come back in the user's UI language.
### Favorites list
```http
GET /api/favorites?lang=&limit=&page=&sort=&category=&q=
Authorization: Bearer <token>
```
Required production response:
```json
{
"items": [
{
"id": "...",
"title": "...",
"description": "...",
"type": "...",
"categoryId": 11,
"categorySlug": "official-assets",
"categoryName": "...",
"language": "...",
"sourceLanguage": "...",
"coverImage": "...",
"updatedAt": "...",
"publishedAt": "...",
"favoriteCount": 0,
"availability": "available"
}
],
"page": 1,
"limit": 24,
"total": 0
}
```
Fields that must be present and pre-resolved by the backend when `lang` is supplied:
- `title` — already in `lang`. If a translation does not exist, fall back to the post's source language.
- `description` — same rule as `title`.
- `categoryName` — localized category name for `lang`. Frontend must not look up categories by slug.
- `type` — a string the frontend can display directly. If you need both a raw type code and a label, add `typeLabel` and use that for display.
- `language` — a human-readable label for the post's source language, in `lang`. e.g. for `lang=zh-CN` a Chinese post returns `language: "中文"`. If you prefer to keep `language` as a code, add `languageLabel` and use it for display.
- `coverImage` — a usable image URL. The frontend will not fall back to attachment arrays.
- `updatedAt`, `publishedAt` — ISO timestamps.
- `favoriteCount` — optional but recommended.
- `availability``"available" | "unavailable"`.
`page`, `limit`, and `total` are needed for correct pagination.
The frontend must never need to: load `/api/categories`, parse `localizations` maps, walk `attachments`, or translate `type` / `language` codes for this page.
### Favorite status by ids
```http
GET /api/favorites?ids=id1,id2,id3
Authorization: Bearer <token>
```
Current staging response observed:
```json
{
"items": []
}
```
This works, but for frontend performance and clarity, recommended response is:
```json
{
"ids": ["id1", "id3"]
}
```
Meaning: only IDs that are already favorited by the current wallet user.
Frontend currently accepts both:
- `{ ids: string[] }`
- `{ items: Resource[] }`
But backend should document and standardize one shape.
---
## Priority 3 — Required status codes
Please standardize these responses:
| Case | Expected status |
| --- | --- |
| Missing Bearer token on protected endpoint | `401` |
| Invalid/expired Bearer token | `401` |
| Valid token but post ID does not exist | `404` |
| Invalid JSON body | `400` |
| Invalid `add` value | `400` |
| Successful favorite add/remove | `200` |
Protected endpoints:
- `GET /api/auth/wallet/me`
- `GET /api/favorites`
- `GET /api/favorites?ids=...`
- `POST /api/posts/{id}/favorite`
---
## Frontend compatibility notes
The frontend currently calls these staging paths through the same-origin prefix:
```txt
/apnew/api/auth/wallet/login
/apnew/api/auth/wallet/me
/apnew/api/favorites
/apnew/api/favorites?ids=...
/apnew/api/posts/{id}/favorite
```
In frontend source this is written as `/api/...`; staging build uses `VITE_API_PREFIX=/apnew`.
Please keep backend routes under `/api/...` behind the proxy.
---
## Final production checklist
Backend should confirm all of the following before production release:
- [ ] `POST /api/posts/{id}/favorite` rejects missing token with `401`.
- [ ] `POST /api/posts/{id}/favorite` rejects invalid token with `401`.
- [ ] `POST /api/posts/{id}/favorite` only changes favorites for the wallet from the validated JWT.
- [ ] `GET /api/favorites` requires a valid Bearer token.
- [ ] `GET /api/favorites?ids=...` requires a valid Bearer token, unless explicitly declared public/legacy.
- [ ] `GET /api/auth/wallet/me` validates token and returns the wallet address from the token.
- [ ] Backend explicitly confirms whether simplified `{ address }` login is acceptable for production, or switches to nonce/signature verification.

View File

@@ -0,0 +1,91 @@
# Cloudflare Cache Purge Manual
Use this when the deployed frontend has already updated on the servers, but the public site still shows an old version because Cloudflare or browser cache is serving stale files.
## Current API-token note
The current `ark-library.com` Cloudflare Zone ID is:
```text
ca1368486d3b0bf9f1066f2a2281dced
```
Cloudflare cache purge by API requires both a valid API token and this Zone ID. If API access is unavailable, use the manual dashboard method below.
> Security note: if a token was pasted into chat, terminal logs, or docs by mistake, revoke it in Cloudflare and create a new one.
## Manual purge in Cloudflare Dashboard
1. Open <https://dash.cloudflare.com/> and log in.
2. Go to **Websites**.
3. Select **ark-library.com**.
4. Go to **Caching****Configuration**.
5. Click **Purge Cache**.
6. Choose one of these:
- **Purge Everything**: safest when a deploy looks stale.
- **Custom Purge**: use when only specific pages/assets are stale.
7. For a stale frontend deploy, purge at least:
- `https://ark-library.com/`
- `https://ark-library.com/index.html`
8. Wait 1060 seconds, then hard-refresh the browser:
- macOS Chrome/Safari: `Cmd + Shift + R`
- Windows/Linux Chrome: `Ctrl + Shift + R`
## Find the Cloudflare Zone ID
1. Open <https://dash.cloudflare.com/>.
2. Select **ark-library.com**.
3. Open **Overview**.
4. In the right sidebar, find **API****Zone ID**.
5. Copy the Zone ID for API purge commands.
For this project, the known Zone ID is `ca1368486d3b0bf9f1066f2a2281dced`.
## API purge after you have Zone ID
Store the token in an environment variable instead of writing it directly into commands:
```bash
export CLOUDFLARE_API_TOKEN='replace-with-new-token'
export CLOUDFLARE_ZONE_ID='replace-with-zone-id'
```
Purge everything:
```bash
curl -sS -X POST \
"https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}'
```
Purge only the main frontend files:
```bash
curl -sS -X POST \
"https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{"files":["https://ark-library.com/","https://ark-library.com/index.html"]}'
```
Expected success response includes:
```json
{"success":true}
```
## Create a safer Cloudflare API token
1. In Cloudflare Dashboard, open **My Profile****API Tokens**.
2. Click **Create Token**.
3. Use **Custom token**.
4. Add permissions:
- `Zone``Cache Purge``Purge`
- `Zone``Zone``Read`
5. Set zone resources:
- `Include``Specific zone``ark-library.com`
6. Create the token, copy it once, and store it in a password manager or CI secret.
Do not commit Cloudflare tokens into this repository.

View File

@@ -49,6 +49,18 @@ The workflow expects these Gitea secrets:
## 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
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.

258
docs/link-preview.md Normal file
View File

@@ -0,0 +1,258 @@
# Link preview (`/api/link-preview`)
Telegram-style rich card for the **first URL** found in a post's text.
Front-end renders a single clickable card showing site name, title,
description, and a thumbnail; the data is fetched from a back-end proxy
that scrapes Open Graph / oEmbed / Twitter Card metadata once and caches
it.
> **Scope**: only the first link in the post text gets a preview, matching
> Telegram's behaviour. Any additional URLs in the same post still render
> as inline autolinks but do not get their own card.
## Why a back-end proxy
Browsers cannot fetch arbitrary cross-origin pages, so OG metadata must be
fetched server-side. A single proxy endpoint keeps secrets / outbound IPs on
the server and lets us cache so each URL is only scraped once for the whole
audience.
---
## Endpoint contract
```
GET /api/link-preview?url=<encoded-absolute-url>
```
| Query | Required | Notes |
| ----- | -------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `url` | yes | Absolute `http://` or `https://` URL. Must be `URI` encoded so query strings inside the target URL survive the round trip. |
### Success — `200 OK`
```json
{
"url": "https://app.safe.global/welcome",
"canonicalUrl": "https://app.safe.global/welcome",
"siteName": "app.safe.global",
"title": "Safe{Wallet}",
"description": "Safe{Wallet} is the most trusted smart account wallet on Ethereum with over $100B secured.",
"imageUrl": "https://app.safe.global/og.png",
"imageWidth": 1200,
"imageHeight": 630,
"favicon": "https://app.safe.global/favicon.ico",
"themeColor": "#12FF80",
"fetchedAt": "2026-05-29T10:00:00Z",
"cacheTtlSeconds": 86400
}
```
- All string fields except `url` may be empty. The front-end gracefully hides
rows that are missing (e.g. no `imageUrl` → image area is omitted).
- `url` echoes the original input so the client can match the response
against the URL it asked about, even if the request was racy.
- `canonicalUrl` is the URL the client should open when the card is tapped.
Defaults to `url` if no `<link rel=canonical>` was found.
### Already cached / freshly cached — same shape
The endpoint is idempotent and the response shape is identical whether
the metadata is hot, warm, or freshly scraped.
### Errors
| Status | When | Body shape |
| ------ | --------------------------------------------------- | --------------------------------------------------------------------------- |
| `400` | Missing / invalid / non-http(s) `url` | `{ "error": "invalid_url" }` |
| `422` | URL passed validation but resolves to a private/internal address (SSRF guard) | `{ "error": "blocked_target" }` |
| `404` | Target returned 404 or fetch produced no metadata | `{ "error": "not_found" }` |
| `408` | Target took longer than the timeout to respond | `{ "error": "timeout" }` |
| `502` | Target returned 5xx | `{ "error": "upstream_error" }` |
| `429` | Rate limit on this client / IP | `{ "error": "rate_limited", "retryAfter": 60 }` |
The front-end treats every non-`200` as “no preview available” and
silently renders nothing. No toasts. URLs already render as inline
clickable text via `autolink`, so the user is never blocked.
---
## Caching strategy
Store one row per `canonicalUrl` (or normalized `url` if `canonicalUrl` is
absent). Suggested TTLs:
- Successful preview: **24 hours** (`cacheTtlSeconds: 86400`).
- 404 / timeout / blocked: **6 hours** negative cache. Otherwise transient
failures on the target site will hammer the proxy.
- Send `Cache-Control: public, max-age=86400` so CDN / browser also cache.
Cache key normalization:
- Lowercase scheme + host.
- Strip the trailing slash on the path when it's the only character.
- Strip `utm_*`, `ref`, `referrer`, `fbclid`, `gclid` query params.
- Keep the rest of the query and fragment as-is.
---
## SSRF and abuse guard (must-have)
The proxy will fetch any URL the front-end asks about, which is dangerous.
Before issuing the outbound request:
1. Resolve the host to all of its A/AAAA records.
2. Reject if any resolved IP is in: loopback, link-local, private
(RFC1918), `0.0.0.0/8`, multicast, broadcast, or the internal cluster
CIDR.
3. Reject schemes other than `http` and `https`.
4. Cap response body at **5 MB**; abort on overflow.
5. Cap request total time at **5 s**; abort on timeout.
6. Cap redirect chain at **3 hops**; re-validate target IP at each hop.
7. Do not forward client cookies, auth headers, or `Referer` to the target.
8. Use a clear `User-Agent` such as `ArkLibraryLinkBot/1.0 (+https://ark-library.com/bot)`.
9. Per-client (IP or session) rate limit, e.g. 60 req / min.
---
## Metadata extraction precedence
For each field, pick the first present:
| Field | Sources (in order) |
| ------------- | -------------------------------------------------------------------------------------------------------- |
| `title` | `og:title``twitter:title``<title>` → empty |
| `description` | `og:description``twitter:description``<meta name="description">` → empty |
| `imageUrl` | `og:image:secure_url``og:image``twitter:image` → first prominent `<img>` (skip if &lt;200×200) → empty |
| `siteName` | `og:site_name``application-name` → hostname (sans `www.`) |
| `canonicalUrl`| `<link rel="canonical">` → request URL |
| `favicon` | `<link rel="icon">``<link rel="shortcut icon">``/favicon.ico` |
| `themeColor` | `<meta name="theme-color">` |
Resolve any relative URLs (`og:image`, `favicon`, `canonical`) against the
final response URL (after redirects).
---
## Provider quirks worth handling
These quirks save a lot of "why doesn't this site preview?" debugging later.
- **Twitter / X**: `x.com` and `twitter.com` strip OG when not signed in. Use
the public oEmbed endpoint
`https://publish.twitter.com/oembed?url=...&omit_script=1` for
Twitter/X URLs and map: `title = author_name`, `description = html` stripped
to text, `imageUrl = thumbnail_url` if available.
- **YouTube**: prefer `https://noembed.com/embed?url=...` or
`https://www.youtube.com/oembed?url=...&format=json` (no key).
- **Reddit / Mastodon**: standard OG works fine.
- **Sites behind Cloudflare bot challenge**: surface 502 to the client.
Don't retry hot — let the negative-cache TTL absorb it.
- **AMP pages**: prefer `og:url` when present so the cached entry points to
the canonical page, not the AMP variant.
---
## Front-end integration
### Type addition (`src/types/post.ts`)
```ts
export type LinkPreview = {
url: string;
canonicalUrl: string;
siteName: string;
title: string;
description: string;
imageUrl?: string;
imageWidth?: number;
imageHeight?: number;
favicon?: string;
themeColor?: string;
};
export type Post = {
// ...existing fields
/** Preview for the first URL in `text`. At most one per post. */
linkPreview?: LinkPreview;
};
```
### Which URL gets previewed
The back-end picks the **first** URL it finds in `text` using the same
regex as the front-end's `autolink` (`/(https?:\/\/[^\s<>"]+[^\s<>".,;:!?)\]}'])/i`).
Only that URL is fetched, stored, and returned as `post.linkPreview`. Any
later URLs in the same post are ignored for preview purposes (still
clickable inline via `autolink`).
### Where data comes from
Two viable paths — pick one when wiring the back-end.
1. **Inline on `Post`** (preferred): the post API enriches each post with
`linkPreview`. The first URL in `text` is resolved once at write time
(or lazily on first read with a background job). The client renders
without making any extra request.
2. **Client-side lookup**: the client extracts the first URL via the
existing `autolink` regex, calls `/api/link-preview?url=...` once per
post (with in-memory dedupe across posts that share the same URL), and
renders the card when the response comes back. Slower first paint but
keeps the posts endpoint cheap.
Recommend (1) for the public feed and keep `/api/link-preview` available for
(2) only on admin previews.
### Rendering
- New component: `src/components/messageStream/LinkPreviewCard.tsx`
- Renders a card with a left vertical 3px accent bar (`themeColor`
fallback `bg-ark-gold`).
- Layout:
```
┌──────────────────────────────────────────────────┐
│ ▍ siteName (12px, neutral-400) │
│ ▍ Title (15px, bold, neutral-100) │
│ ▍ Description (13px, neutral-300, 3-line clamp) │
│ ▍ ┌────────────────────────────────────────────┐ │
│ ▍ │ imageUrl (lazy, aspect-video, rounded) │ │
│ ▍ └────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
```
- Whole card is `<a href={canonicalUrl} target="_blank" rel="noopener noreferrer">`.
- Reuse the bubble background (`bg-[#272632]` is OK, slightly lift with
`bg-white/[0.03]` overlay so the card reads as inset within the bubble).
- Mount points (text-bearing bubbles only): `TextBubble`,
`ImageWithTextBubble`, `AlbumBubble`, `VideoBubble`, `FileDocBubble`.
Render below the existing `CollapsibleText` so cards stay visible even
when long text is collapsed.
### Picking the URL to preview
If `post.linkPreview` is present, render that single card. Otherwise the
bubble renders nothing extra (URLs still autolink inline). The front-end
never picks the URL itself — that decision lives on the back-end so the
client and server agree on which URL was chosen.
### Falling back gracefully
- No `imageUrl` → omit the image area, keep the text block.
- Title shorter than 8 characters → hide the description below (treat as
a low-confidence preview).
- Title empty and description empty → render nothing.
---
## Open questions for the back-end
- Where in the stack will OG extraction live? Existing post pipeline, a
worker queue, or inline on read?
- Storage: a new `link_previews` table keyed by `canonicalUrl`, with a
`post_link_previews` join table preserving original URL order, or just a
JSON column on `posts`?
- How aggressive should re-scrape be? E.g. re-scrape every 30 days for
successful previews, every 24 hours for `themeColor` updates.
- Should admin be able to override / hide a preview per post? Telegram has
a "no preview" toggle and editors often want it.
- Do we want a manual "refresh preview" button in the admin UI?

89
docs/posts-title-api.md Normal file
View File

@@ -0,0 +1,89 @@
# Posts title fields for list/card surfaces
Back-end should provide short title fields on `Post` responses so front-end can render concise list/card titles without using the full Telegram/body text.
## Product requirement
Any list/card surface that is meant for quick browsing should display a short `title`, not the full `text` body. Full Telegram-style body copy is too noisy for compact surfaces.
This includes:
- Home **官方推荐** carousel/cards
- Home **最新资料**
- Home **热门资料** ranking list
- `/official-recommendations`
- `/browse` (**全部资料**)
- `/category/:slug`
- Search result previews/lists
The full body text should remain available as `text` for message/detail rendering, expansion, and search indexing.
## Current front-end implementation status
Already consuming `title` through `postToResource` / `Resource.title`:
- Home **官方推荐** carousel/cards
- `/official-recommendations`
- Home **热门资料** ranking list
Pending front-end follow-up after the back-end fields are available:
- Home **最新资料**
- `/browse` (**全部资料**)
- `/category/:slug`
- Search results
Those pending surfaces currently render `text` via `MessageBubble`. Once the back-end consistently provides `title`, front-end can decide the final UI treatment, but the desired display content for compact browsing is the short `title`.
## Affected endpoints
Any endpoint returning `Post` items should include a short title when available:
- `GET /api/posts`
- `GET /api/posts/search`
- `GET /api/posts/recommended`
- `GET /api/posts/:id`
## Recommended response shape
```jsonc
{
"id": "string",
"title": "ARK 2026 共识加速计划", // optional global fallback title
"text": "完整正文 / Telegram-style body text...",
"localizations": {
"zh": {
"title": "ARK 2026 共识加速计划",
"text": "完整中文正文..."
},
"en": {
"title": "ARK 2026 Consensus Acceleration Plan",
"text": "Full English body..."
}
}
}
```
## Front-end fallback order
For resource card/list `Resource.title`, front-end reads:
1. `localizations[currentLang].title`
2. `post.title`
3. first non-empty line of localized/full `text`
4. first attachment filename
5. `post.id`
So backend can roll this out gradually: old posts without `title` still render, but resource card/list surfaces reduce long body text to its first non-empty line instead of displaying the full paragraph.
## Requirement
Do **not** put an entire body paragraph into `title`. `title` should be concise enough for a two-line card/list title.
Examples:
| Good title | Bad title |
| --- | --- |
| `ARK 2026「共识加速计划」邀请王霸榜` | Full event body with links, schedule, rules, and hashtags |
| `ARK 主网核心合约地址BSC链` | Full contract explainer paragraph |
| `ARK灵魂五问完整视频` | Full video caption text |

View File

@@ -0,0 +1,87 @@
# 搜索与标签接口说明(给后端)
前端搜索体验依赖后端两件事:①`/api/posts/search` 做**模糊搜索**;②新增 `/api/tags`
返回完整标签列表。下面是前端目前的调用方式与期望。
---
## 1. 模糊搜索:`GET /api/posts/search`
### 现状
- 前端在用户输入关键字或点击标签时,调用此接口,只负责把 `q` 传过去。
- **匹配方式(模糊 / 精确)完全由后端决定**,前端无法控制。
- 页面提示写明「支持搜索:标题 · 分类 · 标签 · 简介 · 文件类型 · 正文」,因此期望是
**跨这些字段的模糊匹配**。请确认当前实现;若为精确匹配(`= q`),需改为模糊。
### 查询参数(前端实际会带的)
| 参数 | 必填 | 说明 | 示例 |
|---|---|---|---|
| `q` | 是 | 搜索关键字(已 trim | `海报` |
| `lang` | 是 | 界面语言 | `zh-CN` / `en` |
| `limit` | 是 | 每页数量 | `20`(标签预览用 `12` |
| `cursor` | 否 | 分页游标(上一页返回的 `nextCursor` | |
| `category` | 否 | 分类 slug在分类页内搜索时 | `tutorial` |
| `type` | 否 | 资源类型过滤 | `image`/`video`/`music`/`pdf`/`ppt`/`text`/`link`/`archive` |
| `sort` | 否 | 排序 | `latest`/`popular`/`recommended` |
| `language` | 否 | 资料源语言过滤 | |
### 期望的匹配规则(模糊)
-`q` 做**部分匹配**`LIKE %q%` 或全文索引),**大小写不敏感**。
- 匹配字段:**标题、分类名、标签、简介、文件类型、正文**(与页面提示一致)。
- 中文建议用全文索引 / 分词(如 MySQL FULLTEXT、PostgreSQL `pg_trgm`/`tsvector`、或 ES
避免仅按整词精确匹配。
- 建议按**相关度排序**(命中标题 > 标签 > 正文…);无 `sort` 时默认相关度,有 `sort`
时按指定排序。
- (可选增强)错别字容错、拼音匹配。
### 返回结构(与 `/api/posts` 一致)
```jsonc
{
"items": [ /* Post[] */ ],
"nextCursor": "..." // 还有下一页时返回;没有则省略/为空
}
```
`Post` 关键字段:`id, categoryId, categorySlug, language, text?, attachments[],
isRecommended, publishedAt, updatedAt?, tags?: string[], postType?`
---
## 2. 标签列表:新增 `GET /api/tags`
### 现状(痛点)
- 「现有标签」目前是前端从**最新 80 条**帖子里现算出来的(取前 12 个高频标签),
**不完整、也不稳定**——更早的帖子里的标签不会出现。
### 期望
新增接口直接返回**全部标签 + 计数**,前端不再现算。
```
GET /api/tags?lang=zh-CN
```
| 参数 | 必填 | 说明 |
|---|---|---|
| `lang` | 是 | 界面语言(用于本地化标签名,若有) |
返回:
```jsonc
{
"tags": [
{ "name": "图片", "count": 128 },
{ "name": "教程", "count": 96 }
// 按 count 降序
]
}
```
-`count` 降序;前端会自行截取展示数量。
- 只统计**已发布 / 公开**的帖子。
---
## 验收要点
- [ ] `/api/posts/search?q=部分词` 能返回包含该词的结果(标题/标签/正文等任一命中),
大小写不敏感。
- [ ] 同一关键字在「搜索框」和「分类内搜索」表现一致。
- [ ] `/api/tags` 返回全量标签(不止最新 80 条里的)。
> 前端已就绪:搜索框/标签都走上面的参数;标签支持再次点击取消。后端按本文件落地后,
> 前端只需把「现有标签」数据源从现算切换到 `/api/tags`(小改动,待接口可用后进行)。

View File

@@ -0,0 +1,206 @@
# 社群常用资料(热门资料)区块 — 设计与后端接口文档
- 日期2026-05-29
- 作者:前端
- 状态:待评审
- 关联需求1.6 熱門資料區
---
## 1. 背景与问题
首页(第一页)的「热门资料」区块当前与「最新更新」区块**使用同一个组件**`LatestUpdateRow`,图标 + 文字行卡),两个区块视觉上几乎一模一样,用户无法区分。同时该区块:
- **没有封面**,不符合需求「展示封面」;
- 与官方推荐(大封面横滑)、最新更新(图标行卡)需要在首页形成**三种不同的版式**。
需求 1.6 明确:热门资料由后台数据自动排序,但**前端只展示资料、不展示具体数字**。
本文档定义两部分工作:
- **前端**:把该区块改造为「榜单」版式(封面 + 名次,无任何数字)。
- **后端**:提供/调整支撑该区块的接口(本文档的主要交付物,交付给后端团队实现)。
---
## 2. 范围
> **产品前提:第一版不做登录系统。** 凡是依赖用户身份的功能(收藏)一律顺延到 Phase 2。
### Phase 1本次
- 前端:首页区块改为榜单版式,卡片含**预览 + 下载**操作(两者都不依赖登录)。
- 后端:
- `GET /api/posts?sort=popular` 热度排序;
- 下载计数;
- 管理员推荐权重字段。
### Phase 2后续本文档仅登记不在本次实现
- 收藏功能(依赖登录 / 用户身份系统——第一版无登录,故顺延;当前 Favorites 页仍为 Coming Soon
- 分享计数;
- 独立的「热门资料」Tab / 列表页。
---
## 3. 命名
- 区块标题:**热门资料**zh-CN / **Popular assets**en
- 内部排序标识沿用 `sort=popular`,不改路由与参数。
> 备注:热度时间窗(累计 vs 本周)见 §7 开放问题。
---
## 4. 前端排版设计(前端负责)
### 4.1 组件
新增榜单组件(暂名 `PopularRankRow` / `PopularRankList`),替换首页 `popular` 区块当前的 `LatestUpdateRow` 网格(桌面)与 `MessageBubble` 列表(移动)。最多展示 **5 条**
数据来源不变:继续调用 `/api/posts?sort=popular`,经现有 `postToResource` 适配为前端资源对象。
### 4.2 单行结构(从左到右)
| 元素 | 说明 |
|---|---|
| 名次徽章 | 第 13 名 🥇🥈🥉;第 45 名灰色等宽序号(`tabular-nums` |
| 封面缩略图 | 取 `attachments[0]``thumbnailUrl` / `posterUrl`(复用现有 `coverFor` 逻辑);前 3 名加金色描边;无封面时回退分类图标 |
| 标题 | 两行截断(`line-clamp-2` |
| Meta 行 | `类型 · 分类 · 更新时间` |
| 操作 | **预览 + 下载**图标按钮(均不依赖登录;收藏依赖登录,见 Phase 2 |
### 4.3 交互
- 整行可点击,跳转 `/resource/:id`
- **预览**按钮:打开资料预览(图片 / 视频 / 文档),不触发下载;复用现有预览浮层逻辑。
- **下载**按钮:复用现有 `downloadAttachment(postId, attachmentId)` 逻辑。
- 预览 / 下载按钮均独立响应(`stopPropagation`),点击不冒泡触发整行跳转。
### 4.4 响应式
- 移动端:单列榜单。
- 桌面端:居中定宽单列(保留「榜单」语义,避免退化成网格而与其他区块再次撞脸),行内加宽留白。
### 4.5 铁律与状态
- **零数字(指数量)**:下载量 / 收藏量 / 分享量 / 热度分等任何**数量型计数一律不显示**(即 1.6 所指「下载 500 次」类数字)。
- **名次序号属于「排名」非「数量」**:榜单展示完整名次 15前 3 名 🥇🥈🥉45 灰色等宽序号)。名次表达相对排序、不暴露任何后台计数,与「避免显示数字」的意图不冲突。
- 加载态:骨架屏。
- 不足 5 条:沿用现有 `ComingSoon` 占位。
---
## 5. 后端接口契约(后端负责 · 核心交付)
### 5.1 热度排序 `GET /api/posts?sort=popular`
**请求参数**(沿用现有约定,无新增必填项):
```
GET /api/posts?sort=popular&lang=zh-CN&language=<sourceLang>&limit=5
```
**排序逻辑**:后端按热度分降序返回。
```
popularityScore =
w_download * downloadCount
+ w_favorite * favoriteCount
+ w_share * shareCount
+ adminWeight
```
- 建议初始权重(可配置):`w_download = 1.0``w_favorite = 2.0``w_share = 3.0``adminWeight` 直接相加。
- Phase 1`favoriteCount` / `shareCount` 暂为 0不影响公式正确性功能上线后自然生效。
- 同分回退顺序:`adminWeight` 降序 → `updatedAt` 降序。
**响应结构**:沿用现有 `PostListResponse`
```jsonc
{
"items": [ /* Post[] */ ],
"nextCursor": "..." // 可选
}
```
**❗硬性约束**`items` 中**不得包含**任何计数 / 分值字段(`downloadCount``favoriteCount``shareCount``popularityScore` 等)。这些仅用于后端排序,前端不需要也不允许展示。
**Post 必含字段**(前端 `postToResource` 依赖,缺失会导致封面 / 类型 / 标题渲染异常):
```jsonc
{
"id": "string",
"postType": "ppt | pdf | image | video | music | link | text | archive",
"categoryId": 0,
"categorySlug": "string",
"language": "string",
"attachments": [
{
"id": "string",
"kind": "image | video | document",
"url": "string",
"mime": "string",
"filename": "string",
"thumbnailUrl": "string?", // 封面优先取这里
"posterUrl": "string?" // 视频封面回退
}
],
"isRecommended": false,
"publishedAt": "ISO8601",
"updatedAt": "ISO8601",
"localizations": { /* */ },
"tags": ["string"]
}
```
**封面 / 缩略图(重要)**:前端封面取值顺序为 `attachments[0].thumbnailUrl``posterUrl`。后端应在 body JSON 中为每条资料提供可用的封面 / 缩略图:
- 图片:`thumbnailUrl`(压缩图);
- 视频:`posterUrl`(首帧 / 封面);
- 文档ppt/pdf 等):尽量提供后端生成的预览缩略图 `thumbnailUrl`;若暂时无法生成,前端会按**资料类型**渲染兜底封面(类型色渐变 + 类型图标),但**仍建议后端长期补齐文档缩略图**以获得最佳效果。
> 前端兜底仅为优雅降级;最终视觉效果依赖后端在 body 中提供真实封面 / 缩略图。
### 5.2 下载计数
下载行为发生时累加 `downloadCount`(排序输入)。
- 采集点:现有下载接口(前端通过 `downloadAttachment(postId, attachmentId)` 触发)。
- 实现建议:在现有下载端点内 `++downloadCount`,或提供 `POST /api/posts/:postId/attachments/:attachmentId/download` 返回文件并计数。
- 防刷建议(后端定夺):同一 IP / 设备在 N 分钟内对同一资源只计一次。
### 5.3 管理员推荐权重
- `Post` 增加字段 `adminWeight: number`(默认 `0`)。
- 后台资料编辑表单(`/api/admin/resources`)可设置该值,用于人工置顶热门。
### 5.4 Phase 2登记暂不实现
- **收藏**`POST` / `DELETE /api/posts/:id/favorite`,依赖登录 / 用户身份;累加 `favoriteCount`;提供收藏状态查询与收藏列表(支撑 Favorites 页)。
- **分享计数**:分享行为上报 `POST /api/posts/:id/share``++shareCount`
---
## 6. 后端内部字段(不对外暴露)
`downloadCount``favoriteCount``shareCount``adminWeight`,以及派生的 `popularityScore`。前端响应中**不返回**。
---
## 7. 开放问题(需后端 / 产品确认)
1. **下载接口的确切路径与契约**:前端 `downloadAttachment` 当前对应的后端端点是什么?计数挂在哪里?
2. **热度时间窗**:是「累计热门」还是「本周 / 近 30 天热门」?若要「本周精选」语义需按时间窗统计计数。当前命名「社群常用资料」默认**累计**。
3. **数量与分页**:首页固定 5 条Phase 2 独立热门页是否需要分页?
4. **防刷计数策略**的具体规则。
5. **预览实现**:图片 / 视频可直接用 `attachment.url` / `thumbnailUrl`文档类ppt/pdf在线预览是否已有渲染服务还是仅展示封面缩略图预览预计**不需要后端新接口**,待确认)
---
## 8. 验收标准
- `GET /api/posts?sort=popular` 按热度分降序返回,且响应中**无任何计数 / 分值字段**。
- 下载行为能累加 `downloadCount` 并反映到排序。
- 后台可设置 `adminWeight` 并影响排序。
- 前端首页区块呈现为榜单(含封面、名次、无数字),与「最新更新」「官方推荐」区块视觉区分明显。

View File

@@ -0,0 +1,103 @@
# 网站动画与 UX 提升 — 设计文件
**日期**: 2026-05-29
**目标**: 在**不改变 UI 布局和颜色**的前提下,为 Arkie Library 前端加入精致动画并提升使用者体验,效果与性能兼顾,并顺手清理冗余代码。
---
## 1. 背景与约束
- 技术栈React 18 + TypeScript + Vite + Tailwind CSS。
- 主题:深色 (`#141319`),金色重点 (`#eeb726` / `#ffd35c`)。
- 现有动画:`ark-page-fade-in`(页面淡入 240ms`ark-header-menu-enter`(菜单 clip-path 180ms皆已支援 `prefers-reduced-motion`
- **硬约束**
- 不改变任何布局、颜色、字体、间距。仅加入「动作 / 时间 / 反馈」。
- 全部动画必须尊重 `prefers-reduced-motion: reduce`
- 效果与性能兼顾库需按需加载、bundle 影响最小化。
## 2. 决策(已与用户确认)
| 项目 | 决定 |
|------|------|
| 动画强度 | 适中活泼moderate & lively克制但明显 |
| 范围 | 全站(首页、浏览、分类、分类详情、搜索、关于、收藏) |
| 实现方式 | **混合方案**`framer-motion`LazyMotion 精简模式)+ 纯 CSS/Tailwind |
| 冗余清理 | 实作中一并清理(如 `RecommendedCard` 内两边相同的三元运算) |
### 库分工
- **framer-motion (v11, `LazyMotion` + `m`)**:页面切换退场动画(`AnimatePresence`)、弹簧悬停质感、筛选/排序卡片重排(`layout`)、复杂 stagger 编排。
- **CSS / Tailwind**:骨架屏 shimmer、图片加载淡入、简单滚动出现无需 framer 的场景)。
## 3. 架构与组件
### 3.1 动画基础设施(一次建好,全站复用)
- `tailwind.config.js`:新增 `keyframes``shimmer``fade-in-up``scale-in`)、对应 `animation`,统一缓动 `cubic-bezier(0.22, 1, 0.36, 1)`
- `src/index.css`:新增可复用工具类与 reduced-motion 保护。
- `src/motion/` 新目录:
- `MotionProvider.tsx`:包 `LazyMotion``domAnimation` features全站只引入一次确保精简 bundle。
- `variants.ts`:共享 variants`fadeInUp``staggerContainer``cardHover``pageTransition`)与统一 transition 设定。
- `useRevealOnScroll.ts`:轻量 `IntersectionObserver` hook元素进入视窗触发一次CSS 路径用)。
- `Reveal.tsx`:薄包装组件,子元素滚动进入视窗时 fade-in-up内部用 framer 的 `whileInView` 或 CSS hook择一统一
### 3.2 页面切换退场framer-motion
-`PublicLayout``<Outlet/>` 外层用 `AnimatePresence mode="wait"`,以 `pathname+search` 为 key。
- 退场/进场使用 `pageTransition` variant淡入 + 轻微位移,~220ms
- 取代现有 `ark-page-fade-in`(避免重复;保留 CSS 作为 reduced-motion fallback
### 3.3 滚动出现Scroll Reveal
- 列表/区块(首页 carousel/区段、浏览 grid、分类卡片`Reveal` 包装,依序 stagger~60ms淡入上浮只触发一次。
- grid 大量项目时限制 stagger 上限,避免长列表整体延迟。
### 3.4 悬停与微互动(不改样式,仅加动作)
- 卡片:现有 `hover:scale-[1.02]` 升级为 framer 弹簧整卡轻浮 + 阴影过渡(沿用金色边框)。
- 下载按钮:`active:scale` 点击反馈 + 完成时的状态过渡。
- 导航连结:金色下划线滑入(`gold-underline` 过渡化)。
### 3.5 载入与反馈UX 重点)
- **骨架屏**:新增 `src/components/Skeleton.tsx`(带 shimmer用于列表/卡片/详情加载态,取代空白或突兀闪现。
- **图片淡入**:图片 `onLoad` 后淡入(卡片封面、详情图),避免硬切。
- **Toast**:轻量 `src/components/Toast.tsx` + provider下载成功/失败提示(沿用现有配色),无障碍 `aria-live`
## 4. 冗余代码清理
- `RecommendedCard.tsx`
- `useFigmaDesign ? "group-hover:scale-[1.02]" : "group-hover:scale-[1.02]"` → 两边相同,简化。
- spinner `useFigmaDesign ? "h-5 w-5 animate-spin" : "h-5 w-5 animate-spin"` → 简化。
- 实作各档案时移除遇到的同类重复(不做无关重构)。
## 5. 资料流 / 隔离
- 动画相关逻辑集中于 `src/motion/`,组件只引用 variants/hook不内嵌魔术数字。
- `MotionProvider` 在 app root 包一次;各页面无需重复设定。
- 骨架屏 / Toast 为独立、可单测的展示组件。
## 6. 无障碍与性能
- 所有 framer 动画与 CSS 动画在 `prefers-reduced-motion: reduce` 下降级为无动作或纯淡入。
- 使用 `LazyMotion` 精简 features避免引入完整 framer bundle。
- 动画用 `transform`/`opacity`GPU 友好),避免 layout thrash。
- build 后检查 bundle 体积变化在可接受范围。
## 7. 实作顺序
1. 安装 framer-motion + 建 `src/motion/` 基础设施 + tailwind/CSS keyframes。
2. `MotionProvider` 接入 app root。
3. 页面切换退场PublicLayout
4. Scroll reveal全站列表/区块)。
5. 骨架屏 + 图片淡入。
6. 悬停/微互动升级。
7. Toast 反馈。
8. 清理冗余代码。
9. `npm run build` 验证 + reduced-motion 验证。
## 8. 验收标准
- 视觉布局/颜色与改动前一致(仅多了动作)。
- `npm run build` 通过bundle 增量可控framer 精简模式)。
- 开启「减少动态效果」时网站仍可正常使用、无动画。
- 全站列表有 scroll reveal、卡片有弹簧悬停、加载有骨架屏、下载有 toast。

View File

@@ -0,0 +1,183 @@
# 钱包登录 + 收藏:重设计与 Bug 修复 设计文档
日期2026-06-02
分支:`terry-wallet-login`
范围登录弹窗、Header/菜单钱包入口、收藏按钮、我的收藏页面、收藏入口
关联:
- 需求简报 `.unipi/docs/generated/2026-06-01-wallet-favorites-ui-redesign-requirements.md`
- 后端核对 `docs/backend-requirements-wallet-favorites.md`
---
## 1. 目标
把已上线的「钱包登录 + 收藏」从「功能能跑但设计未完善、桌面/手机均有 bug」提升到完成度合格
1. 修复登录流程中的真实功能 bug强制切链、桌面误导、手机死路
2. 按已批准的极简原则重做登录弹窗。
3. 补齐完全缺失的「我的收藏」入口。
4. 打磨收藏按钮状态与收藏页(移动端筛选、空/错误/不可用状态)。
**关键事实**经核对后端钱包认证、TokenPocket 扫码、收藏接口(筛选/排序/分页/可用性/计数)均已实现并符合契约。**本次为纯前端工作**,后端仅需确认 CORS见后端文档 §2.1)。
---
## 2. 登录架构(决策已定)
三条路径共存,但 UI 上分主次:
| 路径 | 用途 | UI 位置 |
|---|---|---|
| `window.ethereum` 注入登录 | 桌面插件 / 钱包内置浏览器 | **主路径** |
| TokenPocket 自写扫码deep link + 轮询) | 中国稳定扫码 | 「其他方式」折叠区 |
| RainbowKit / WalletConnect | MetaMask / imToken 扫码兜底 | 「其他方式」折叠区 |
**决策**:保留并**真正接上** RainbowKit当前为未被调用的死代码
**前置项**:需在环境变量配置有效的 `VITE_WALLETCONNECT_PROJECT_ID`(当前默认 `ark-library-dev-only` 无效。WalletConnect 兜底在部分中国网络不稳定UI 需提示。
签名验证链无关(后端 EIP-191 personal_sign recover消息不引用任何链
---
## 3. Bug 修复清单(前端)
| # | 严重度 | 问题 | 修复 |
|---|---|---|---|
| B1 | 🟠 | 每次登录强制切 BNB 链(`ensureBnbChain`),多一个换网络弹窗,常见失败点 | 删除强制切链;`personal_sign` 不需要链 |
| B2 | 🟠 | 桌面弹窗摆 3 个钱包按钮,点 TP/imToken 误弹「请安装」 | 桌面只留 1 个主操作「使用浏览器钱包登录」 |
| B3 | 🟠 | 桌面无扫码TP 扫码被包在仅手机分支) | 扫码移入桌面「其他方式」 |
| B4 | 🟠 | 手机「打开钱包 App」是死路无反馈、App 未装无兜底 | 加跳转反馈 + 未安装兜底(提示去下载) |
| B5 | 🔴 | RainbowKit 整套加载但从未被登录流程调用 | 真正接成「其他方式」扫码兜底 |
| B6 | 🔴 | 全站无「我的收藏」入口,页面只能手敲 URL | 加 3 处入口(见 §5 |
| B7 | 🔴 | 钱包下拉只有地址 + 断开 | 下拉加「我的收藏」 |
| B8 | 🟡 | 收藏 token 过期只弹失败 toast | 401 时自动登出并引导重新登录 |
| B9 | 🟡 | `isMobileDevice` 把触屏 Mac/iPad 判为手机 | 收紧检测,避免桌面被推进 App 跳转流 |
| B10 | 🟡 | 收藏页加载失败无重试 | 错误态加重试按钮 |
| B11 | 🟡 | WalletConnect projectId 默认无效值 | 用 env缺失时禁用扫码兜底并提示 |
---
## 4. 登录弹窗设计
### 4.1 桌面版
结构(自上而下):
1. 标题「连接钱包」
2. 说明「签名仅用于验证钱包地址,不会发起交易,也不需要 Gas」
3. **主按钮**「使用浏览器钱包登录」(金色)→ `window.ethereum` 注入流程
4. 辅助说明「请使用已安装钱包插件的浏览器,例如 MetaMask」
5. 折叠「其他登录方式」(**默认折叠**),展开后:
- TokenPocket 扫码(第一项,中国常用)
- MetaMask / imToken 扫码WalletConnect附不稳定提示
6. 关闭按钮
7. 错误区(红色)
### 4.2 手机版
结构:
1. 标题「连接钱包」+ 说明「请在钱包 App 中打开本站并签名登录,无交易、无 Gas」
2. 若检测到注入钱包:**「使用当前钱包登录」**主按钮
3. 否则:分组「打开钱包 App」+ 三个按钮TokenPocket / MetaMask / imToken带品牌图标
- 点按尝试 deep link未跳转/未安装 → 提示去下载(**不再死路**,修 B4
4. 折叠「其他方式(扫码)」默认折叠
5. 关闭按钮 + 错误区
### 4.3 通用
- 钱包按钮配品牌彩色图标。
- 多语言预留文字长度en/zh-CN/zh-TW/ko/ja/vi/id/ms按钮不溢出。
- 弹窗在手机上可滚动、不被遮挡。
---
## 5. 钱包入口与收藏入口
### 5.1 Header 钱包入口
- 未登录:`Connect Wallet / 连接钱包` 主按钮(桌面右侧 / 手机菜单内)。
- 已登录:短地址 `0x12…ab34` + 绿点;点击展开下拉。
### 5.2 钱包下拉(已登录,修 B7
顺序:完整地址 → **♥ 我的收藏**(新增)→ 断开连接。
### 5.3 「我的收藏」入口策略:**始终显示(方案 B**
- 桌面钱包下拉、手机菜单中**始终**显示「我的收藏」入口。
- 未登录点击 → 落到 `/favorites` 的「连接钱包查看收藏」引导页(现状已有,保留)。
- 手机菜单导航项中加「♥ 我的收藏」。
### 5.4 收藏按钮触发登录(保留现状逻辑)
未登录点 ♥ → 打开登录弹窗 → 登录成功后自动补上本次收藏(`pendingAfterLogin` 已实现)。
---
## 6. 收藏按钮(`FavoriteButton`
四态,一眼可分:
- 未收藏:空心 ♡,低对比底。
- 已收藏:实心 ♥,品牌金填充。
- 加载中:转圈(`LoaderCircle`)。
- 请求中:禁用 + 降透明。
行为:
- 点击 `preventDefault + stopPropagation`,不误触进详情(已实现,保留)。
- 增加点击微动效(`active:scale`)。
- 乐观更新,失败回滚 + 错误 toast已实现保留
摆放:推荐卡 / 最新 / 热门 / 内容流 / 收藏页卡片右上角,不挡主内容、不与下载/预览混淆。
---
## 7. 我的收藏页面 `/favorites`
### 7.1 未登录
图标 + 标题 + 说明 + 「连接钱包」CTA现状已有保留视觉打磨
### 7.2 已登录
- **桌面**筛选一行:搜索 + 排序 + 分类 + 搜索按钮(现状保留)。
- **移动端**:搜索框单独一行;排序/分类收进**「筛选抽屉」**,解决现状 4 控件挤压(新增)。
- 列表:收藏资源卡(封面/标题/描述/分类/类型/更新时间/收藏数/收藏按钮)。
- 分页:上一页/下一页 + 页码(现状保留)。
- 「清除筛选」当存在筛选时显示。
### 7.3 状态
- **不可用/下架**:黄边 + 「不可用」标 + 不可点进详情 + 保留移除按钮(后端 `availability` 已支持)。
- **空状态**:区分「还没有收藏」与「筛选无结果」,后者给清除筛选入口。
- **错误**:加载失败提示 +(新增)**重试按钮**(修 B10
- **加载**4 张 skeleton布局不跳。
排序选项:最近收藏 / 最近发布 / 热门(后端 `favorited_at`/`published_at`/`hot` 已支持)。
---
## 8. 多语言
所有新增/改动文案覆盖 8 语言en、zh-CN、zh-TW、ko、ja、vi、id、mskey 写入 `src/locales/*`。移动端按钮预留长文本。
---
## 9. 验收清单
登录:
- [ ] 桌面弹窗只有 1 个主操作;扫码在折叠区。
- [ ] 手机可打开 TP/MetaMask/imToken未安装有兜底。
- [ ] 登录不再强制切链。
- [ ] RainbowKit 真正接通projectId 有效时);无效时扫码兜底禁用并提示。
- [ ] 已登录显示短地址,可断开。
收藏:
- [ ] 钱包下拉、手机菜单均有「我的收藏」入口(始终显示)。
- [ ] 收藏按钮四态清楚,不与卡片点击冲突。
- [ ] 未登录点收藏 → 引导登录 → 自动补收藏。
- [ ] token 过期自动登出并引导重登。
收藏页:
- [ ] 桌面一行筛选;移动端筛选抽屉。
- [ ] 不可用/空/错误(含重试)/骨架屏 完整。
- [ ] Desktop 与 mobile 均验证。
质量门槛(实现后):`npx tsc --noEmit``npm run format:check``npm test` 全绿。
---
## 10. 不做YAGNI
- 服务端登出 / token 撤销(保持无状态 JWT
- ENS、链上读取、交易。
- 收藏分组/文件夹、批量操作(本期不做)。
- 收藏 `q` 全文索引优化(数据量小,暂不做)。

View File

@@ -1,9 +1,41 @@
<!doctype html>
<html lang="zh-Hant">
<html lang="zh-CN" translate="no">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ARK 資料庫</title>
<!-- The app ships its own 7-language i18n and serves localized content, so
browser auto-translation only garbles the UI. Opt out of it. -->
<meta name="google" content="notranslate" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<meta name="theme-color" content="#08070c" />
<meta name="color-scheme" content="dark" />
<meta name="application-name" content="ARK 资料库" />
<meta
name="description"
content="ARK 官方数据库集中整理官方教材、公告、视频、图片与常用文件,帮助社区快速找到可信资料。"
/>
<meta property="og:type" content="website" />
<meta property="og:site_name" content="ARK 资料库" />
<meta property="og:title" content="ARK 官方数据库" />
<meta
property="og:description"
content="ARK 官方数据库集中整理官方教材、公告、视频、图片与常用文件,帮助社区快速找到可信资料。"
/>
<meta property="og:image" content="/assets/ark-mark.png" />
<meta property="og:locale" content="zh_CN" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="ARK 官方数据库" />
<meta
name="twitter:description"
content="ARK 官方数据库集中整理官方教材、公告、视频、图片与常用文件,帮助社区快速找到可信资料。"
/>
<meta name="twitter:image" content="/assets/ark-mark.png" />
<link rel="icon" type="image/webp" href="/assets/logo-primary.webp" />
<link rel="apple-touch-icon" href="/assets/ark-mark.png" />
<link rel="manifest" href="/site.webmanifest" />
<title>ARK 官方数据库</title>
</head>
<body class="bg-ark-bg text-neutral-100">
<div id="root"></div>

287
package-lock.json generated
View File

@@ -9,12 +9,14 @@
"version": "1.0.0",
"dependencies": {
"@rainbow-me/rainbowkit": "^2.2.11",
"@tanstack/react-query": "^5.100.9",
"@tanstack/react-query": "^5.100.14",
"framer-motion": "^11.18.2",
"lucide-react": "^0.460.0",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"viem": "^2.48.11",
"viem": "^2.52.0",
"wagmi": "^2.19.5"
},
"devDependencies": {
@@ -495,16 +497,16 @@
}
},
"node_modules/@coinbase/cdp-sdk": {
"version": "1.48.3",
"resolved": "https://registry.npmjs.org/@coinbase/cdp-sdk/-/cdp-sdk-1.48.3.tgz",
"integrity": "sha512-1fldOyJw/vjk42GsOCQ2pys/3r3LXHq8wZyhnt6OXkFfKirCjZiw966PT0vFcI/IzIQnhDgSeG7Tlt7kul3osg==",
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@coinbase/cdp-sdk/-/cdp-sdk-1.51.0.tgz",
"integrity": "sha512-XK8+OXDER1jirYpuiOct4ij65ODQ31LsmyRrZi/J7zF4GB89qxWZ0KPfAdsqJMP7VvE4no+Q++MKkQtAJUBoyg==",
"license": "MIT",
"dependencies": {
"@solana-program/system": "^0.10.0",
"@solana-program/token": "^0.9.0",
"@solana/kit": "^5.5.1",
"abitype": "1.0.6",
"axios": "1.13.6",
"axios": "1.16.0",
"axios-retry": "^4.5.0",
"bs58": "^6.0.0",
"jose": "^6.2.0",
@@ -1297,9 +1299,9 @@
}
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.5.1.tgz",
"integrity": "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz",
"integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==",
"license": "BSD-3-Clause"
},
"node_modules/@lit/reactive-element": {
@@ -1408,9 +1410,9 @@
}
},
"node_modules/@metamask/eth-json-rpc-provider/node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -1501,9 +1503,9 @@
}
},
"node_modules/@metamask/json-rpc-engine/node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -1562,9 +1564,9 @@
}
},
"node_modules/@metamask/json-rpc-middleware-stream/node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -1686,9 +1688,9 @@
}
},
"node_modules/@metamask/providers/node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -1879,9 +1881,9 @@
}
},
"node_modules/@metamask/utils/node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -1985,7 +1987,7 @@
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@paulmillr/qr/-/qr-0.2.1.tgz",
"integrity": "sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ==",
"deprecated": "The package is now available as \"qr\": npm install qr",
"deprecated": "Switch to \"qr\" (new package name) for security updates: npm install qr",
"license": "(MIT OR Apache-2.0)",
"funding": {
"url": "https://paulmillr.com/funding/"
@@ -2343,9 +2345,9 @@
}
},
"node_modules/@reown/appkit-controllers/node_modules/lru-cache": {
"version": "11.3.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@@ -2852,9 +2854,9 @@
}
},
"node_modules/@reown/appkit-utils/node_modules/lru-cache": {
"version": "11.3.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@@ -3313,9 +3315,9 @@
}
},
"node_modules/@reown/appkit/node_modules/lru-cache": {
"version": "11.3.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@@ -4603,27 +4605,6 @@
}
}
},
"node_modules/@solana/rpc-subscriptions-channel-websocket/node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/@solana/rpc-subscriptions-spec": {
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-spec/-/rpc-subscriptions-spec-5.5.1.tgz",
@@ -4879,9 +4860,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.100.9",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.9.tgz",
"integrity": "sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==",
"version": "5.100.14",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz",
"integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==",
"license": "MIT",
"funding": {
"type": "github",
@@ -4889,12 +4870,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.100.9",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.9.tgz",
"integrity": "sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A==",
"version": "5.100.14",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz",
"integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.100.9"
"@tanstack/query-core": "5.100.14"
},
"funding": {
"type": "github",
@@ -5535,9 +5516,9 @@
}
},
"node_modules/@walletconnect/core/node_modules/lru-cache": {
"version": "11.3.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@@ -5722,9 +5703,9 @@
}
},
"node_modules/@walletconnect/ethereum-provider/node_modules/lru-cache": {
"version": "11.3.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@@ -5938,9 +5919,9 @@
}
},
"node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/ws": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"version": "7.5.11",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz",
"integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"
@@ -6114,9 +6095,9 @@
}
},
"node_modules/@walletconnect/types/node_modules/lru-cache": {
"version": "11.3.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@@ -6287,9 +6268,9 @@
}
},
"node_modules/@walletconnect/universal-provider/node_modules/lru-cache": {
"version": "11.3.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@@ -6566,9 +6547,9 @@
}
},
"node_modules/@walletconnect/utils/node_modules/lru-cache": {
"version": "11.3.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@@ -6963,14 +6944,14 @@
}
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
"proxy-from-env": "^2.1.0"
}
},
"node_modules/axios-retry": {
@@ -7939,15 +7920,15 @@
}
},
"node_modules/engine.io-client": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
"version": "6.6.5",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.5.tgz",
"integrity": "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3",
"ws": "~8.20.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
@@ -7986,9 +7967,9 @@
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -8123,9 +8104,9 @@
}
},
"node_modules/eth-block-tracker/node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -8458,6 +8439,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "11.18.2",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
"integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==",
"license": "MIT",
"dependencies": {
"motion-dom": "^11.18.1",
"motion-utils": "^11.18.1",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -8650,9 +8658,9 @@
}
},
"node_modules/hono": {
"version": "4.12.18",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
"version": "4.12.23",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -9190,9 +9198,9 @@
}
},
"node_modules/lit-html": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.2.tgz",
"integrity": "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.3.tgz",
"integrity": "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/trusted-types": "^2.0.2"
@@ -9398,6 +9406,21 @@
"integrity": "sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==",
"license": "MIT"
},
"node_modules/motion-dom": {
"version": "11.18.1",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
"integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==",
"license": "MIT",
"dependencies": {
"motion-utils": "^11.18.1"
}
},
"node_modules/motion-utils": {
"version": "11.18.1",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz",
"integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -9615,9 +9638,9 @@
"license": "MIT"
},
"node_modules/ox": {
"version": "0.14.20",
"resolved": "https://registry.npmjs.org/ox/-/ox-0.14.20.tgz",
"integrity": "sha512-rby38C3nDn8eQkf29Zgw4hkCZJ64Qqi0zRPWL8ENUQ7JVuoITqrVtwWQgM/He19SCMUEc7hS/Sjw0jIOSLJhOw==",
"version": "0.14.27",
"resolved": "https://registry.npmjs.org/ox/-/ox-0.14.27.tgz",
"integrity": "sha512-+xhLHo/f+f4BH121/1Pomm/1vgBBda1wYiFpTvjSo8o5OcEj76Pf1hGPJiepoYMTQoTm2SKdSBvWkFWk5l07PA==",
"funding": [
{
"type": "github",
@@ -10080,10 +10103,13 @@
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.4",
@@ -10132,6 +10158,15 @@
"node": ">=10.13.0"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/query-string": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
@@ -11126,9 +11161,9 @@
"license": "MIT"
},
"node_modules/ua-parser-js": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-2.0.9.tgz",
"integrity": "sha512-OsqGhxyo/wGdLSXMSJxuMGN6H4gDnKz6Fb3IBm4bxZFMnyy0sdf6MN96Ie8tC6z/btdO+Bsy8guxlvLdwT076w==",
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-2.0.10.tgz",
"integrity": "sha512-t+3Ktbq0Ies2vaSezfOaWiolH4OigQIO1dk+1xDpOydB1COVPocVYOrEV5rqZ0kFY9XYG1v9LutCyMgYBpABcw==",
"funding": [
{
"type": "opencollective",
@@ -11188,9 +11223,9 @@
}
},
"node_modules/undici-types": {
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.25.0.tgz",
"integrity": "sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==",
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.27.0.tgz",
"integrity": "sha512-sqqlwW3zm+cE82GwKdGyn3pcze7LXlx/4jUgA0vtAf6Fa81KMrJqc3VfWmmeOTUIElW9IdPsLwMUIpiOZQgK3A==",
"license": "MIT"
},
"node_modules/update-browserslist-db": {
@@ -11354,9 +11389,9 @@
}
},
"node_modules/viem": {
"version": "2.48.11",
"resolved": "https://registry.npmjs.org/viem/-/viem-2.48.11.tgz",
"integrity": "sha512-+WZ5E0dBS6GtKb+1wEk5DeYRRRW42+pFnXCo67Ydodf42sBwO+hu3wnQy66lc4MKmHz+llPVdbyehYr9oTE2iw==",
"version": "2.52.0",
"resolved": "https://registry.npmjs.org/viem/-/viem-2.52.0.tgz",
"integrity": "sha512-py2QPYe9e1f4DmPJCsXF7zHmyZ0PkJrBxdQZ5dvNXvzy3UzWkUn7dNfC0TMeNm6Qv1tKw3b6qXXExpx6L0oMbw==",
"funding": [
{
"type": "github",
@@ -11371,8 +11406,8 @@
"@scure/bip39": "1.6.0",
"abitype": "1.2.3",
"isows": "1.0.7",
"ox": "0.14.20",
"ws": "8.18.3"
"ox": "0.14.27",
"ws": "8.20.1"
},
"peerDependencies": {
"typescript": ">=5.0.4"
@@ -11599,13 +11634,13 @@
"license": "ISC"
},
"node_modules/which-typed-array": {
"version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
"integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz",
"integrity": "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==",
"license": "MIT",
"dependencies": {
"available-typed-arrays": "^1.0.7",
"call-bind": "^1.0.8",
"call-bind": "^1.0.9",
"call-bound": "^1.0.4",
"for-each": "^0.3.5",
"get-proto": "^1.0.1",
@@ -11657,9 +11692,9 @@
"license": "ISC"
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"

View File

@@ -14,12 +14,14 @@
},
"dependencies": {
"@rainbow-me/rainbowkit": "^2.2.11",
"@tanstack/react-query": "^5.100.9",
"@tanstack/react-query": "^5.100.14",
"framer-motion": "^11.18.2",
"lucide-react": "^0.460.0",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"viem": "^2.48.11",
"viem": "^2.52.0",
"wagmi": "^2.19.5"
},
"devDependencies": {

View File

@@ -2,11 +2,8 @@
Source: Figma file `uHDZkVHjAp7BXDKQKB0PM4`, responsive reference node `3761:10923`.
- `banner-desktop.png` — desktop/tablet banner export from node `3621:1225`.
- `banner-wide.png` — provided wide banner crop from node `3718:11952`.
- `banner-576.png` — mobile/tablet banner crop from node `3726:13099`.
- `banner-440.png` — mobile banner crop from node `3726:14199`.
- `banner-375.png` — mobile banner crop from node `3726:14238`.
- `official-recommendation-1.png` ... `official-recommendation-5.png` — official recommendation cover exports from the 1920px frame card image nodes; used only as fallback/placeholder covers so real resource cards keep accurate API-provided imagery.
Currently retained asset:
These files are visual UI assets only. They do not change backend data or API contracts.
- `official-recommendation-cover.png` — fallback/placeholder cover used by `src/components/RecommendedCard.tsx` via `src/components/FigmaBanner.tsx`.
Old static banner exports and individual recommendation placeholder exports were removed because the frontend now loads home banners from `/api/banners` and only uses the single shared recommendation cover fallback.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

View File

@@ -0,0 +1,12 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4220_10205)">
<path d="M11.9998 23.9995C18.6268 23.9995 23.9995 18.6264 23.9995 11.9998C23.9995 5.37318 18.6273 0 11.9998 0C5.37224 0 0 5.37364 0 11.9998C0 18.6259 5.37271 23.9995 11.9998 23.9995Z" fill="#F0F0F0"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.2259 5.73314H11.4599V2.59961H19.4594C20.5579 3.47415 21.4943 4.53476 22.2259 5.73314ZM23.9992 11.9988H11.4665V8.86621H23.5862C23.8612 9.88767 24.0001 10.941 23.9992 11.9988ZM11.9994 23.9986C14.7123 24.0048 17.3462 23.0855 19.466 21.3924H4.53332C6.65275 23.0857 9.2866 24.0051 11.9994 23.9986ZM22.2391 18.2659H1.75978C1.16246 17.2918 0.708889 16.2365 0.413086 15.1328H23.5858C23.29 16.2365 22.8365 17.2918 22.2391 18.2659Z" fill="#D80027"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.57099 1.86747H5.5602V1.87497L5.57099 1.86747ZM5.57099 1.86747H6.65612L5.6338 2.60105L6.02051 3.80103L5.00053 3.06745L3.98711 3.80103L4.32038 2.76558C3.42466 3.51526 2.64316 4.39167 2.00059 5.36709H2.35402L1.70715 5.83583L1.41372 6.35145L1.72028 7.30487L1.14045 6.883L0.740611 7.81392L1.07389 8.85921H2.33949L1.33357 9.60029L1.72028 10.8003L0.700299 10.0667L0.100311 10.5064C0.0333572 11.0014 -0.000155581 11.5003 5.42967e-07 11.9998H11.9998V1.01072e-05C9.72265 -0.00295146 7.49213 0.644982 5.57099 1.86747ZM6.01816 10.7942L6.02707 10.8012H6.02051L6.01816 10.7942ZM5.63333 9.60076L6.01816 10.7942L5.00006 10.0676L3.98008 10.8012L4.36679 9.60123L3.34681 8.86765H4.61241L4.99912 7.66767L5.38583 8.86765H6.65143L5.63333 9.60076ZM5.64036 6.1077L6.02707 7.30768L5.00709 6.5741L3.98711 7.30768L4.37382 6.1077L3.35384 5.37412H4.61944L5.00615 4.17415L5.39286 5.37412H6.65846L5.64036 6.1077ZM9.31341 10.0676L10.3334 10.8012L9.94668 9.60123L10.9685 8.86765H9.70294L9.31623 7.66767L8.92951 8.86765H7.66391L8.68389 9.60123L8.29718 10.8012L9.31341 10.0676ZM9.94668 6.1077L10.3334 7.30768L9.31341 6.5741L8.29343 7.30768L8.68014 6.1077L7.66016 5.37412H8.92576L9.31248 4.17415L9.69919 5.37412H10.9648L9.94668 6.1077ZM10.3334 3.81415L9.94668 2.61418L10.9685 1.8806H9.70294L9.31623 0.680622L8.92951 1.8806H7.66391L8.68389 2.61418L8.29718 3.81415L9.31716 3.08057L10.3334 3.81415Z" fill="#0052B4"/>
</g>
<defs>
<clipPath id="clip0_4220_10205">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,11 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4220_10204)">
<path d="M0 12C0 5.37281 5.37281 0 12 0C18.6272 0 24 5.37281 24 12" fill="#D81F2A"/>
<path d="M24 12C24 18.6272 18.6272 24 12 24C5.37281 24 0 18.6272 0 12H24Z" fill="#F1F0F0"/>
</g>
<defs>
<clipPath id="clip0_4220_10204">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 428 B

View File

@@ -0,0 +1,11 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4220_10208)">
<path d="M12 24C18.6272 24 24 18.6267 24 12C24 5.37328 18.6272 0 12 0C5.37281 0 0 5.37328 0 12C0 18.6267 5.37281 24 12 24Z" fill="#F0F0F0"/>
<path d="M11.9975 17.2193C13.03 17.2199 14.0395 16.9142 14.8983 16.341C15.757 15.7677 16.4265 14.9527 16.8221 13.999C17.2176 13.0452 17.3214 11.9956 17.1203 10.9828C16.9193 9.97007 16.4224 9.03969 15.6925 8.30936C14.9627 7.57903 14.0326 7.08156 13.02 6.87986C12.0073 6.67817 10.9576 6.78131 10.0036 7.17624C9.04964 7.57118 8.23419 8.24016 7.66043 9.09859C7.08666 9.95701 6.78037 10.9663 6.78027 11.9988C6.78027 13.3828 7.32984 14.7102 8.30815 15.6891C9.28647 16.668 10.6135 17.2184 11.9975 17.2193Z" fill="#D80027"/>
</g>
<defs>
<clipPath id="clip0_4220_10208">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 909 B

View File

@@ -0,0 +1,23 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4220_10206)">
<path d="M24 12C24 13.5759 23.6896 15.1363 23.0866 16.5922C22.4835 18.0481 21.5996 19.371 20.4853 20.4853C19.371 21.5996 18.0481 22.4835 16.5922 23.0866C15.1363 23.6896 13.5759 24 12 24C10.4241 24 8.86371 23.6896 7.4078 23.0866C5.95189 22.4835 4.62902 21.5996 3.51472 20.4853C2.40042 19.371 1.5165 18.0481 0.913447 16.5922C0.310391 15.1363 -3.32088e-08 13.5759 0 12C-3.32088e-08 10.4241 0.310391 8.86371 0.913447 7.4078C1.5165 5.95189 2.40042 4.62902 3.51472 3.51472C4.62902 2.40042 5.95189 1.5165 7.4078 0.913446C8.86371 0.310389 10.4241 0 12 0C13.5759 0 15.1363 0.310389 16.5922 0.913446C18.0481 1.5165 19.371 2.40042 20.4853 3.51472C21.5996 4.62902 22.4835 5.95189 23.0866 7.4078C23.6896 8.86371 24 10.4241 24 12Z" fill="white"/>
<path d="M3.64258 8.92969L5.94777 5.47398L3.64258 8.92969ZM4.50651 9.50599L6.8117 6.05027L4.50651 9.50599ZM5.37043 10.0823L7.67563 6.62657L5.37043 10.0823Z" fill="black"/>
<path d="M16.3135 17.3828L18.6187 13.9271L16.3135 17.3828ZM17.1774 17.9591L19.4826 14.5034L17.1774 17.9591ZM18.0413 18.5354L20.3465 15.0797L18.0413 18.5354Z" fill="black"/>
<path d="M16.8906 15.2715L19.7704 17.1925L16.8906 15.2715Z" fill="black"/>
<path d="M16.8906 15.2715L19.7704 17.1925" stroke="white" stroke-width="7.38492"/>
<path d="M15.451 14.3109C16.7241 12.4024 16.209 9.82314 14.3005 8.55002C12.3919 7.2769 9.81269 7.792 8.53957 9.70054C7.26644 11.6091 7.78155 14.1883 9.69009 15.4614C11.5986 16.7346 14.1779 16.2195 15.451 14.3109Z" fill="#E74C3C"/>
<path d="M8.53957 9.70054C8.23388 10.1588 8.12275 10.7197 8.23064 11.2599C8.33852 11.8001 8.65657 12.2753 9.11483 12.581C9.57308 12.8867 10.134 12.9978 10.6742 12.8899C11.2144 12.782 11.6896 12.464 11.9953 12.0057C12.301 11.5475 12.7762 11.2294 13.3164 11.1215C13.8566 11.0137 14.4175 11.1248 14.8757 11.4305C15.334 11.7362 15.652 12.2114 15.7599 12.7516C15.8678 13.2917 15.7567 13.8527 15.451 14.3109C14.8396 15.2274 13.8892 15.8635 12.8088 16.0793C11.7284 16.2951 10.6066 16.0728 9.69009 15.4614C8.77358 14.8501 8.13747 13.8996 7.9217 12.8193C7.70594 11.7389 7.92819 10.617 8.53957 9.70054Z" fill="#356BA0"/>
<path d="M5.94824 18.5352L3.64305 15.0794L5.94824 18.5352ZM6.81217 17.9589L4.50698 14.5031L6.81217 17.9589ZM7.6761 17.3826L5.37091 13.9268L7.6761 17.3826Z" fill="black"/>
<path d="M18.6191 10.082L16.3139 6.62632L18.6191 10.082ZM19.4831 9.50573L17.1779 6.05002L19.4831 9.50573ZM20.347 8.92944L18.0418 5.47372L20.347 8.92944Z" fill="black"/>
<path d="M7.38365 6.43193L7.9685 6.82207L5.66331 10.2778L5.07846 9.88765L7.38365 6.43193ZM6.5197 5.85562L7.10459 6.24578L4.7994 9.7015L4.21451 9.31134L6.5197 5.85562ZM5.65579 5.27934L6.24064 5.66947L3.93545 9.12519L3.3506 8.73505L5.65579 5.27934Z" fill="#222222"/>
<path d="M20.0546 14.8843L20.6394 15.2744L18.3343 18.7301L17.7494 18.34L20.0546 14.8843ZM19.1906 14.308L19.7755 14.6981L17.4703 18.1538L16.8855 17.7637L19.1906 14.308ZM18.3267 13.7317L18.9116 14.1218L16.6064 17.5775L16.0215 17.1874L18.3267 13.7317Z" fill="#222222"/>
<path d="M5.07844 14.1219L5.66329 13.7317L7.96849 17.1874L7.38364 17.5776L5.07844 14.1219ZM4.2145 14.6982L4.79938 14.308L7.10458 17.7637L6.51969 18.1539L4.2145 14.6982ZM3.35059 15.2745L3.93544 14.8843L6.24063 18.34L5.65578 18.7302L3.35059 15.2745Z" fill="#222222"/>
<path d="M17.7494 5.66943L18.3342 5.2793L20.6394 8.73501L20.0546 9.12515L17.7494 5.66943ZM16.8854 6.24574L17.4703 5.85558L19.7755 9.3113L19.1906 9.70146L16.8854 6.24574ZM16.0215 6.82203L16.6064 6.43189L18.9116 9.88761L18.3267 10.2777L16.0215 6.82203Z" fill="#222222"/>
<path d="M18.666 7.34743L19.53 6.77112L19.722 7.05909L18.8581 7.6354L18.666 7.34743ZM16.7942 8.59608L17.8021 7.92372L17.9942 8.21169L16.9863 8.88405L16.7942 8.59608ZM5.13115 16.3761L5.99506 15.7998L6.18715 16.0878L5.32324 16.6641L5.13115 16.3761Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_4220_10206">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1,25 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4220_10209)">
<mask id="mask0_4220_10209" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="-1" width="24" height="25">
<path d="M12 23.9785C18.6274 23.9785 24 18.6059 24 11.9785C24 5.3511 18.6274 -0.0214844 12 -0.0214844C5.37258 -0.0214844 0 5.3511 0 11.9785C0 18.6059 5.37258 23.9785 12 23.9785Z" fill="white"/>
</mask>
<g mask="url(#mask0_4220_10209)">
<path d="M0 0H24V24.048H0V0Z" fill="#CB2026"/>
<path d="M0 22.332H24V24.0497H0V22.332Z" fill="white"/>
<path d="M0 18.8965H24V20.6142H0V18.8965Z" fill="white"/>
<path d="M0 15.4609H24V17.1786H0V15.4609Z" fill="white"/>
<path d="M0 12.0254H24V13.7431H0V12.0254Z" fill="white"/>
<path d="M0 8.58984H24V10.3076H0V8.58984Z" fill="white"/>
<path d="M0 5.1543H24V6.872H0V5.1543Z" fill="white"/>
<path d="M0 1.71875H24V3.43648H0V1.71875Z" fill="white"/>
<path d="M0 -0.0214844H15.1638V13.742H0V-0.0214844Z" fill="#21205F"/>
<path d="M7.13106 4.41406C5.14942 4.41406 3.54102 6.01709 3.54102 7.99217C3.54102 9.96725 5.14942 11.5703 7.13106 11.5703C7.84698 11.5703 8.51401 11.3608 9.07426 11.0004C8.74801 11.1129 8.39787 11.1746 8.03338 11.1746C6.27625 11.1746 4.85014 9.75315 4.85014 8.00185C4.85014 6.25047 6.27625 4.82914 8.03338 4.82914C8.4217 4.82914 8.79368 4.8985 9.13786 5.02553C8.56486 4.63961 7.87417 4.41406 7.13106 4.41406Z" fill="#FFCD05"/>
<path d="M13.085 9.85617L11.5728 9.06714L12.0198 10.682L11.0084 9.33121L10.6929 10.9753L10.3825 9.33021L9.36676 10.6779L9.81901 9.06453L8.3043 9.84899L9.42949 8.58669L7.71582 8.65249L9.2912 7.99139L7.71793 7.32549L9.43144 7.39643L8.31018 6.13079L9.82237 6.91981L9.3753 5.30502L10.3868 6.65577L10.7023 5.01172L11.0126 6.65675L12.0284 5.30903L11.5761 6.92243L13.0908 6.13797L11.9656 7.40027L13.6793 7.33446L12.104 7.99559L13.6772 8.66147L11.9637 8.59053L13.085 9.85617Z" fill="#FFCD05"/>
</g>
</g>
<defs>
<clipPath id="clip0_4220_10209">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,11 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4220_10207)">
<path d="M12 24C18.6274 24 24 18.6274 24 12C24 5.37258 18.6274 0 12 0C5.37258 0 0 5.37258 0 12C0 18.6274 5.37258 24 12 24Z" fill="#D80027"/>
<path d="M11.9995 6.7832L13.2946 10.7689H17.4855L14.095 13.2322L15.39 17.218L11.9995 14.7547L8.60908 17.218L9.90414 13.2322L6.51367 10.7689H10.7045L11.9995 6.7832Z" fill="#FFDA44"/>
</g>
<defs>
<clipPath id="clip0_4220_10207">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 574 B

View File

@@ -0,0 +1,15 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4220_10210)">
<path d="M12 24C18.6274 24 24 18.6274 24 12C24 5.37258 18.6274 0 12 0C5.37258 0 0 5.37258 0 12C0 18.6274 5.37258 24 12 24Z" fill="#EE1C25"/>
<path d="M20.0091 13.8945L18.8307 13.8753L18.4475 12.7617L18.0651 13.8753L16.8867 13.8953L17.8283 14.6025L17.4835 15.7281L18.4483 15.0521L19.4131 15.7281L19.0675 14.6025L20.0091 13.8945Z" fill="#FFFF00"/>
<path d="M15.6869 8.58218L15.7061 7.40378L16.8197 7.02058L15.7061 6.63738L15.6861 5.45898L14.9789 6.40058L13.8525 6.05658L14.5293 7.02138L13.8525 7.98618L14.9789 7.63978L15.6869 8.58218Z" fill="#FFFF00"/>
<path d="M15.6869 18.5412L15.7061 17.3628L16.8197 16.9796L15.7061 16.5964L15.6861 15.418L14.9789 16.3596L13.8525 16.0156L14.5293 16.9804L13.8525 17.9452L14.9789 17.5988L15.6869 18.5412Z" fill="#FFFF00"/>
<path d="M18.9224 10.0288L19.4392 8.97037L18.3808 9.48717L17.5336 8.66797L17.6992 9.83357L16.6592 10.3864L17.82 10.5904L18.024 11.7512L18.5752 10.7104L19.7416 10.8752L18.9224 10.0288Z" fill="#FFFF00"/>
<path d="M12.4088 10.572L9.23201 10.5192L8.19921 7.51758L7.16721 10.52L3.99121 10.5736L6.52961 12.4816L5.60001 15.5176L8.20081 13.6944L10.8024 15.516L9.87121 12.4808L12.4088 10.572Z" fill="#FFFF00"/>
</g>
<defs>
<clipPath id="clip0_4220_10210">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.875 16.5C20.5125 16.5 21 16.9875 21 17.625C21 18.2625 20.5125 18.75 19.875 18.75H4.125C3.4875 18.75 3 18.2625 3 17.625C3 16.9875 3.4875 16.5 4.125 16.5H19.875ZM19.875 11.25C20.5125 11.25 21 11.7375 21 12.375C21 13.0125 20.5125 13.5 19.875 13.5H4.125C3.4875 13.5 3 13.0125 3 12.375C3 11.7375 3.4875 11.25 4.125 11.25H19.875ZM19.875 6C20.5125 6 21 6.4875 21 7.125C21 7.7625 20.5125 8.25 19.875 8.25H4.125C3.4875 8.25 3 7.7625 3 7.125C3 6.4875 3.4875 6 4.125 6H19.875Z" fill="#A8A9AE"/>
</svg>

After

Width:  |  Height:  |  Size: 600 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.7372 18.1928L16.6086 14.0643C16.2582 13.7138 15.6869 13.7138 15.3364 14.0643L14.3427 13.0705C16.191 10.603 15.9989 7.08894 13.757 4.84705C11.2943 2.38432 7.30497 2.38432 4.84705 4.84705C2.38432 7.30977 2.38432 11.2991 4.84705 13.757C7.08894 15.9989 10.603 16.191 13.0705 14.3427L14.0643 15.3364C13.7138 15.6869 13.7138 16.2582 14.0643 16.6086L18.1928 20.7372C18.5433 21.0876 19.1145 21.0876 19.465 20.7372L20.7372 19.465C21.0876 19.1145 21.0876 18.5433 20.7372 18.1928ZM12.9361 11.9328L11.9424 12.9265C11.1791 13.4834 10.267 13.7954 9.30684 13.7954C8.09708 13.8002 6.96893 13.3346 6.11922 12.4801C5.2695 11.6304 4.79904 10.4974 4.79904 9.29724C4.79904 8.09708 5.2695 6.96413 6.11922 6.11442C6.96893 5.2647 8.10188 4.79424 9.30204 4.79424C10.5022 4.79424 11.6352 5.2647 12.4849 6.11442C13.3346 6.96413 13.805 8.09708 13.805 9.29724C13.805 10.2622 13.493 11.1695 12.9361 11.9328Z" fill="#A8A9AE"/>
</svg>

After

Width:  |  Height:  |  Size: 1012 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 357 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 343 KiB

View File

@@ -1,19 +0,0 @@
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M28.2619 19.2138C28.7318 18.9688 29.2941 19.3098 29.2941 19.8397V24.3033C29.2941 26.091 29.2941 28.2271 26.4919 29.8449C24.5294 30.978 21.2896 33.1462 17.4706 33.1462C13.6516 33.1462 10.4118 31.0286 8.43741 29.8449C5.64706 28.1721 5.64706 26.091 5.64706 24.3033V19.8397C5.64706 19.3098 6.20934 18.9688 6.67929 19.2138L14.0536 23.0587C15.1295 23.534 16.2882 23.7716 17.4706 23.7716C18.6529 23.7716 19.8116 23.534 20.8876 23.0587L28.2619 19.2138Z" fill="url(#paint0_linear_3621_1510)"/>
<path d="M32.7491 16.5221C33.2168 16.3069 33.75 16.6486 33.75 17.1633V26.9036C33.75 27.5223 33.2438 28.0286 32.625 28.0286C32.0063 28.0286 31.5 27.5223 31.5 26.9036V18.0012C31.5 17.4499 31.8209 16.949 32.3218 16.7186L32.7491 16.5221Z" fill="url(#paint1_linear_3621_1510)"/>
<path d="M15.6803 21.1454C16.4171 21.4818 17.208 21.6505 18 21.6505C18.792 21.6505 19.5818 21.4829 20.3198 21.1454L34.6871 13.1659C35.4971 12.7958 36 12.0117 36 11.1195C36 10.2274 35.4971 9.44216 34.6871 9.07203L20.3198 2.50541C18.8449 1.83153 17.1562 1.83153 15.6814 2.50541L1.31288 9.07091C0.502875 9.44216 0 10.2263 0 11.1184C0 12.0105 0.502875 12.7947 1.31288 13.1659L15.6803 21.1454Z" fill="url(#paint2_linear_3621_1510)"/>
<defs>
<linearGradient id="paint0_linear_3621_1510" x1="18" y1="2" x2="18" y2="33.1462" gradientUnits="userSpaceOnUse">
<stop stop-color="#E6CD8B"/>
<stop offset="1" stop-color="#E7AD25"/>
</linearGradient>
<linearGradient id="paint1_linear_3621_1510" x1="18" y1="2" x2="18" y2="33.1462" gradientUnits="userSpaceOnUse">
<stop stop-color="#E6CD8B"/>
<stop offset="1" stop-color="#E7AD25"/>
</linearGradient>
<linearGradient id="paint2_linear_3621_1510" x1="18" y1="2" x2="18" y2="33.1462" gradientUnits="userSpaceOnUse">
<stop stop-color="#E6CD8B"/>
<stop offset="1" stop-color="#E7AD25"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1,9 +0,0 @@
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M28.5233 3.73047H7.47674C3.34745 3.73047 0 7.07792 0 11.2072L0 24.4091C0 28.5384 3.34745 31.8858 7.47674 31.8858H28.5233C32.6526 31.8858 36 28.5384 36 24.4091V11.2072C36 7.07792 32.6526 3.73047 28.5233 3.73047ZM24.2328 18.5115L14.3886 23.2066C14.1263 23.3317 13.8233 23.1404 13.8233 22.8499V13.1663C13.8233 12.8716 14.1343 12.6806 14.3971 12.8138L24.2413 17.8023C24.5339 17.9506 24.5289 18.3703 24.2328 18.5115Z" fill="url(#paint0_linear_3621_1529)"/>
<defs>
<linearGradient id="paint0_linear_3621_1529" x1="18" y1="3.73047" x2="18" y2="31.8858" gradientUnits="userSpaceOnUse">
<stop stop-color="#E6CD8B"/>
<stop offset="1" stop-color="#E7AD25"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 785 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 445 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 291 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 381 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 489 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 406 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 407 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 414 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 379 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 504 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 405 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.6453 3H5.373C5.00886 3 4.65963 3.13876 4.40214 3.38576C4.14465 3.63276 4 3.96776 4 4.31707V19.4634C4.00036 19.7517 4.08674 20.0339 4.24894 20.2766C4.41114 20.5193 4.64238 20.7125 4.91533 20.8332C5.1355 20.944 5.38066 21.0012 5.62929 21C5.95995 20.9896 6.27938 20.8824 6.54462 20.6927L11.4416 17.1805C11.6001 17.0665 11.7928 17.0049 11.9908 17.0049C12.1889 17.0049 12.3816 17.0665 12.54 17.1805L17.4371 20.6927C17.6751 20.8639 17.958 20.9681 18.2543 20.9938C18.5506 21.0194 18.8485 20.9654 19.1145 20.8378C19.3806 20.7101 19.6044 20.514 19.7608 20.2712C19.9172 20.0285 20 19.7488 20 19.4634V4.31707C20 3.97078 19.8579 3.63842 19.6044 3.39192C19.3508 3.14542 19.0063 3.00462 18.6453 3Z" fill="#EEB726"/>
</svg>

After

Width:  |  Height:  |  Size: 818 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.6453 3H5.373C5.00886 3 4.65963 3.13876 4.40214 3.38576C4.14465 3.63276 4 3.96776 4 4.31707V19.4634C4.00036 19.7517 4.08674 20.0339 4.24894 20.2766C4.41114 20.5193 4.64238 20.7125 4.91533 20.8332C5.1355 20.944 5.38066 21.0012 5.62929 21C5.95995 20.9896 6.27938 20.8824 6.54462 20.6927L11.4416 17.1805C11.6001 17.0665 11.7928 17.0049 11.9908 17.0049C12.1889 17.0049 12.3816 17.0665 12.54 17.1805L17.4371 20.6927C17.6751 20.8639 17.958 20.9681 18.2543 20.9938C18.5506 21.0194 18.8485 20.9654 19.1145 20.8378C19.3806 20.7101 19.6044 20.514 19.7608 20.2712C19.9172 20.0285 20 19.7488 20 19.4634V4.31707C20 3.97078 19.8579 3.63842 19.6044 3.39192C19.3508 3.14542 19.0063 3.00462 18.6453 3Z" fill="#908F92"/>
</svg>

After

Width:  |  Height:  |  Size: 818 B

View File

@@ -1,3 +1,3 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M45.5346 41.8605C45.5346 43.488 44.8872 45.0508 43.7374 46.2028C42.5854 47.3526 41.0226 48 39.3951 48H9.60438C7.97684 48 6.41405 47.3526 5.26205 46.2028C4.11229 45.0508 3.46484 43.488 3.46484 41.8605V6.13953C3.46484 4.512 4.11229 2.94921 5.26205 1.79721C6.41405 0.647442 7.97684 0 9.60438 0L31.7731 0C32.809 0 33.8025 0.410791 34.5348 1.1453L44.3893 10.9998C45.1238 11.7321 45.5346 12.7256 45.5346 13.7615V41.8605ZM15.0695 39.0698H25.116C26.0403 39.0698 26.7904 38.3196 26.7904 37.3953C26.7904 36.4711 26.0403 35.7209 25.116 35.7209H15.0695C14.1452 35.7209 13.3951 36.4711 13.3951 37.3953C13.3951 38.3196 14.1452 39.0698 15.0695 39.0698ZM15.0695 31.2558H32.93C33.8542 31.2558 34.6044 30.5057 34.6044 29.5814C34.6044 28.6571 33.8542 27.907 32.93 27.907H15.0695C14.1452 27.907 13.3951 28.6571 13.3951 29.5814C13.3951 30.5057 14.1452 31.2558 15.0695 31.2558ZM33.3721 4.71875V10.5091C33.3721 11.4223 34.1103 12.1635 35.0199 12.1635H40.7871L33.3721 4.71875ZM15.0695 23.4419H32.93C33.8542 23.4419 34.6044 22.6917 34.6044 21.7674C34.6044 20.8432 33.8542 20.093 32.93 20.093H15.0695C14.1452 20.093 13.3951 20.8432 13.3951 21.7674C13.3951 22.6917 14.1452 23.4419 15.0695 23.4419Z" fill="white"/>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.09766 13.3958C7.57534 13.3958 7.97405 13.3965 8.29981 13.4231C8.63965 13.4509 8.95903 13.5106 9.26074 13.6643C9.72355 13.9002 10.1001 14.2767 10.3359 14.7395C10.4896 15.0412 10.5493 15.3607 10.5771 15.7005C10.6037 16.0262 10.6035 16.4249 10.6035 16.9026V17.4934C10.6035 17.9712 10.6037 18.3708 10.5771 18.6966C10.5493 19.0362 10.4894 19.3551 10.3359 19.6565C10.1001 20.1193 9.72347 20.4959 9.26074 20.7317C8.95903 20.8855 8.63965 20.9462 8.29981 20.9739C7.97408 21.0006 7.57529 21.0004 7.09766 21.0003H6.50586C6.0282 21.0004 5.62947 21.0006 5.30371 20.9739C4.96388 20.9462 4.64448 20.8855 4.34277 20.7317C3.88 20.4959 3.5034 20.1194 3.26758 19.6565C3.11405 19.3551 3.05413 19.0362 3.02637 18.6966C2.99975 18.3708 2.99999 17.9712 3 17.4934V16.9026C2.99999 16.4249 2.99977 16.0262 3.02637 15.7005C3.05413 15.3607 3.11388 15.0412 3.26758 14.7395C3.50343 14.2766 3.87991 13.9002 4.34277 13.6643C4.64448 13.5106 4.96388 13.4509 5.30371 13.4231C5.62947 13.3965 6.02818 13.3958 6.50586 13.3958H7.09766ZM16.9375 13.3958C17.4154 13.3958 17.8147 13.3965 18.1406 13.4231C18.4802 13.4509 18.799 13.5107 19.1006 13.6643C19.5634 13.9001 19.9399 14.2766 20.1758 14.7395C20.3295 15.0412 20.3901 15.3606 20.418 15.7005C20.4445 16.0262 20.4443 16.425 20.4443 16.9026V17.4934C20.4443 17.9712 20.4445 18.3708 20.418 18.6966C20.3901 19.0363 20.3294 19.355 20.1758 19.6565C19.9399 20.1195 19.5634 20.4959 19.1006 20.7317C18.799 20.8854 18.4803 20.9462 18.1406 20.9739C17.8148 21.0006 17.4153 21.0004 16.9375 21.0003H16.3467C15.8689 21.0004 15.4694 21.0006 15.1436 20.9739C14.8039 20.9462 14.4852 20.8854 14.1836 20.7317C13.7207 20.4959 13.3443 20.1195 13.1084 19.6565C12.9549 19.355 12.895 19.0362 12.8672 18.6966C12.8406 18.3708 12.8398 17.9712 12.8398 17.4934V16.9026C12.8398 16.425 12.8406 16.0262 12.8672 15.7005C12.895 15.3606 12.9547 15.0412 13.1084 14.7395C13.3443 14.2766 13.7208 13.9001 14.1836 13.6643C14.4851 13.5107 14.804 13.4509 15.1436 13.4231C15.4695 13.3965 15.8688 13.3958 16.3467 13.3958H16.9375ZM15.8818 3.12039C16.3759 2.95986 16.9083 2.95988 17.4023 3.12039C17.7243 3.22501 17.992 3.40858 18.252 3.62918C18.5011 3.84068 18.7834 4.12251 19.1211 4.46024L19.5205 4.85965L19.54 4.87821C19.8778 5.21599 20.1595 5.49817 20.3711 5.74735C20.5918 6.00728 20.7753 6.27589 20.8799 6.59793C21.0404 7.09195 21.0404 7.62444 20.8799 8.11844C20.7753 8.44037 20.5917 8.70819 20.3711 8.96805C20.1596 9.2172 19.8778 9.49943 19.54 9.83719L19.5205 9.85672L19.1406 10.2356L19.1211 10.2552C18.7834 10.593 18.5011 10.8757 18.252 11.0872C17.9921 11.3077 17.7242 11.4904 17.4023 11.595C16.9083 11.7556 16.3759 11.7556 15.8818 11.595C15.56 11.4904 15.292 11.3077 15.0322 11.0872C14.783 10.8756 14.5009 10.593 14.1631 10.2552L14.1436 10.2356L13.7637 9.85672L13.7451 9.83719C13.4072 9.49931 13.1247 9.21727 12.9131 8.96805C12.6925 8.7082 12.5089 8.44036 12.4043 8.11844C12.2438 7.62445 12.2438 7.09194 12.4043 6.59793C12.5089 6.27589 12.6924 6.00728 12.9131 5.74735C13.1246 5.49815 13.4073 5.21603 13.7451 4.87821L14.1631 4.46024C14.5008 4.12249 14.7831 3.84069 15.0322 3.62918C15.2921 3.40856 15.5598 3.22502 15.8818 3.12039ZM7.09766 3.55594C7.57532 3.55593 7.97406 3.5557 8.29981 3.58231C8.63965 3.61008 8.95903 3.67077 9.26074 3.8245C9.72338 4.06033 10.1001 4.43606 10.3359 4.89871C10.4896 5.20038 10.5493 5.51987 10.5771 5.85965C10.6037 6.18537 10.6035 6.58418 10.6035 7.0618V7.6536C10.6035 8.13125 10.6037 8.53 10.5771 8.85575C10.5493 9.19555 10.4896 9.515 10.3359 9.81668C10.1001 10.2795 9.72356 10.6561 9.26074 10.8919C8.95903 11.0457 8.63965 11.1054 8.29981 11.1331C7.97406 11.1597 7.57534 11.1595 7.09766 11.1595H6.50586C6.02819 11.1595 5.62948 11.1597 5.30371 11.1331C4.96388 11.1054 4.64448 11.0457 4.34277 10.8919C3.87991 10.6561 3.50343 10.2795 3.26758 9.81668C3.11388 9.51499 3.05413 9.19556 3.02637 8.85575C2.99977 8.52999 2.99999 8.13126 3 7.6536V7.06278C2.99999 6.58496 2.99975 6.18548 3.02637 5.85965C3.05414 5.51987 3.11387 5.20038 3.26758 4.89871C3.5034 4.43604 3.8801 4.06032 4.34277 3.8245C4.64448 3.67077 4.96388 3.61008 5.30371 3.58231C5.62946 3.5557 6.0282 3.55593 6.50586 3.55594H7.09766Z" fill="#EEB726"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -1,7 +1,3 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M45.5346 41.8605C45.5346 43.488 44.8872 45.0508 43.7374 46.2028C42.5854 47.3526 41.0226 48 39.3951 48H9.60438C7.97684 48 6.41405 47.3526 5.26205 46.2028C4.11229 45.0508 3.46484 43.488 3.46484 41.8605V6.13953C3.46484 4.512 4.11229 2.94921 5.26205 1.79721C6.41405 0.647442 7.97684 0 9.60438 0H31.7731C32.809 0 33.8025 0.410791 34.5348 1.1453L44.3893 10.9998C45.1238 11.7321 45.5346 12.7256 45.5346 13.7615V41.8605ZM42.1858 41.8605V13.7615C42.1858 13.6141 42.1277 13.4713 42.0228 13.3663L32.1683 3.51181C32.0633 3.40688 31.9205 3.34884 31.7731 3.34884H9.60438C8.86317 3.34884 8.15545 3.64353 7.6308 4.16595C7.10838 4.6906 6.81368 5.39833 6.81368 6.13953V41.8605C6.81368 42.6017 7.10838 43.3094 7.6308 43.834C8.15545 44.3565 8.86317 44.6512 9.60438 44.6512H39.3951C40.1363 44.6512 40.844 44.3565 41.3687 43.834C41.8911 43.3094 42.1858 42.6017 42.1858 41.8605Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M30.0234 3.79063C30.0234 2.86635 30.7736 2.11621 31.6979 2.11621C32.6221 2.11621 33.3723 2.86635 33.3723 3.79063V11.6046C33.3723 11.9127 33.6223 12.1627 33.9304 12.1627H41.7444C42.6686 12.1627 43.4188 12.9129 43.4188 13.8371C43.4188 14.7614 42.6686 15.5116 41.7444 15.5116H33.9304C31.7715 15.5116 30.0234 13.7635 30.0234 11.6046V3.79063Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.0699 22.3256C14.1456 22.3256 13.3955 21.5755 13.3955 20.6512C13.3955 19.7269 14.1456 18.9768 15.0699 18.9768H32.9304C33.8547 18.9768 34.6048 19.7269 34.6048 20.6512C34.6048 21.5755 33.8547 22.3256 32.9304 22.3256H15.0699Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.0699 30.1396C14.1456 30.1396 13.3955 29.3895 13.3955 28.4652C13.3955 27.5409 14.1456 26.7908 15.0699 26.7908H32.9304C33.8547 26.7908 34.6048 27.5409 34.6048 28.4652C34.6048 29.3895 33.8547 30.1396 32.9304 30.1396H15.0699Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.0699 37.9536C14.1456 37.9536 13.3955 37.2034 13.3955 36.2792C13.3955 35.3549 14.1456 34.6047 15.0699 34.6047H25.1164C26.0407 34.6047 26.7909 35.3549 26.7909 36.2792C26.7909 37.2034 26.0407 37.9536 25.1164 37.9536H15.0699Z" fill="white"/>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.09766 13.3958C7.57534 13.3958 7.97405 13.3965 8.29981 13.4231C8.63965 13.4509 8.95903 13.5106 9.26074 13.6643C9.72355 13.9002 10.1001 14.2767 10.3359 14.7395C10.4896 15.0412 10.5493 15.3607 10.5771 15.7005C10.6037 16.0262 10.6035 16.4249 10.6035 16.9026V17.4934C10.6035 17.9712 10.6037 18.3708 10.5771 18.6966C10.5493 19.0362 10.4894 19.3551 10.3359 19.6565C10.1001 20.1193 9.72347 20.4959 9.26074 20.7317C8.95903 20.8855 8.63965 20.9462 8.29981 20.9739C7.97408 21.0006 7.57529 21.0004 7.09766 21.0003H6.50586C6.0282 21.0004 5.62947 21.0006 5.30371 20.9739C4.96388 20.9462 4.64448 20.8855 4.34277 20.7317C3.88 20.4959 3.5034 20.1194 3.26758 19.6565C3.11405 19.3551 3.05413 19.0362 3.02637 18.6966C2.99975 18.3708 2.99999 17.9712 3 17.4934V16.9026C2.99999 16.4249 2.99977 16.0262 3.02637 15.7005C3.05413 15.3607 3.11388 15.0412 3.26758 14.7395C3.50343 14.2766 3.87991 13.9002 4.34277 13.6643C4.64448 13.5106 4.96388 13.4509 5.30371 13.4231C5.62947 13.3965 6.02818 13.3958 6.50586 13.3958H7.09766ZM16.9375 13.3958C17.4154 13.3958 17.8147 13.3965 18.1406 13.4231C18.4802 13.4509 18.799 13.5107 19.1006 13.6643C19.5634 13.9001 19.9399 14.2766 20.1758 14.7395C20.3295 15.0412 20.3901 15.3606 20.418 15.7005C20.4445 16.0262 20.4443 16.425 20.4443 16.9026V17.4934C20.4443 17.9712 20.4445 18.3708 20.418 18.6966C20.3901 19.0363 20.3294 19.355 20.1758 19.6565C19.9399 20.1195 19.5634 20.4959 19.1006 20.7317C18.799 20.8854 18.4803 20.9462 18.1406 20.9739C17.8148 21.0006 17.4153 21.0004 16.9375 21.0003H16.3467C15.8689 21.0004 15.4694 21.0006 15.1436 20.9739C14.8039 20.9462 14.4852 20.8854 14.1836 20.7317C13.7207 20.4959 13.3443 20.1195 13.1084 19.6565C12.9549 19.355 12.895 19.0362 12.8672 18.6966C12.8406 18.3708 12.8398 17.9712 12.8398 17.4934V16.9026C12.8398 16.425 12.8406 16.0262 12.8672 15.7005C12.895 15.3606 12.9547 15.0412 13.1084 14.7395C13.3443 14.2766 13.7208 13.9001 14.1836 13.6643C14.4851 13.5107 14.804 13.4509 15.1436 13.4231C15.4695 13.3965 15.8688 13.3958 16.3467 13.3958H16.9375ZM15.8818 3.12039C16.3759 2.95986 16.9083 2.95988 17.4023 3.12039C17.7243 3.22501 17.992 3.40858 18.252 3.62918C18.5011 3.84068 18.7834 4.12251 19.1211 4.46024L19.5205 4.85965L19.54 4.87821C19.8778 5.21599 20.1595 5.49817 20.3711 5.74735C20.5918 6.00728 20.7753 6.27589 20.8799 6.59793C21.0404 7.09195 21.0404 7.62444 20.8799 8.11844C20.7753 8.44037 20.5917 8.70819 20.3711 8.96805C20.1596 9.2172 19.8778 9.49943 19.54 9.83719L19.5205 9.85672L19.1406 10.2356L19.1211 10.2552C18.7834 10.593 18.5011 10.8757 18.252 11.0872C17.9921 11.3077 17.7242 11.4904 17.4023 11.595C16.9083 11.7556 16.3759 11.7556 15.8818 11.595C15.56 11.4904 15.292 11.3077 15.0322 11.0872C14.783 10.8756 14.5009 10.593 14.1631 10.2552L14.1436 10.2356L13.7637 9.85672L13.7451 9.83719C13.4072 9.49931 13.1247 9.21727 12.9131 8.96805C12.6925 8.7082 12.5089 8.44036 12.4043 8.11844C12.2438 7.62445 12.2438 7.09194 12.4043 6.59793C12.5089 6.27589 12.6924 6.00728 12.9131 5.74735C13.1246 5.49815 13.4073 5.21603 13.7451 4.87821L14.1631 4.46024C14.5008 4.12249 14.7831 3.84069 15.0322 3.62918C15.2921 3.40856 15.5598 3.22502 15.8818 3.12039ZM7.09766 3.55594C7.57532 3.55593 7.97406 3.5557 8.29981 3.58231C8.63965 3.61008 8.95903 3.67077 9.26074 3.8245C9.72338 4.06033 10.1001 4.43606 10.3359 4.89871C10.4896 5.20038 10.5493 5.51987 10.5771 5.85965C10.6037 6.18537 10.6035 6.58418 10.6035 7.0618V7.6536C10.6035 8.13125 10.6037 8.53 10.5771 8.85575C10.5493 9.19555 10.4896 9.515 10.3359 9.81668C10.1001 10.2795 9.72356 10.6561 9.26074 10.8919C8.95903 11.0457 8.63965 11.1054 8.29981 11.1331C7.97406 11.1597 7.57534 11.1595 7.09766 11.1595H6.50586C6.02819 11.1595 5.62948 11.1597 5.30371 11.1331C4.96388 11.1054 4.64448 11.0457 4.34277 10.8919C3.87991 10.6561 3.50343 10.2795 3.26758 9.81668C3.11388 9.51499 3.05413 9.19556 3.02637 8.85575C2.99977 8.52999 2.99999 8.13126 3 7.6536V7.06278C2.99999 6.58496 2.99975 6.18548 3.02637 5.85965C3.05414 5.51987 3.11387 5.20038 3.26758 4.89871C3.5034 4.43604 3.8801 4.06032 4.34277 3.8245C4.64448 3.67077 4.96388 3.61008 5.30371 3.58231C5.62946 3.5557 6.0282 3.55593 6.50586 3.55594H7.09766Z" fill="#908F92"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -1,3 +0,0 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.38367 8.28389C7.6745 5.72389 10.8178 4.31396 14.2353 4.31396C16.7899 4.31396 19.1293 5.14608 21.1889 6.78699C22.2281 7.61528 23.1697 8.62863 24 9.8114C24.8299 8.62898 25.7719 7.61528 26.8114 6.78699C28.8706 5.14608 31.2101 4.31396 33.7646 4.31396C37.1821 4.31396 40.3258 5.72389 42.6166 8.28389C44.8801 10.814 46.127 14.2704 46.127 18.017C46.127 21.8732 44.7322 25.4031 41.7378 29.126C39.059 32.4562 35.209 35.8368 30.7506 39.7514C29.2282 41.0883 27.5026 42.6036 25.7107 44.2178C25.2374 44.645 24.63 44.8801 24 44.8801C23.3703 44.8801 22.7625 44.645 22.2899 44.2185C20.4981 42.604 18.7714 41.088 17.2484 39.7504C12.7906 35.8365 8.94061 32.4562 6.26185 29.1257C3.2674 25.4031 1.87298 21.8732 1.87298 18.0167C1.87298 14.2704 3.11985 10.814 5.38367 8.28389Z" fill="white" stroke="white" stroke-width="2.4"/>
</svg>

Before

Width:  |  Height:  |  Size: 920 B

View File

@@ -1,3 +0,0 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.38367 8.28389C7.6745 5.72389 10.8178 4.31396 14.2353 4.31396C16.7899 4.31396 19.1293 5.14608 21.1889 6.78699C22.2281 7.61528 23.1697 8.62863 24 9.8114C24.8299 8.62898 25.7719 7.61528 26.8114 6.78699C28.8706 5.14608 31.2101 4.31396 33.7646 4.31396C37.1821 4.31396 40.3258 5.72389 42.6166 8.28389C44.8801 10.814 46.127 14.2704 46.127 18.017C46.127 21.8732 44.7322 25.4031 41.7378 29.126C39.059 32.4562 35.209 35.8368 30.7506 39.7514C29.2282 41.0883 27.5026 42.6036 25.7107 44.2178C25.2374 44.645 24.63 44.8801 24 44.8801C23.3703 44.8801 22.7625 44.645 22.2899 44.2185C20.4981 42.604 18.7714 41.088 17.2484 39.7504C12.7906 35.8365 8.94061 32.4562 6.26185 29.1257C3.2674 25.4031 1.87298 21.8732 1.87298 18.0167C1.87298 14.2704 3.11985 10.814 5.38367 8.28389Z" stroke="white" stroke-width="2.4"/>
</svg>

Before

Width:  |  Height:  |  Size: 907 B

View File

@@ -1,10 +1,3 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3644_75)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M44.3496 13.3879L31.4232 2.70005C27.0689 -0.900018 20.9311 -0.900018 16.5769 2.70005L3.65033 13.3879C1.32142 15.3134 0 18.2329 0 21.2791V37.9462C0 43.3584 4.16196 48 9.6 48H14.4C17.051 48 19.2 45.851 19.2 43.2V35.3947C19.2 32.3527 21.485 30.1409 24 30.1409C26.515 30.1409 28.8 32.3527 28.8 35.3947V43.2C28.8 45.851 30.949 48 33.6 48H38.4C43.8382 48 48 43.3584 48 37.9462V21.2791C48 18.2329 46.6786 15.3134 44.3496 13.3879Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_3644_75">
<rect width="48" height="48" fill="white"/>
</clipPath>
</defs>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.6311 8.02044L14.7837 4.01252C13.1508 2.66249 10.8492 2.66249 9.21633 4.01252L4.36887 8.02044C3.49553 8.74253 3 9.83735 3 10.9797V17.2298C3 19.2594 4.56073 21 6.6 21H8.4C9.39411 21 10.2 20.1941 10.2 19.2V16.273C10.2 15.1323 11.0569 14.3028 12 14.3028C12.9431 14.3028 13.8 15.1323 13.8 16.273V19.2C13.8 20.1941 14.6059 21 15.6 21H17.4C19.4393 21 21 19.2594 21 17.2298V10.9797C21 9.83736 20.5045 8.74253 19.6311 8.02044Z" fill="#EEB726"/>
</svg>

Before

Width:  |  Height:  |  Size: 733 B

After

Width:  |  Height:  |  Size: 592 B

Some files were not shown because too many files have changed in this diff Show More