From b265a57541989cf0d2de0d8978c1177528cc93be Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 00:05:37 +0800 Subject: [PATCH 01/89] docs: design china-friendly wallet login --- ...6-01-china-friendly-wallet-login-design.md | 367 ++++++++++++++++++ 1 file changed, 367 insertions(+) create mode 100644 .unipi/docs/specs/2026-06-01-china-friendly-wallet-login-design.md diff --git a/.unipi/docs/specs/2026-06-01-china-friendly-wallet-login-design.md b/.unipi/docs/specs/2026-06-01-china-friendly-wallet-login-design.md new file mode 100644 index 0000000..12dc992 --- /dev/null +++ b/.unipi/docs/specs/2026-06-01-china-friendly-wallet-login-design.md @@ -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. From df200053574d6421ad707376f30674fb369f9266 Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 00:14:10 +0800 Subject: [PATCH 02/89] docs: design user favorites --- .../specs/2026-06-01-user-favorites-design.md | 504 ++++++++++++++++++ 1 file changed, 504 insertions(+) create mode 100644 .unipi/docs/specs/2026-06-01-user-favorites-design.md diff --git a/.unipi/docs/specs/2026-06-01-user-favorites-design.md b/.unipi/docs/specs/2026-06-01-user-favorites-design.md new file mode 100644 index 0000000..6165a70 --- /dev/null +++ b/.unipi/docs/specs/2026-06-01-user-favorites-design.md @@ -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 +``` + +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. From 71dac8373e18d002a5bd9054d7f825833d072858 Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 00:28:22 +0800 Subject: [PATCH 03/89] feat: add wallet provider foundation --- .env.example | 4 + package-lock.json | 7833 +++++++++++++++++++++++++- package.json | 7 +- src/App.tsx | 152 +- src/locales/en.ts | 3 + src/locales/id.ts | 3 + src/locales/ja.ts | 3 + src/locales/ko.ts | 3 + src/locales/ms.ts | 3 + src/locales/vi.ts | 3 + src/locales/zh-CN.ts | 3 + src/vite-env.d.ts | 1 + src/wallet/RainbowWalletProvider.tsx | 68 + src/wallet/WalletProvider.tsx | 136 + src/wallet/api.ts | 72 + src/wallet/injected.ts | 39 + src/wallet/token.ts | 28 + 17 files changed, 8276 insertions(+), 85 deletions(-) create mode 100644 src/wallet/RainbowWalletProvider.tsx create mode 100644 src/wallet/WalletProvider.tsx create mode 100644 src/wallet/api.ts create mode 100644 src/wallet/injected.ts create mode 100644 src/wallet/token.ts diff --git a/.env.example b/.env.example index 3b42c74..c8439ac 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,7 @@ 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= diff --git a/package-lock.json b/package-lock.json index 1fd805d..0d9ebd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,16 @@ "name": "ark-database-web", "version": "1.0.0", "dependencies": { + "@rainbow-me/rainbowkit": "^2.2.11", + "@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" + "react-router-dom": "^6.28.0", + "viem": "^2.52.0", + "wagmi": "^2.19.5" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", @@ -38,6 +43,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -340,7 +351,6 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -394,6 +404,85 @@ "node": ">=6.9.0" } }, + "node_modules/@base-org/account": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@base-org/account/-/account-2.4.0.tgz", + "integrity": "sha512-A4Umpi8B9/pqR78D1Yoze4xHyQaujioVRqqO3d6xuDFw9VRtjg6tK3bPlwE0aW+nVH/ntllCpPa2PbI8Rnjcug==", + "license": "Apache-2.0", + "dependencies": { + "@coinbase/cdp-sdk": "^1.0.0", + "@noble/hashes": "1.4.0", + "clsx": "1.2.1", + "eventemitter3": "5.0.1", + "idb-keyval": "6.2.1", + "ox": "0.6.9", + "preact": "10.24.2", + "viem": "^2.31.7", + "zustand": "5.0.3" + } + }, + "node_modules/@base-org/account/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@base-org/account/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@base-org/account/node_modules/ox": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.6.9.tgz", + "integrity": "sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.10.1", + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@scure/bip32": "^1.5.0", + "@scure/bip39": "^1.4.0", + "abitype": "^1.0.6", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@base-org/account/node_modules/ox/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -407,6 +496,125 @@ "specificity": "bin/cli.js" } }, + "node_modules/@coinbase/cdp-sdk": { + "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.16.0", + "axios-retry": "^4.5.0", + "bs58": "^6.0.0", + "jose": "^6.2.0", + "md5": "^2.3.0", + "uncrypto": "^0.1.3", + "viem": "^2.47.0", + "zod": "^3.25.76" + } + }, + "node_modules/@coinbase/cdp-sdk/node_modules/abitype": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.6.tgz", + "integrity": "sha512-MMSqYh4+C/aVqI2RQaWqbvI4Kxo5cQV40WQ4QFtDnNzCkqChm8MuENhElmynZlO0qUy/ObkEUaXtKqYnx1Kp3A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3 >=3.22.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@coinbase/wallet-sdk": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@coinbase/wallet-sdk/-/wallet-sdk-4.3.6.tgz", + "integrity": "sha512-4q8BNG1ViL4mSAAvPAtpwlOs1gpC+67eQtgIwNvT3xyeyFFd+guwkc8bcX5rTmQhXpqnhzC4f0obACbP9CqMSA==", + "license": "Apache-2.0", + "dependencies": { + "@noble/hashes": "1.4.0", + "clsx": "1.2.1", + "eventemitter3": "5.0.1", + "idb-keyval": "6.2.1", + "ox": "0.6.9", + "preact": "10.24.2", + "viem": "^2.27.2", + "zustand": "5.0.3" + } + }, + "node_modules/@coinbase/wallet-sdk/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@coinbase/wallet-sdk/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@coinbase/wallet-sdk/node_modules/ox": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.6.9.tgz", + "integrity": "sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.10.1", + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@scure/bip32": "^1.5.0", + "@scure/bip39": "^1.4.0", + "abitype": "^1.0.6", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@coinbase/wallet-sdk/node_modules/ox/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", @@ -547,6 +755,26 @@ "node": ">=20.19.0" } }, + "node_modules/@ecies/ciphers": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.6.tgz", + "integrity": "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==", + "license": "MIT", + "engines": { + "bun": ">=1", + "deno": ">=2.7.10", + "node": ">=16" + }, + "peerDependencies": { + "@noble/ciphers": "^1.0.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -938,6 +1166,57 @@ "node": ">=12" } }, + "node_modules/@ethereumjs/common": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@ethereumjs/common/-/common-3.2.0.tgz", + "integrity": "sha512-pksvzI0VyLgmuEF2FA/JR/4/y6hcPq8OUail3/AvycBaW1d5VSauOZzqGvJ3RTmR4MU35lWE8KseKOsEhrFRBA==", + "license": "MIT", + "dependencies": { + "@ethereumjs/util": "^8.1.0", + "crc-32": "^1.2.0" + } + }, + "node_modules/@ethereumjs/rlp": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@ethereumjs/rlp/-/rlp-4.0.1.tgz", + "integrity": "sha512-tqsQiBQDQdmPWE1xkkBq4rlSW5QZpLOUJ5RJh2/9fug+q9tnUhuZoVLk7s0scUIKTOzEtR72DFBXI4WiZcMpvw==", + "license": "MPL-2.0", + "bin": { + "rlp": "bin/rlp" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@ethereumjs/tx": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ethereumjs/tx/-/tx-4.2.0.tgz", + "integrity": "sha512-1nc6VO4jtFd172BbSnTnDQVr9IYBFl1y4xPzZdtkrkKIncBCkdbgfdRV+MiTkJYAtTxvV12GRZLqBFT1PNK6Yw==", + "license": "MPL-2.0", + "dependencies": { + "@ethereumjs/common": "^3.2.0", + "@ethereumjs/rlp": "^4.0.1", + "@ethereumjs/util": "^8.1.0", + "ethereum-cryptography": "^2.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@ethereumjs/util": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@ethereumjs/util/-/util-8.1.0.tgz", + "integrity": "sha512-zQ0IqbdX8FZ9aw11vP+dZkKDkS+kgIvQPHnSAXzP9pLu+Rfu3D3XEeLbicvoXJTYnhZiPmsZUxgdzXwNKxRPbA==", + "license": "MPL-2.0", + "dependencies": { + "@ethereumjs/rlp": "^4.0.1", + "ethereum-cryptography": "^2.0.0", + "micro-ftch": "^0.3.1" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/@exodus/bytes": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", @@ -956,6 +1235,19 @@ } } }, + "node_modules/@gemini-wallet/core": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@gemini-wallet/core/-/core-0.3.2.tgz", + "integrity": "sha512-Z4aHi3ECFf5oWYWM3F1rW83GJfB9OvhBYPTmb5q+VyK3uvzvS48lwo+jwh2eOoCRWEuT/crpb9Vwp2QaS5JqgQ==", + "license": "MIT", + "dependencies": { + "@metamask/rpc-errors": "7.0.2", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "viem": ">=2.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1006,6 +1298,653 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lit-labs/ssr-dom-shim": { + "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": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz", + "integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0" + } + }, + "node_modules/@metamask/eth-json-rpc-provider": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@metamask/eth-json-rpc-provider/-/eth-json-rpc-provider-1.0.1.tgz", + "integrity": "sha512-whiUMPlAOrVGmX8aKYVPvlKyG4CpQXiNNyt74vE1xb5sPvmx5oA7B/kOi/JdBvhGQq97U1/AVdXEdk2zkP8qyA==", + "dependencies": { + "@metamask/json-rpc-engine": "^7.0.0", + "@metamask/safe-event-emitter": "^3.0.0", + "@metamask/utils": "^5.0.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@metamask/eth-json-rpc-provider/node_modules/@metamask/json-rpc-engine": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/@metamask/json-rpc-engine/-/json-rpc-engine-7.3.3.tgz", + "integrity": "sha512-dwZPq8wx9yV3IX2caLi9q9xZBw2XeIoYqdyihDDDpuHVCEiqadJLwqM3zy+uwf6F1QYQ65A8aOMQg1Uw7LMLNg==", + "license": "ISC", + "dependencies": { + "@metamask/rpc-errors": "^6.2.1", + "@metamask/safe-event-emitter": "^3.0.0", + "@metamask/utils": "^8.3.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@metamask/eth-json-rpc-provider/node_modules/@metamask/json-rpc-engine/node_modules/@metamask/utils": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@metamask/utils/-/utils-8.5.0.tgz", + "integrity": "sha512-I6bkduevXb72TIM9q2LRO63JSsF9EXduh3sBr9oybNX2hNNpr/j1tEjXrsG0Uabm4MJ1xkGAQEMwifvKZIkyxQ==", + "license": "ISC", + "dependencies": { + "@ethereumjs/tx": "^4.2.0", + "@metamask/superstruct": "^3.0.0", + "@noble/hashes": "^1.3.1", + "@scure/base": "^1.1.3", + "@types/debug": "^4.1.7", + "debug": "^4.3.4", + "pony-cause": "^2.1.10", + "semver": "^7.5.4", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@metamask/eth-json-rpc-provider/node_modules/@metamask/rpc-errors": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@metamask/rpc-errors/-/rpc-errors-6.4.0.tgz", + "integrity": "sha512-1ugFO1UoirU2esS3juZanS/Fo8C8XYocCuBpfZI5N7ECtoG+zu0wF+uWZASik6CkO6w9n/Iebt4iI4pT0vptpg==", + "license": "MIT", + "dependencies": { + "@metamask/utils": "^9.0.0", + "fast-safe-stringify": "^2.0.6" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@metamask/eth-json-rpc-provider/node_modules/@metamask/rpc-errors/node_modules/@metamask/utils": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@metamask/utils/-/utils-9.3.0.tgz", + "integrity": "sha512-w8CVbdkDrVXFJbfBSlDfafDR6BAkpDmv1bC1UJVCoVny5tW2RKAdn9i68Xf7asYT4TnUhl/hN4zfUiKQq9II4g==", + "license": "ISC", + "dependencies": { + "@ethereumjs/tx": "^4.2.0", + "@metamask/superstruct": "^3.1.0", + "@noble/hashes": "^1.3.1", + "@scure/base": "^1.1.3", + "@types/debug": "^4.1.7", + "debug": "^4.3.4", + "pony-cause": "^2.1.10", + "semver": "^7.5.4", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@metamask/eth-json-rpc-provider/node_modules/@metamask/utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@metamask/utils/-/utils-5.0.2.tgz", + "integrity": "sha512-yfmE79bRQtnMzarnKfX7AEJBwFTxvTyw3nBQlu/5rmGXrjAeAMltoGxO62TFurxrQAFMNa/fEjIHNvungZp0+g==", + "license": "ISC", + "dependencies": { + "@ethereumjs/tx": "^4.1.2", + "@types/debug": "^4.1.7", + "debug": "^4.3.4", + "semver": "^7.3.8", + "superstruct": "^1.0.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@metamask/eth-json-rpc-provider/node_modules/semver": { + "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" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@metamask/eth-json-rpc-provider/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@metamask/json-rpc-engine": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@metamask/json-rpc-engine/-/json-rpc-engine-8.0.2.tgz", + "integrity": "sha512-IoQPmql8q7ABLruW7i4EYVHWUbF74yrp63bRuXV5Zf9BQwcn5H9Ww1eLtROYvI1bUXwOiHZ6qT5CWTrDc/t/AA==", + "license": "ISC", + "dependencies": { + "@metamask/rpc-errors": "^6.2.1", + "@metamask/safe-event-emitter": "^3.0.0", + "@metamask/utils": "^8.3.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@metamask/json-rpc-engine/node_modules/@metamask/rpc-errors": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@metamask/rpc-errors/-/rpc-errors-6.4.0.tgz", + "integrity": "sha512-1ugFO1UoirU2esS3juZanS/Fo8C8XYocCuBpfZI5N7ECtoG+zu0wF+uWZASik6CkO6w9n/Iebt4iI4pT0vptpg==", + "license": "MIT", + "dependencies": { + "@metamask/utils": "^9.0.0", + "fast-safe-stringify": "^2.0.6" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@metamask/json-rpc-engine/node_modules/@metamask/rpc-errors/node_modules/@metamask/utils": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@metamask/utils/-/utils-9.3.0.tgz", + "integrity": "sha512-w8CVbdkDrVXFJbfBSlDfafDR6BAkpDmv1bC1UJVCoVny5tW2RKAdn9i68Xf7asYT4TnUhl/hN4zfUiKQq9II4g==", + "license": "ISC", + "dependencies": { + "@ethereumjs/tx": "^4.2.0", + "@metamask/superstruct": "^3.1.0", + "@noble/hashes": "^1.3.1", + "@scure/base": "^1.1.3", + "@types/debug": "^4.1.7", + "debug": "^4.3.4", + "pony-cause": "^2.1.10", + "semver": "^7.5.4", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@metamask/json-rpc-engine/node_modules/@metamask/utils": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@metamask/utils/-/utils-8.5.0.tgz", + "integrity": "sha512-I6bkduevXb72TIM9q2LRO63JSsF9EXduh3sBr9oybNX2hNNpr/j1tEjXrsG0Uabm4MJ1xkGAQEMwifvKZIkyxQ==", + "license": "ISC", + "dependencies": { + "@ethereumjs/tx": "^4.2.0", + "@metamask/superstruct": "^3.0.0", + "@noble/hashes": "^1.3.1", + "@scure/base": "^1.1.3", + "@types/debug": "^4.1.7", + "debug": "^4.3.4", + "pony-cause": "^2.1.10", + "semver": "^7.5.4", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@metamask/json-rpc-engine/node_modules/semver": { + "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" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@metamask/json-rpc-engine/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@metamask/json-rpc-middleware-stream": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@metamask/json-rpc-middleware-stream/-/json-rpc-middleware-stream-7.0.2.tgz", + "integrity": "sha512-yUdzsJK04Ev98Ck4D7lmRNQ8FPioXYhEUZOMS01LXW8qTvPGiRVXmVltj2p4wrLkh0vW7u6nv0mNl5xzC5Qmfg==", + "license": "ISC", + "dependencies": { + "@metamask/json-rpc-engine": "^8.0.2", + "@metamask/safe-event-emitter": "^3.0.0", + "@metamask/utils": "^8.3.0", + "readable-stream": "^3.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@metamask/json-rpc-middleware-stream/node_modules/@metamask/utils": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@metamask/utils/-/utils-8.5.0.tgz", + "integrity": "sha512-I6bkduevXb72TIM9q2LRO63JSsF9EXduh3sBr9oybNX2hNNpr/j1tEjXrsG0Uabm4MJ1xkGAQEMwifvKZIkyxQ==", + "license": "ISC", + "dependencies": { + "@ethereumjs/tx": "^4.2.0", + "@metamask/superstruct": "^3.0.0", + "@noble/hashes": "^1.3.1", + "@scure/base": "^1.1.3", + "@types/debug": "^4.1.7", + "debug": "^4.3.4", + "pony-cause": "^2.1.10", + "semver": "^7.5.4", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@metamask/json-rpc-middleware-stream/node_modules/semver": { + "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" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@metamask/json-rpc-middleware-stream/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@metamask/object-multiplex": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@metamask/object-multiplex/-/object-multiplex-2.1.0.tgz", + "integrity": "sha512-4vKIiv0DQxljcXwfpnbsXcfa5glMj5Zg9mqn4xpIWqkv6uJ2ma5/GtUfLFSxhlxnR8asRMv8dDmWya1Tc1sDFA==", + "license": "ISC", + "dependencies": { + "once": "^1.4.0", + "readable-stream": "^3.6.2" + }, + "engines": { + "node": "^16.20 || ^18.16 || >=20" + } + }, + "node_modules/@metamask/onboarding": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@metamask/onboarding/-/onboarding-1.0.1.tgz", + "integrity": "sha512-FqHhAsCI+Vacx2qa5mAFcWNSrTcVGMNjzxVgaX8ECSny/BJ9/vgXP9V7WF/8vb9DltPeQkxr+Fnfmm6GHfmdTQ==", + "license": "MIT", + "dependencies": { + "bowser": "^2.9.0" + } + }, + "node_modules/@metamask/providers": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/@metamask/providers/-/providers-16.1.0.tgz", + "integrity": "sha512-znVCvux30+3SaUwcUGaSf+pUckzT5ukPRpcBmy+muBLC0yaWnBcvDqGfcsw6CBIenUdFrVoAFa8B6jsuCY/a+g==", + "license": "MIT", + "dependencies": { + "@metamask/json-rpc-engine": "^8.0.1", + "@metamask/json-rpc-middleware-stream": "^7.0.1", + "@metamask/object-multiplex": "^2.0.0", + "@metamask/rpc-errors": "^6.2.1", + "@metamask/safe-event-emitter": "^3.1.1", + "@metamask/utils": "^8.3.0", + "detect-browser": "^5.2.0", + "extension-port-stream": "^3.0.0", + "fast-deep-equal": "^3.1.3", + "is-stream": "^2.0.0", + "readable-stream": "^3.6.2", + "webextension-polyfill": "^0.10.0" + }, + "engines": { + "node": "^18.18 || >=20" + } + }, + "node_modules/@metamask/providers/node_modules/@metamask/rpc-errors": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@metamask/rpc-errors/-/rpc-errors-6.4.0.tgz", + "integrity": "sha512-1ugFO1UoirU2esS3juZanS/Fo8C8XYocCuBpfZI5N7ECtoG+zu0wF+uWZASik6CkO6w9n/Iebt4iI4pT0vptpg==", + "license": "MIT", + "dependencies": { + "@metamask/utils": "^9.0.0", + "fast-safe-stringify": "^2.0.6" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@metamask/providers/node_modules/@metamask/rpc-errors/node_modules/@metamask/utils": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@metamask/utils/-/utils-9.3.0.tgz", + "integrity": "sha512-w8CVbdkDrVXFJbfBSlDfafDR6BAkpDmv1bC1UJVCoVny5tW2RKAdn9i68Xf7asYT4TnUhl/hN4zfUiKQq9II4g==", + "license": "ISC", + "dependencies": { + "@ethereumjs/tx": "^4.2.0", + "@metamask/superstruct": "^3.1.0", + "@noble/hashes": "^1.3.1", + "@scure/base": "^1.1.3", + "@types/debug": "^4.1.7", + "debug": "^4.3.4", + "pony-cause": "^2.1.10", + "semver": "^7.5.4", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@metamask/providers/node_modules/@metamask/utils": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@metamask/utils/-/utils-8.5.0.tgz", + "integrity": "sha512-I6bkduevXb72TIM9q2LRO63JSsF9EXduh3sBr9oybNX2hNNpr/j1tEjXrsG0Uabm4MJ1xkGAQEMwifvKZIkyxQ==", + "license": "ISC", + "dependencies": { + "@ethereumjs/tx": "^4.2.0", + "@metamask/superstruct": "^3.0.0", + "@noble/hashes": "^1.3.1", + "@scure/base": "^1.1.3", + "@types/debug": "^4.1.7", + "debug": "^4.3.4", + "pony-cause": "^2.1.10", + "semver": "^7.5.4", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@metamask/providers/node_modules/semver": { + "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" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@metamask/providers/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@metamask/rpc-errors": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@metamask/rpc-errors/-/rpc-errors-7.0.2.tgz", + "integrity": "sha512-YYYHsVYd46XwY2QZzpGeU4PSdRhHdxnzkB8piWGvJW2xbikZ3R+epAYEL4q/K8bh9JPTucsUdwRFnACor1aOYw==", + "license": "MIT", + "dependencies": { + "@metamask/utils": "^11.0.1", + "fast-safe-stringify": "^2.0.6" + }, + "engines": { + "node": "^18.20 || ^20.17 || >=22" + } + }, + "node_modules/@metamask/safe-event-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@metamask/safe-event-emitter/-/safe-event-emitter-3.1.2.tgz", + "integrity": "sha512-5yb2gMI1BDm0JybZezeoX/3XhPDOtTbcFvpTXM9kxsoZjPZFh4XciqRbpD6N86HYZqWDhEaKUDuOyR0sQHEjMA==", + "license": "ISC", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@metamask/sdk": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@metamask/sdk/-/sdk-0.33.1.tgz", + "integrity": "sha512-1mcOQVGr9rSrVcbKPNVzbZ8eCl1K0FATsYH3WJ/MH4WcZDWGECWrXJPNMZoEAkLxWiMe8jOQBumg2pmcDa9zpQ==", + "deprecated": "No longer maintained, superseded by https://docs.metamask.io/metamask-connect", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@metamask/onboarding": "^1.0.1", + "@metamask/providers": "16.1.0", + "@metamask/sdk-analytics": "0.0.5", + "@metamask/sdk-communication-layer": "0.33.1", + "@metamask/sdk-install-modal-web": "0.32.1", + "@paulmillr/qr": "^0.2.1", + "bowser": "^2.9.0", + "cross-fetch": "^4.0.0", + "debug": "4.3.4", + "eciesjs": "^0.4.11", + "eth-rpc-errors": "^4.0.3", + "eventemitter2": "^6.4.9", + "obj-multiplex": "^1.0.0", + "pump": "^3.0.0", + "readable-stream": "^3.6.2", + "socket.io-client": "^4.5.1", + "tslib": "^2.6.0", + "util": "^0.12.4", + "uuid": "^8.3.2" + } + }, + "node_modules/@metamask/sdk-analytics": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@metamask/sdk-analytics/-/sdk-analytics-0.0.5.tgz", + "integrity": "sha512-fDah+keS1RjSUlC8GmYXvx6Y26s3Ax1U9hGpWb6GSY5SAdmTSIqp2CvYy6yW0WgLhnYhW+6xERuD0eVqV63QIQ==", + "deprecated": "No longer maintained, superseded by @metamask/connect-analytics", + "license": "MIT", + "dependencies": { + "openapi-fetch": "^0.13.5" + } + }, + "node_modules/@metamask/sdk-communication-layer": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@metamask/sdk-communication-layer/-/sdk-communication-layer-0.33.1.tgz", + "integrity": "sha512-0bI9hkysxcfbZ/lk0T2+aKVo1j0ynQVTuB3sJ5ssPWlz+Z3VwveCkP1O7EVu1tsVVCb0YV5WxK9zmURu2FIiaA==", + "deprecated": "No longer maintained, superseded by https://docs.metamask.io/metamask-connect", + "dependencies": { + "@metamask/sdk-analytics": "0.0.5", + "bufferutil": "^4.0.8", + "date-fns": "^2.29.3", + "debug": "4.3.4", + "utf-8-validate": "^5.0.2", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "cross-fetch": "^4.0.0", + "eciesjs": "*", + "eventemitter2": "^6.4.9", + "readable-stream": "^3.6.2", + "socket.io-client": "^4.5.1" + } + }, + "node_modules/@metamask/sdk-communication-layer/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@metamask/sdk-communication-layer/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "license": "MIT" + }, + "node_modules/@metamask/sdk-install-modal-web": { + "version": "0.32.1", + "resolved": "https://registry.npmjs.org/@metamask/sdk-install-modal-web/-/sdk-install-modal-web-0.32.1.tgz", + "integrity": "sha512-MGmAo6qSjf1tuYXhCu2EZLftq+DSt5Z7fsIKr2P+lDgdTPWgLfZB1tJKzNcwKKOdf6q9Qmmxn7lJuI/gq5LrKw==", + "deprecated": "No longer maintained, superseded by https://docs.metamask.io/metamask-connect", + "dependencies": { + "@paulmillr/qr": "^0.2.1" + } + }, + "node_modules/@metamask/sdk/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@metamask/sdk/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "license": "MIT" + }, + "node_modules/@metamask/superstruct": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@metamask/superstruct/-/superstruct-3.2.1.tgz", + "integrity": "sha512-fLgJnDOXFmuVlB38rUN5SmU7hAFQcCjrg3Vrxz67KTY7YHFnSNEKvX4avmEBdOI0yTCxZjwMCFEqsC8k2+Wd3g==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@metamask/utils": { + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@metamask/utils/-/utils-11.11.0.tgz", + "integrity": "sha512-0nF2CWjWQr/m0Y2t2lJnBTU1/CZPPTvKvcESLplyWe/tyeb8zFOi/FeneDmaFnML6LYRIGZU6f+xR0jKAIUZfw==", + "license": "ISC", + "dependencies": { + "@ethereumjs/tx": "^4.2.0", + "@metamask/superstruct": "^3.1.0", + "@noble/hashes": "^1.3.1", + "@scure/base": "^1.1.3", + "@types/debug": "^4.1.7", + "@types/lodash": "^4.17.20", + "debug": "^4.3.4", + "lodash": "^4.17.21", + "pony-cause": "^2.1.10", + "semver": "^7.5.4", + "uuid": "^9.0.1" + }, + "engines": { + "node": "^18.18 || ^20.14 || >=22" + } + }, + "node_modules/@metamask/utils/node_modules/semver": { + "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" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@metamask/utils/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1044,6 +1983,41 @@ "node": ">= 8" } }, + "node_modules/@paulmillr/qr": { + "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": "Switch to \"qr\" (new package name) for security updates: npm install qr", + "license": "(MIT OR Apache-2.0)", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@rainbow-me/rainbowkit": { + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/@rainbow-me/rainbowkit/-/rainbowkit-2.2.11.tgz", + "integrity": "sha512-FHPsRHMBpuHHhuyKktAR13O9agmsUUunDnVEP4hG1dSZ2JojXLUSWyLG28VbGIJakHYylkNguiLFnqM/BM8ERA==", + "license": "MIT", + "dependencies": { + "@vanilla-extract/css": "1.20.1", + "@vanilla-extract/dynamic": "2.1.5", + "@vanilla-extract/sprinkles": "1.6.5", + "clsx": "2.1.1", + "cuer": "0.0.3", + "react-remove-scroll": "2.7.2", + "ua-parser-js": "^2.0.9" + }, + "engines": { + "node": ">=12.4" + }, + "peerDependencies": { + "@tanstack/react-query": ">=5.0.0", + "react": ">=18", + "react-dom": ">=18", + "viem": "2.x", + "wagmi": "^2.9.0" + } + }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -1053,6 +2027,1461 @@ "node": ">=14.0.0" } }, + "node_modules/@reown/appkit": { + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@reown/appkit/-/appkit-1.7.8.tgz", + "integrity": "sha512-51kTleozhA618T1UvMghkhKfaPcc9JlKwLJ5uV+riHyvSoWPKPRIa5A6M1Wano5puNyW0s3fwywhyqTHSilkaA==", + "license": "Apache-2.0", + "dependencies": { + "@reown/appkit-common": "1.7.8", + "@reown/appkit-controllers": "1.7.8", + "@reown/appkit-pay": "1.7.8", + "@reown/appkit-polyfills": "1.7.8", + "@reown/appkit-scaffold-ui": "1.7.8", + "@reown/appkit-ui": "1.7.8", + "@reown/appkit-utils": "1.7.8", + "@reown/appkit-wallet": "1.7.8", + "@walletconnect/types": "2.21.0", + "@walletconnect/universal-provider": "2.21.0", + "bs58": "6.0.0", + "valtio": "1.13.2", + "viem": ">=2.29.0" + } + }, + "node_modules/@reown/appkit-common": { + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@reown/appkit-common/-/appkit-common-1.7.8.tgz", + "integrity": "sha512-ridIhc/x6JOp7KbDdwGKY4zwf8/iK8EYBl+HtWrruutSLwZyVi5P8WaZa+8iajL6LcDcDF7LoyLwMTym7SRuwQ==", + "license": "Apache-2.0", + "dependencies": { + "big.js": "6.2.2", + "dayjs": "1.11.13", + "viem": ">=2.29.0" + } + }, + "node_modules/@reown/appkit-controllers": { + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@reown/appkit-controllers/-/appkit-controllers-1.7.8.tgz", + "integrity": "sha512-IdXlJlivrlj6m63VsGLsjtPHHsTWvKGVzWIP1fXZHVqmK+rZCBDjCi9j267Rb9/nYRGHWBtlFQhO8dK35WfeDA==", + "license": "Apache-2.0", + "dependencies": { + "@reown/appkit-common": "1.7.8", + "@reown/appkit-wallet": "1.7.8", + "@walletconnect/universal-provider": "2.21.0", + "valtio": "1.13.2", + "viem": ">=2.29.0" + } + }, + "node_modules/@reown/appkit-controllers/node_modules/@noble/ciphers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.2.1.tgz", + "integrity": "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@reown/appkit-controllers/node_modules/@noble/curves": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.1.tgz", + "integrity": "sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.7.1" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@reown/appkit-controllers/node_modules/@noble/hashes": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz", + "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@reown/appkit-controllers/node_modules/@scure/bip32": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.6.2.tgz", + "integrity": "sha512-t96EPDMbtGgtb7onKKqxRLfE5g05k7uHnHRM2xdE6BP/ZmxaLtPek4J4KfVn/90IQNrU1IOAqMgiDtUdtbe3nw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.8.1", + "@noble/hashes": "~1.7.1", + "@scure/base": "~1.2.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@reown/appkit-controllers/node_modules/@scure/bip39": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.5.4.tgz", + "integrity": "sha512-TFM4ni0vKvCfBpohoh+/lY05i9gRbSwXWngAsF4CABQxoaOHijxuaZ2R6cStDQ5CHtHO9aGJTr4ksVJASRRyMA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.7.1", + "@scure/base": "~1.2.4" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@reown/appkit-controllers/node_modules/@walletconnect/core": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@walletconnect/core/-/core-2.21.0.tgz", + "integrity": "sha512-o6R7Ua4myxR8aRUAJ1z3gT9nM+jd2B2mfamu6arzy1Cc6vi10fIwFWb6vg3bC8xJ6o9H3n/cN5TOW3aA9Y1XVw==", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/heartbeat": "1.2.2", + "@walletconnect/jsonrpc-provider": "1.0.14", + "@walletconnect/jsonrpc-types": "1.0.4", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/jsonrpc-ws-connection": "1.0.16", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/logger": "2.1.2", + "@walletconnect/relay-api": "1.0.11", + "@walletconnect/relay-auth": "1.1.0", + "@walletconnect/safe-json": "1.0.2", + "@walletconnect/time": "1.0.2", + "@walletconnect/types": "2.21.0", + "@walletconnect/utils": "2.21.0", + "@walletconnect/window-getters": "1.0.1", + "es-toolkit": "1.33.0", + "events": "3.3.0", + "uint8arrays": "3.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@reown/appkit-controllers/node_modules/@walletconnect/keyvaluestorage": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@walletconnect/keyvaluestorage/-/keyvaluestorage-1.1.1.tgz", + "integrity": "sha512-V7ZQq2+mSxAq7MrRqDxanTzu2RcElfK1PfNYiaVnJgJ7Q7G7hTVwF8voIBx92qsRyGHZihrwNPHuZd1aKkd0rA==", + "license": "MIT", + "dependencies": { + "@walletconnect/safe-json": "^1.0.1", + "idb-keyval": "^6.2.1", + "unstorage": "^1.9.0" + }, + "peerDependencies": { + "@react-native-async-storage/async-storage": "1.x" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@reown/appkit-controllers/node_modules/@walletconnect/sign-client": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@walletconnect/sign-client/-/sign-client-2.21.0.tgz", + "integrity": "sha512-z7h+PeLa5Au2R591d/8ZlziE0stJvdzP9jNFzFolf2RG/OiXulgFKum8PrIyXy+Rg2q95U9nRVUF9fWcn78yBA==", + "deprecated": "Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/core": "2.21.0", + "@walletconnect/events": "1.0.1", + "@walletconnect/heartbeat": "1.2.2", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/logger": "2.1.2", + "@walletconnect/time": "1.0.2", + "@walletconnect/types": "2.21.0", + "@walletconnect/utils": "2.21.0", + "events": "3.3.0" + } + }, + "node_modules/@reown/appkit-controllers/node_modules/@walletconnect/types": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@walletconnect/types/-/types-2.21.0.tgz", + "integrity": "sha512-ll+9upzqt95ZBWcfkOszXZkfnpbJJ2CmxMfGgE5GmhdxxxCcO5bGhXkI+x8OpiS555RJ/v/sXJYMSOLkmu4fFw==", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/events": "1.0.1", + "@walletconnect/heartbeat": "1.2.2", + "@walletconnect/jsonrpc-types": "1.0.4", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/logger": "2.1.2", + "events": "3.3.0" + } + }, + "node_modules/@reown/appkit-controllers/node_modules/@walletconnect/universal-provider": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@walletconnect/universal-provider/-/universal-provider-2.21.0.tgz", + "integrity": "sha512-mtUQvewt+X0VBQay/xOJBvxsB3Xsm1lTwFjZ6WUwSOTR1X+FNb71hSApnV5kbsdDIpYPXeQUbGt2se1n5E5UBg==", + "deprecated": "Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/events": "1.0.1", + "@walletconnect/jsonrpc-http-connection": "1.0.8", + "@walletconnect/jsonrpc-provider": "1.0.14", + "@walletconnect/jsonrpc-types": "1.0.4", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/logger": "2.1.2", + "@walletconnect/sign-client": "2.21.0", + "@walletconnect/types": "2.21.0", + "@walletconnect/utils": "2.21.0", + "es-toolkit": "1.33.0", + "events": "3.3.0" + } + }, + "node_modules/@reown/appkit-controllers/node_modules/@walletconnect/utils": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@walletconnect/utils/-/utils-2.21.0.tgz", + "integrity": "sha512-zfHLiUoBrQ8rP57HTPXW7rQMnYxYI4gT9yTACxVW6LhIFROTF6/ytm5SKNoIvi4a5nX5dfXG4D9XwQUCu8Ilig==", + "license": "Apache-2.0", + "dependencies": { + "@noble/ciphers": "1.2.1", + "@noble/curves": "1.8.1", + "@noble/hashes": "1.7.1", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/relay-api": "1.0.11", + "@walletconnect/relay-auth": "1.1.0", + "@walletconnect/safe-json": "1.0.2", + "@walletconnect/time": "1.0.2", + "@walletconnect/types": "2.21.0", + "@walletconnect/window-getters": "1.0.1", + "@walletconnect/window-metadata": "1.0.1", + "bs58": "6.0.0", + "detect-browser": "5.3.0", + "query-string": "7.1.3", + "uint8arrays": "3.1.0", + "viem": "2.23.2" + } + }, + "node_modules/@reown/appkit-controllers/node_modules/@walletconnect/utils/node_modules/viem": { + "version": "2.23.2", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.23.2.tgz", + "integrity": "sha512-NVmW/E0c5crMOtbEAqMF0e3NmvQykFXhLOc/CkLIXOlzHSA6KXVz3CYVmaKqBF8/xtjsjHAGjdJN3Ru1kFJLaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.8.1", + "@noble/hashes": "1.7.1", + "@scure/bip32": "1.6.2", + "@scure/bip39": "1.5.4", + "abitype": "1.0.8", + "isows": "1.0.6", + "ox": "0.6.7", + "ws": "8.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@reown/appkit-controllers/node_modules/abitype": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.8.tgz", + "integrity": "sha512-ZeiI6h3GnW06uYDLx0etQtX/p8E24UaHHBj57RSjK7YBFe7iuVn07EDpOeP451D06sF27VOz9JJPlIKJmXgkEg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3 >=3.22.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@reown/appkit-controllers/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@reown/appkit-controllers/node_modules/isows": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.6.tgz", + "integrity": "sha512-lPHCayd40oW98/I0uvgaHKWCSvkzY27LjWLbtzOm64yQ+G3Q5npjjbdppU65iZXkK1Zt+kH9pfegli0AYfwYYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/@reown/appkit-controllers/node_modules/lru-cache": { + "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" + } + }, + "node_modules/@reown/appkit-controllers/node_modules/ox": { + "version": "0.6.7", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.6.7.tgz", + "integrity": "sha512-17Gk/eFsFRAZ80p5eKqv89a57uXjd3NgIf1CaXojATPBuujVc/fQSVhBeAU9JCRB+k7J50WQAyWTxK19T9GgbA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.10.1", + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@scure/bip32": "^1.5.0", + "@scure/bip39": "^1.4.0", + "abitype": "^1.0.6", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@reown/appkit-controllers/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@reown/appkit-controllers/node_modules/unstorage": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", + "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/@reown/appkit-controllers/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "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/@reown/appkit-pay": { + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@reown/appkit-pay/-/appkit-pay-1.7.8.tgz", + "integrity": "sha512-OSGQ+QJkXx0FEEjlpQqIhT8zGJKOoHzVnyy/0QFrl3WrQTjCzg0L6+i91Ad5Iy1zb6V5JjqtfIFpRVRWN4M3pw==", + "license": "Apache-2.0", + "dependencies": { + "@reown/appkit-common": "1.7.8", + "@reown/appkit-controllers": "1.7.8", + "@reown/appkit-ui": "1.7.8", + "@reown/appkit-utils": "1.7.8", + "lit": "3.3.0", + "valtio": "1.13.2" + } + }, + "node_modules/@reown/appkit-polyfills": { + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@reown/appkit-polyfills/-/appkit-polyfills-1.7.8.tgz", + "integrity": "sha512-W/kq786dcHHAuJ3IV2prRLEgD/2iOey4ueMHf1sIFjhhCGMynMkhsOhQMUH0tzodPqUgAC494z4bpIDYjwWXaA==", + "license": "Apache-2.0", + "dependencies": { + "buffer": "6.0.3" + } + }, + "node_modules/@reown/appkit-scaffold-ui": { + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@reown/appkit-scaffold-ui/-/appkit-scaffold-ui-1.7.8.tgz", + "integrity": "sha512-RCeHhAwOrIgcvHwYlNWMcIDibdI91waaoEYBGw71inE0kDB8uZbE7tE6DAXJmDkvl0qPh+DqlC4QbJLF1FVYdQ==", + "license": "Apache-2.0", + "dependencies": { + "@reown/appkit-common": "1.7.8", + "@reown/appkit-controllers": "1.7.8", + "@reown/appkit-ui": "1.7.8", + "@reown/appkit-utils": "1.7.8", + "@reown/appkit-wallet": "1.7.8", + "lit": "3.3.0" + } + }, + "node_modules/@reown/appkit-ui": { + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@reown/appkit-ui/-/appkit-ui-1.7.8.tgz", + "integrity": "sha512-1hjCKjf6FLMFzrulhl0Y9Vb9Fu4royE+SXCPSWh4VhZhWqlzUFc7kutnZKx8XZFVQH4pbBvY62SpRC93gqoHow==", + "license": "Apache-2.0", + "dependencies": { + "@reown/appkit-common": "1.7.8", + "@reown/appkit-controllers": "1.7.8", + "@reown/appkit-wallet": "1.7.8", + "lit": "3.3.0", + "qrcode": "1.5.3" + } + }, + "node_modules/@reown/appkit-utils": { + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@reown/appkit-utils/-/appkit-utils-1.7.8.tgz", + "integrity": "sha512-8X7UvmE8GiaoitCwNoB86pttHgQtzy4ryHZM9kQpvjQ0ULpiER44t1qpVLXNM4X35O0v18W0Dk60DnYRMH2WRw==", + "license": "Apache-2.0", + "dependencies": { + "@reown/appkit-common": "1.7.8", + "@reown/appkit-controllers": "1.7.8", + "@reown/appkit-polyfills": "1.7.8", + "@reown/appkit-wallet": "1.7.8", + "@walletconnect/logger": "2.1.2", + "@walletconnect/universal-provider": "2.21.0", + "valtio": "1.13.2", + "viem": ">=2.29.0" + }, + "peerDependencies": { + "valtio": "1.13.2" + } + }, + "node_modules/@reown/appkit-utils/node_modules/@noble/ciphers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.2.1.tgz", + "integrity": "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@reown/appkit-utils/node_modules/@noble/curves": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.1.tgz", + "integrity": "sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.7.1" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@reown/appkit-utils/node_modules/@noble/hashes": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz", + "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@reown/appkit-utils/node_modules/@scure/bip32": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.6.2.tgz", + "integrity": "sha512-t96EPDMbtGgtb7onKKqxRLfE5g05k7uHnHRM2xdE6BP/ZmxaLtPek4J4KfVn/90IQNrU1IOAqMgiDtUdtbe3nw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.8.1", + "@noble/hashes": "~1.7.1", + "@scure/base": "~1.2.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@reown/appkit-utils/node_modules/@scure/bip39": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.5.4.tgz", + "integrity": "sha512-TFM4ni0vKvCfBpohoh+/lY05i9gRbSwXWngAsF4CABQxoaOHijxuaZ2R6cStDQ5CHtHO9aGJTr4ksVJASRRyMA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.7.1", + "@scure/base": "~1.2.4" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@reown/appkit-utils/node_modules/@walletconnect/core": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@walletconnect/core/-/core-2.21.0.tgz", + "integrity": "sha512-o6R7Ua4myxR8aRUAJ1z3gT9nM+jd2B2mfamu6arzy1Cc6vi10fIwFWb6vg3bC8xJ6o9H3n/cN5TOW3aA9Y1XVw==", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/heartbeat": "1.2.2", + "@walletconnect/jsonrpc-provider": "1.0.14", + "@walletconnect/jsonrpc-types": "1.0.4", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/jsonrpc-ws-connection": "1.0.16", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/logger": "2.1.2", + "@walletconnect/relay-api": "1.0.11", + "@walletconnect/relay-auth": "1.1.0", + "@walletconnect/safe-json": "1.0.2", + "@walletconnect/time": "1.0.2", + "@walletconnect/types": "2.21.0", + "@walletconnect/utils": "2.21.0", + "@walletconnect/window-getters": "1.0.1", + "es-toolkit": "1.33.0", + "events": "3.3.0", + "uint8arrays": "3.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@reown/appkit-utils/node_modules/@walletconnect/keyvaluestorage": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@walletconnect/keyvaluestorage/-/keyvaluestorage-1.1.1.tgz", + "integrity": "sha512-V7ZQq2+mSxAq7MrRqDxanTzu2RcElfK1PfNYiaVnJgJ7Q7G7hTVwF8voIBx92qsRyGHZihrwNPHuZd1aKkd0rA==", + "license": "MIT", + "dependencies": { + "@walletconnect/safe-json": "^1.0.1", + "idb-keyval": "^6.2.1", + "unstorage": "^1.9.0" + }, + "peerDependencies": { + "@react-native-async-storage/async-storage": "1.x" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@reown/appkit-utils/node_modules/@walletconnect/sign-client": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@walletconnect/sign-client/-/sign-client-2.21.0.tgz", + "integrity": "sha512-z7h+PeLa5Au2R591d/8ZlziE0stJvdzP9jNFzFolf2RG/OiXulgFKum8PrIyXy+Rg2q95U9nRVUF9fWcn78yBA==", + "deprecated": "Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/core": "2.21.0", + "@walletconnect/events": "1.0.1", + "@walletconnect/heartbeat": "1.2.2", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/logger": "2.1.2", + "@walletconnect/time": "1.0.2", + "@walletconnect/types": "2.21.0", + "@walletconnect/utils": "2.21.0", + "events": "3.3.0" + } + }, + "node_modules/@reown/appkit-utils/node_modules/@walletconnect/types": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@walletconnect/types/-/types-2.21.0.tgz", + "integrity": "sha512-ll+9upzqt95ZBWcfkOszXZkfnpbJJ2CmxMfGgE5GmhdxxxCcO5bGhXkI+x8OpiS555RJ/v/sXJYMSOLkmu4fFw==", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/events": "1.0.1", + "@walletconnect/heartbeat": "1.2.2", + "@walletconnect/jsonrpc-types": "1.0.4", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/logger": "2.1.2", + "events": "3.3.0" + } + }, + "node_modules/@reown/appkit-utils/node_modules/@walletconnect/universal-provider": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@walletconnect/universal-provider/-/universal-provider-2.21.0.tgz", + "integrity": "sha512-mtUQvewt+X0VBQay/xOJBvxsB3Xsm1lTwFjZ6WUwSOTR1X+FNb71hSApnV5kbsdDIpYPXeQUbGt2se1n5E5UBg==", + "deprecated": "Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/events": "1.0.1", + "@walletconnect/jsonrpc-http-connection": "1.0.8", + "@walletconnect/jsonrpc-provider": "1.0.14", + "@walletconnect/jsonrpc-types": "1.0.4", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/logger": "2.1.2", + "@walletconnect/sign-client": "2.21.0", + "@walletconnect/types": "2.21.0", + "@walletconnect/utils": "2.21.0", + "es-toolkit": "1.33.0", + "events": "3.3.0" + } + }, + "node_modules/@reown/appkit-utils/node_modules/@walletconnect/utils": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@walletconnect/utils/-/utils-2.21.0.tgz", + "integrity": "sha512-zfHLiUoBrQ8rP57HTPXW7rQMnYxYI4gT9yTACxVW6LhIFROTF6/ytm5SKNoIvi4a5nX5dfXG4D9XwQUCu8Ilig==", + "license": "Apache-2.0", + "dependencies": { + "@noble/ciphers": "1.2.1", + "@noble/curves": "1.8.1", + "@noble/hashes": "1.7.1", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/relay-api": "1.0.11", + "@walletconnect/relay-auth": "1.1.0", + "@walletconnect/safe-json": "1.0.2", + "@walletconnect/time": "1.0.2", + "@walletconnect/types": "2.21.0", + "@walletconnect/window-getters": "1.0.1", + "@walletconnect/window-metadata": "1.0.1", + "bs58": "6.0.0", + "detect-browser": "5.3.0", + "query-string": "7.1.3", + "uint8arrays": "3.1.0", + "viem": "2.23.2" + } + }, + "node_modules/@reown/appkit-utils/node_modules/@walletconnect/utils/node_modules/viem": { + "version": "2.23.2", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.23.2.tgz", + "integrity": "sha512-NVmW/E0c5crMOtbEAqMF0e3NmvQykFXhLOc/CkLIXOlzHSA6KXVz3CYVmaKqBF8/xtjsjHAGjdJN3Ru1kFJLaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.8.1", + "@noble/hashes": "1.7.1", + "@scure/bip32": "1.6.2", + "@scure/bip39": "1.5.4", + "abitype": "1.0.8", + "isows": "1.0.6", + "ox": "0.6.7", + "ws": "8.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@reown/appkit-utils/node_modules/abitype": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.8.tgz", + "integrity": "sha512-ZeiI6h3GnW06uYDLx0etQtX/p8E24UaHHBj57RSjK7YBFe7iuVn07EDpOeP451D06sF27VOz9JJPlIKJmXgkEg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3 >=3.22.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@reown/appkit-utils/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@reown/appkit-utils/node_modules/isows": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.6.tgz", + "integrity": "sha512-lPHCayd40oW98/I0uvgaHKWCSvkzY27LjWLbtzOm64yQ+G3Q5npjjbdppU65iZXkK1Zt+kH9pfegli0AYfwYYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/@reown/appkit-utils/node_modules/lru-cache": { + "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" + } + }, + "node_modules/@reown/appkit-utils/node_modules/ox": { + "version": "0.6.7", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.6.7.tgz", + "integrity": "sha512-17Gk/eFsFRAZ80p5eKqv89a57uXjd3NgIf1CaXojATPBuujVc/fQSVhBeAU9JCRB+k7J50WQAyWTxK19T9GgbA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.10.1", + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@scure/bip32": "^1.5.0", + "@scure/bip39": "^1.4.0", + "abitype": "^1.0.6", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@reown/appkit-utils/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@reown/appkit-utils/node_modules/unstorage": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", + "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/@reown/appkit-utils/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "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/@reown/appkit-wallet": { + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@reown/appkit-wallet/-/appkit-wallet-1.7.8.tgz", + "integrity": "sha512-kspz32EwHIOT/eg/ZQbFPxgXq0B/olDOj3YMu7gvLEFz4xyOFd/wgzxxAXkp5LbG4Cp++s/elh79rVNmVFdB9A==", + "license": "Apache-2.0", + "dependencies": { + "@reown/appkit-common": "1.7.8", + "@reown/appkit-polyfills": "1.7.8", + "@walletconnect/logger": "2.1.2", + "zod": "3.22.4" + } + }, + "node_modules/@reown/appkit-wallet/node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@reown/appkit/node_modules/@noble/ciphers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.2.1.tgz", + "integrity": "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@reown/appkit/node_modules/@noble/curves": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.1.tgz", + "integrity": "sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.7.1" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@reown/appkit/node_modules/@noble/hashes": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz", + "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@reown/appkit/node_modules/@scure/bip32": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.6.2.tgz", + "integrity": "sha512-t96EPDMbtGgtb7onKKqxRLfE5g05k7uHnHRM2xdE6BP/ZmxaLtPek4J4KfVn/90IQNrU1IOAqMgiDtUdtbe3nw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.8.1", + "@noble/hashes": "~1.7.1", + "@scure/base": "~1.2.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@reown/appkit/node_modules/@scure/bip39": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.5.4.tgz", + "integrity": "sha512-TFM4ni0vKvCfBpohoh+/lY05i9gRbSwXWngAsF4CABQxoaOHijxuaZ2R6cStDQ5CHtHO9aGJTr4ksVJASRRyMA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.7.1", + "@scure/base": "~1.2.4" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@reown/appkit/node_modules/@walletconnect/core": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@walletconnect/core/-/core-2.21.0.tgz", + "integrity": "sha512-o6R7Ua4myxR8aRUAJ1z3gT9nM+jd2B2mfamu6arzy1Cc6vi10fIwFWb6vg3bC8xJ6o9H3n/cN5TOW3aA9Y1XVw==", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/heartbeat": "1.2.2", + "@walletconnect/jsonrpc-provider": "1.0.14", + "@walletconnect/jsonrpc-types": "1.0.4", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/jsonrpc-ws-connection": "1.0.16", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/logger": "2.1.2", + "@walletconnect/relay-api": "1.0.11", + "@walletconnect/relay-auth": "1.1.0", + "@walletconnect/safe-json": "1.0.2", + "@walletconnect/time": "1.0.2", + "@walletconnect/types": "2.21.0", + "@walletconnect/utils": "2.21.0", + "@walletconnect/window-getters": "1.0.1", + "es-toolkit": "1.33.0", + "events": "3.3.0", + "uint8arrays": "3.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@reown/appkit/node_modules/@walletconnect/keyvaluestorage": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@walletconnect/keyvaluestorage/-/keyvaluestorage-1.1.1.tgz", + "integrity": "sha512-V7ZQq2+mSxAq7MrRqDxanTzu2RcElfK1PfNYiaVnJgJ7Q7G7hTVwF8voIBx92qsRyGHZihrwNPHuZd1aKkd0rA==", + "license": "MIT", + "dependencies": { + "@walletconnect/safe-json": "^1.0.1", + "idb-keyval": "^6.2.1", + "unstorage": "^1.9.0" + }, + "peerDependencies": { + "@react-native-async-storage/async-storage": "1.x" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@reown/appkit/node_modules/@walletconnect/sign-client": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@walletconnect/sign-client/-/sign-client-2.21.0.tgz", + "integrity": "sha512-z7h+PeLa5Au2R591d/8ZlziE0stJvdzP9jNFzFolf2RG/OiXulgFKum8PrIyXy+Rg2q95U9nRVUF9fWcn78yBA==", + "deprecated": "Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/core": "2.21.0", + "@walletconnect/events": "1.0.1", + "@walletconnect/heartbeat": "1.2.2", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/logger": "2.1.2", + "@walletconnect/time": "1.0.2", + "@walletconnect/types": "2.21.0", + "@walletconnect/utils": "2.21.0", + "events": "3.3.0" + } + }, + "node_modules/@reown/appkit/node_modules/@walletconnect/types": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@walletconnect/types/-/types-2.21.0.tgz", + "integrity": "sha512-ll+9upzqt95ZBWcfkOszXZkfnpbJJ2CmxMfGgE5GmhdxxxCcO5bGhXkI+x8OpiS555RJ/v/sXJYMSOLkmu4fFw==", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/events": "1.0.1", + "@walletconnect/heartbeat": "1.2.2", + "@walletconnect/jsonrpc-types": "1.0.4", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/logger": "2.1.2", + "events": "3.3.0" + } + }, + "node_modules/@reown/appkit/node_modules/@walletconnect/universal-provider": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@walletconnect/universal-provider/-/universal-provider-2.21.0.tgz", + "integrity": "sha512-mtUQvewt+X0VBQay/xOJBvxsB3Xsm1lTwFjZ6WUwSOTR1X+FNb71hSApnV5kbsdDIpYPXeQUbGt2se1n5E5UBg==", + "deprecated": "Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/events": "1.0.1", + "@walletconnect/jsonrpc-http-connection": "1.0.8", + "@walletconnect/jsonrpc-provider": "1.0.14", + "@walletconnect/jsonrpc-types": "1.0.4", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/logger": "2.1.2", + "@walletconnect/sign-client": "2.21.0", + "@walletconnect/types": "2.21.0", + "@walletconnect/utils": "2.21.0", + "es-toolkit": "1.33.0", + "events": "3.3.0" + } + }, + "node_modules/@reown/appkit/node_modules/@walletconnect/utils": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@walletconnect/utils/-/utils-2.21.0.tgz", + "integrity": "sha512-zfHLiUoBrQ8rP57HTPXW7rQMnYxYI4gT9yTACxVW6LhIFROTF6/ytm5SKNoIvi4a5nX5dfXG4D9XwQUCu8Ilig==", + "license": "Apache-2.0", + "dependencies": { + "@noble/ciphers": "1.2.1", + "@noble/curves": "1.8.1", + "@noble/hashes": "1.7.1", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/relay-api": "1.0.11", + "@walletconnect/relay-auth": "1.1.0", + "@walletconnect/safe-json": "1.0.2", + "@walletconnect/time": "1.0.2", + "@walletconnect/types": "2.21.0", + "@walletconnect/window-getters": "1.0.1", + "@walletconnect/window-metadata": "1.0.1", + "bs58": "6.0.0", + "detect-browser": "5.3.0", + "query-string": "7.1.3", + "uint8arrays": "3.1.0", + "viem": "2.23.2" + } + }, + "node_modules/@reown/appkit/node_modules/@walletconnect/utils/node_modules/viem": { + "version": "2.23.2", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.23.2.tgz", + "integrity": "sha512-NVmW/E0c5crMOtbEAqMF0e3NmvQykFXhLOc/CkLIXOlzHSA6KXVz3CYVmaKqBF8/xtjsjHAGjdJN3Ru1kFJLaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.8.1", + "@noble/hashes": "1.7.1", + "@scure/bip32": "1.6.2", + "@scure/bip39": "1.5.4", + "abitype": "1.0.8", + "isows": "1.0.6", + "ox": "0.6.7", + "ws": "8.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@reown/appkit/node_modules/abitype": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.8.tgz", + "integrity": "sha512-ZeiI6h3GnW06uYDLx0etQtX/p8E24UaHHBj57RSjK7YBFe7iuVn07EDpOeP451D06sF27VOz9JJPlIKJmXgkEg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3 >=3.22.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@reown/appkit/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@reown/appkit/node_modules/isows": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.6.tgz", + "integrity": "sha512-lPHCayd40oW98/I0uvgaHKWCSvkzY27LjWLbtzOm64yQ+G3Q5npjjbdppU65iZXkK1Zt+kH9pfegli0AYfwYYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/@reown/appkit/node_modules/lru-cache": { + "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" + } + }, + "node_modules/@reown/appkit/node_modules/ox": { + "version": "0.6.7", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.6.7.tgz", + "integrity": "sha512-17Gk/eFsFRAZ80p5eKqv89a57uXjd3NgIf1CaXojATPBuujVc/fQSVhBeAU9JCRB+k7J50WQAyWTxK19T9GgbA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.10.1", + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@scure/bip32": "^1.5.0", + "@scure/bip39": "^1.4.0", + "abitype": "^1.0.6", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@reown/appkit/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@reown/appkit/node_modules/unstorage": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", + "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/@reown/appkit/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "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/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1410,6 +3839,1052 @@ "win32" ] }, + "node_modules/@safe-global/safe-apps-provider": { + "version": "0.18.6", + "resolved": "https://registry.npmjs.org/@safe-global/safe-apps-provider/-/safe-apps-provider-0.18.6.tgz", + "integrity": "sha512-4LhMmjPWlIO8TTDC2AwLk44XKXaK6hfBTWyljDm0HQ6TWlOEijVWNrt2s3OCVMSxlXAcEzYfqyu1daHZooTC2Q==", + "license": "MIT", + "dependencies": { + "@safe-global/safe-apps-sdk": "^9.1.0", + "events": "^3.3.0" + } + }, + "node_modules/@safe-global/safe-apps-sdk": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@safe-global/safe-apps-sdk/-/safe-apps-sdk-9.1.0.tgz", + "integrity": "sha512-N5p/ulfnnA2Pi2M3YeWjULeWbjo7ei22JwU/IXnhoHzKq3pYCN6ynL9mJBOlvDVv892EgLPCWCOwQk/uBT2v0Q==", + "license": "MIT", + "dependencies": { + "@safe-global/safe-gateway-typescript-sdk": "^3.5.3", + "viem": "^2.1.1" + } + }, + "node_modules/@safe-global/safe-gateway-typescript-sdk": { + "version": "3.23.1", + "resolved": "https://registry.npmjs.org/@safe-global/safe-gateway-typescript-sdk/-/safe-gateway-typescript-sdk-3.23.1.tgz", + "integrity": "sha512-6ORQfwtEJYpalCeVO21L4XXGSdbEMfyp2hEv6cP82afKXSwvse6d3sdelgaPWUxHIsFRkWvHDdzh8IyyKHZKxw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@solana-program/system": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@solana-program/system/-/system-0.10.0.tgz", + "integrity": "sha512-Go+LOEZmqmNlfr+Gjy5ZWAdY5HbYzk2RBewD9QinEU/bBSzpFfzqDRT55JjFRBGJUvMgf3C2vfXEGT4i8DSI4g==", + "license": "Apache-2.0", + "peerDependencies": { + "@solana/kit": "^5.0" + } + }, + "node_modules/@solana-program/token": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@solana-program/token/-/token-0.9.0.tgz", + "integrity": "sha512-vnZxndd4ED4Fc56sw93cWZ2djEeeOFxtaPS8SPf5+a+JZjKA/EnKqzbE1y04FuMhIVrLERQ8uR8H2h72eZzlsA==", + "license": "Apache-2.0", + "peerDependencies": { + "@solana/kit": "^5.0" + } + }, + "node_modules/@solana/accounts": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/accounts/-/accounts-5.5.1.tgz", + "integrity": "sha512-TfOY9xixg5rizABuLVuZ9XI2x2tmWUC/OoN556xwfDlhBHBjKfszicYYOyD6nbFmwTGYarCmyGIdteXxTXIdhQ==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/rpc-spec": "5.5.1", + "@solana/rpc-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/addresses": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/addresses/-/addresses-5.5.1.tgz", + "integrity": "sha512-5xoah3Q9G30HQghu/9BiHLb5pzlPKRC3zydQDmE3O9H//WfayxTFppsUDCL6FjYUHqj/wzK6CWHySglc2RkpdA==", + "license": "MIT", + "dependencies": { + "@solana/assertions": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/nominal-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/assertions": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/assertions/-/assertions-5.5.1.tgz", + "integrity": "sha512-YTCSWAlGwSlVPnWtWLm3ukz81wH4j2YaCveK+TjpvUU88hTy6fmUqxi0+hvAMAe4zKXpJyj3Az7BrLJRxbIm4Q==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/codecs/-/codecs-5.5.1.tgz", + "integrity": "sha512-Vea29nJub/bXjfzEV7ZZQ/PWr1pYLZo3z0qW0LQL37uKKVzVFRQlwetd7INk3YtTD3xm9WUYr7bCvYUk3uKy2g==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/codecs-data-structures": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/options": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-core": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-5.5.1.tgz", + "integrity": "sha512-TgBt//bbKBct0t6/MpA8ElaOA3sa8eYVvR7LGslCZ84WiAwwjCY0lW/lOYsFHJQzwREMdUyuEyy5YWBKtdh8Rw==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-data-structures": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-data-structures/-/codecs-data-structures-5.5.1.tgz", + "integrity": "sha512-97bJWGyUY9WvBz3mX1UV3YPWGDTez6btCfD0ip3UVEXJbItVuUiOkzcO5iFDUtQT5riKT6xC+Mzl+0nO76gd0w==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-numbers": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-5.5.1.tgz", + "integrity": "sha512-rllMIZAHqmtvC0HO/dc/21wDuWaD0B8Ryv8o+YtsICQBuiL/0U4AGwH7Pi5GNFySYk0/crSuwfIqQFtmxNSPFw==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-strings": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-strings/-/codecs-strings-5.5.1.tgz", + "integrity": "sha512-7klX4AhfHYA+uKKC/nxRGP2MntbYQCR3N6+v7bk1W/rSxYuhNmt+FN8aoThSZtWIKwN6BEyR1167ka8Co1+E7A==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "fastestsmallesttextencoderdecoder": "^1.0.22", + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "fastestsmallesttextencoderdecoder": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/errors": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-5.5.1.tgz", + "integrity": "sha512-vFO3p+S7HoyyrcAectnXbdsMfwUzY2zYFUc2DEe5BwpiE9J1IAxPBGjOWO6hL1bbYdBrlmjNx8DXCslqS+Kcmg==", + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "commander": "14.0.2" + }, + "bin": { + "errors": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/errors/node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@solana/fast-stable-stringify": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/fast-stable-stringify/-/fast-stable-stringify-5.5.1.tgz", + "integrity": "sha512-Ni7s2FN33zTzhTFgRjEbOVFO+UAmK8qi3Iu0/GRFYK4jN696OjKHnboSQH/EacQ+yGqS54bfxf409wU5dsLLCw==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/functional": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/functional/-/functional-5.5.1.tgz", + "integrity": "sha512-tTHoJcEQq3gQx5qsdsDJ0LEJeFzwNpXD80xApW9o/PPoCNimI3SALkZl+zNW8VnxRrV3l3yYvfHWBKe/X3WG3w==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/instruction-plans": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/instruction-plans/-/instruction-plans-5.5.1.tgz", + "integrity": "sha512-7z3CB7YMcFKuVvgcnNY8bY6IsZ8LG61Iytbz7HpNVGX2u1RthOs1tRW8luTzSG1MPL0Ox7afyAVMYeFqSPHnaQ==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/instructions": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/promises": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/instructions": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/instructions/-/instructions-5.5.1.tgz", + "integrity": "sha512-h0G1CG6S+gUUSt0eo6rOtsaXRBwCq1+Js2a+Ps9Bzk9q7YHNFA75/X0NWugWLgC92waRp66hrjMTiYYnLBoWOQ==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/keys": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/keys/-/keys-5.5.1.tgz", + "integrity": "sha512-KRD61cL7CRL+b4r/eB9dEoVxIf/2EJ1Pm1DmRYhtSUAJD2dJ5Xw8QFuehobOGm9URqQ7gaQl+Fkc1qvDlsWqKg==", + "license": "MIT", + "dependencies": { + "@solana/assertions": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/nominal-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/kit": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/kit/-/kit-5.5.1.tgz", + "integrity": "sha512-irKUGiV2yRoyf+4eGQ/ZeCRxa43yjFEL1DUI5B0DkcfZw3cr0VJtVJnrG8OtVF01vT0OUfYOcUn6zJW5TROHvQ==", + "license": "MIT", + "dependencies": { + "@solana/accounts": "5.5.1", + "@solana/addresses": "5.5.1", + "@solana/codecs": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/instruction-plans": "5.5.1", + "@solana/instructions": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/offchain-messages": "5.5.1", + "@solana/plugin-core": "5.5.1", + "@solana/programs": "5.5.1", + "@solana/rpc": "5.5.1", + "@solana/rpc-api": "5.5.1", + "@solana/rpc-parsed-types": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "@solana/rpc-subscriptions": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/signers": "5.5.1", + "@solana/sysvars": "5.5.1", + "@solana/transaction-confirmation": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/nominal-types": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/nominal-types/-/nominal-types-5.5.1.tgz", + "integrity": "sha512-I1ImR+kfrLFxN5z22UDiTWLdRZeKtU0J/pkWkO8qm/8WxveiwdIv4hooi8pb6JnlR4mSrWhq0pCIOxDYrL9GIQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/offchain-messages": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/offchain-messages/-/offchain-messages-5.5.1.tgz", + "integrity": "sha512-g+xHH95prTU+KujtbOzj8wn+C7ZNoiLhf3hj6nYq3MTyxOXtBEysguc97jJveUZG0K97aIKG6xVUlMutg5yxhw==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-data-structures": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/nominal-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/options": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/options/-/options-5.5.1.tgz", + "integrity": "sha512-eo971c9iLNLmk+yOFyo7yKIJzJ/zou6uKpy6mBuyb/thKtS/haiKIc3VLhyTXty3OH2PW8yOlORJnv4DexJB8A==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/codecs-data-structures": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/plugin-core": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/plugin-core/-/plugin-core-5.5.1.tgz", + "integrity": "sha512-VUZl30lDQFJeiSyNfzU1EjYt2QZvoBFKEwjn1lilUJw7KgqD5z7mbV7diJhT+dLFs36i0OsjXvq5kSygn8YJ3A==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/programs": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/programs/-/programs-5.5.1.tgz", + "integrity": "sha512-7U9kn0Jsx1NuBLn5HRTFYh78MV4XN145Yc3WP/q5BlqAVNlMoU9coG5IUTJIG847TUqC1lRto3Dnpwm6T4YRpA==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/promises": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/promises/-/promises-5.5.1.tgz", + "integrity": "sha512-T9lfuUYkGykJmppEcssNiCf6yiYQxJkhiLPP+pyAc2z84/7r3UVIb2tNJk4A9sucS66pzJnVHZKcZVGUUp6wzA==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc/-/rpc-5.5.1.tgz", + "integrity": "sha512-ku8zTUMrkCWci66PRIBC+1mXepEnZH/q1f3ck0kJZ95a06bOTl5KU7HeXWtskkyefzARJ5zvCs54AD5nxjQJ+A==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/fast-stable-stringify": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/rpc-api": "5.5.1", + "@solana/rpc-spec": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "@solana/rpc-transformers": "5.5.1", + "@solana/rpc-transport-http": "5.5.1", + "@solana/rpc-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-api": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-api/-/rpc-api-5.5.1.tgz", + "integrity": "sha512-XWOQQPhKl06Vj0xi3RYHAc6oEQd8B82okYJ04K7N0Vvy3J4PN2cxeK7klwkjgavdcN9EVkYCChm2ADAtnztKnA==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/rpc-parsed-types": "5.5.1", + "@solana/rpc-spec": "5.5.1", + "@solana/rpc-transformers": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-parsed-types": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-parsed-types/-/rpc-parsed-types-5.5.1.tgz", + "integrity": "sha512-HEi3G2nZqGEsa3vX6U0FrXLaqnUCg4SKIUrOe8CezD+cSFbRTOn3rCLrUmJrhVyXlHoQVaRO9mmeovk31jWxJg==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-spec": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-spec/-/rpc-spec-5.5.1.tgz", + "integrity": "sha512-m3LX2bChm3E3by4mQrH4YwCAFY57QBzuUSWqlUw7ChuZ+oLLOq7b2czi4i6L4Vna67j3eCmB3e+4tqy1j5wy7Q==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/rpc-spec-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-spec-types": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-spec-types/-/rpc-spec-types-5.5.1.tgz", + "integrity": "sha512-6OFKtRpIEJQs8Jb2C4OO8KyP2h2Hy1MFhatMAoXA+0Ik8S3H+CicIuMZvGZ91mIu/tXicuOOsNNLu3HAkrakrw==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions/-/rpc-subscriptions-5.5.1.tgz", + "integrity": "sha512-CTMy5bt/6mDh4tc6vUJms9EcuZj3xvK0/xq8IQ90rhkpYvate91RjBP+egvjgSayUg9yucU9vNuUpEjz4spM7w==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/fast-stable-stringify": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/promises": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "@solana/rpc-subscriptions-api": "5.5.1", + "@solana/rpc-subscriptions-channel-websocket": "5.5.1", + "@solana/rpc-subscriptions-spec": "5.5.1", + "@solana/rpc-transformers": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/subscribable": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions-api": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-api/-/rpc-subscriptions-api-5.5.1.tgz", + "integrity": "sha512-5Oi7k+GdeS8xR2ly1iuSFkAv6CZqwG0Z6b1QZKbEgxadE1XGSDrhM2cn59l+bqCozUWCqh4c/A2znU/qQjROlw==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/rpc-subscriptions-spec": "5.5.1", + "@solana/rpc-transformers": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions-channel-websocket": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-channel-websocket/-/rpc-subscriptions-channel-websocket-5.5.1.tgz", + "integrity": "sha512-7tGfBBrYY8TrngOyxSHoCU5shy86iA9SRMRrPSyBhEaZRAk6dnbdpmUTez7gtdVo0BCvh9nzQtUycKWSS7PnFQ==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/rpc-subscriptions-spec": "5.5.1", + "@solana/subscribable": "5.5.1", + "ws": "^8.19.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "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", + "integrity": "sha512-iq+rGq5fMKP3/mKHPNB6MC8IbVW41KGZg83Us/+LE3AWOTWV1WT20KT2iH1F1ik9roi42COv/TpoZZvhKj45XQ==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/promises": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "@solana/subscribable": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-transformers": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-transformers/-/rpc-transformers-5.5.1.tgz", + "integrity": "sha512-OsWqLCQdcrRJKvHiMmwFhp9noNZ4FARuMkHT5us3ustDLXaxOjF0gfqZLnMkulSLcKt7TGXqMhBV+HCo7z5M8Q==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/nominal-types": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "@solana/rpc-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-transport-http": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-transport-http/-/rpc-transport-http-5.5.1.tgz", + "integrity": "sha512-yv8GoVSHqEV0kUJEIhkdOVkR2SvJ6yoWC51cJn2rSV7plr6huLGe0JgujCmB7uZhhaLbcbP3zxXxu9sOjsi7Fg==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/rpc-spec": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "undici-types": "^7.19.2" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-types": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-types/-/rpc-types-5.5.1.tgz", + "integrity": "sha512-bibTFQ7PbHJJjGJPmfYC2I+/5CRFS4O2p9WwbFraX1Keeel+nRrt/NBXIy8veP5AEn2sVJIyJPpWBRpCx1oATA==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/nominal-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/signers": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/signers/-/signers-5.5.1.tgz", + "integrity": "sha512-FY0IVaBT2kCAze55vEieR6hag4coqcuJ31Aw3hqRH7mv6sV8oqwuJmUrx+uFwOp1gwd5OEAzlv6N4hOOple4sQ==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/instructions": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/nominal-types": "5.5.1", + "@solana/offchain-messages": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/subscribable": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/subscribable/-/subscribable-5.5.1.tgz", + "integrity": "sha512-9K0PsynFq0CsmK1CDi5Y2vUIJpCqkgSS5yfDN0eKPgHqEptLEaia09Kaxc90cSZDZU5mKY/zv1NBmB6Aro9zQQ==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/sysvars": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/sysvars/-/sysvars-5.5.1.tgz", + "integrity": "sha512-k3Quq87Mm+geGUu1GWv6knPk0ALsfY6EKSJGw9xUJDHzY/RkYSBnh0RiOrUhtFm2TDNjOailg8/m0VHmi3reFA==", + "license": "MIT", + "dependencies": { + "@solana/accounts": "5.5.1", + "@solana/codecs": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/rpc-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/transaction-confirmation": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/transaction-confirmation/-/transaction-confirmation-5.5.1.tgz", + "integrity": "sha512-j4mKlYPHEyu+OD7MBt3jRoX4ScFgkhZC6H65on4Fux6LMScgivPJlwnKoZMnsgxFgWds0pl+BYzSiALDsXlYtw==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/promises": "5.5.1", + "@solana/rpc": "5.5.1", + "@solana/rpc-subscriptions": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/transaction-messages": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/transaction-messages/-/transaction-messages-5.5.1.tgz", + "integrity": "sha512-aXyhMCEaAp3M/4fP0akwBBQkFPr4pfwoC5CLDq999r/FUwDax2RE/h4Ic7h2Xk+JdcUwsb+rLq85Y52hq84XvQ==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-data-structures": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/instructions": "5.5.1", + "@solana/nominal-types": "5.5.1", + "@solana/rpc-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/transactions": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/transactions/-/transactions-5.5.1.tgz", + "integrity": "sha512-8hHtDxtqalZ157pnx6p8k10D7J/KY/biLzfgh9R09VNLLY3Fqi7kJvJCr7M2ik3oRll56pxhraAGCC9yIT6eOA==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-data-structures": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/instructions": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/nominal-types": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/transaction-messages": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@tanstack/query-core": { + "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", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "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.14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -1553,6 +5028,15 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1560,18 +5044,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1588,6 +5084,61 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/@vanilla-extract/css": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/@vanilla-extract/css/-/css-1.20.1.tgz", + "integrity": "sha512-5I9RNo5uZW9tsBnqrWzJqELegOqTHBrZyDFnES0gR9gJJHBB9dom1N0bwITM9tKwBcfKrTX4a6DHVeQdJ2ubQA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.0", + "@vanilla-extract/private": "^1.0.9", + "css-what": "^6.1.0", + "csstype": "^3.2.3", + "dedent": "^1.5.3", + "deep-object-diff": "^1.1.9", + "deepmerge": "^4.2.2", + "lru-cache": "^10.4.3", + "media-query-parser": "^2.0.2", + "modern-ahocorasick": "^1.0.0", + "picocolors": "^1.0.0" + } + }, + "node_modules/@vanilla-extract/css/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/@vanilla-extract/dynamic": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vanilla-extract/dynamic/-/dynamic-2.1.5.tgz", + "integrity": "sha512-QGIFGb1qyXQkbzx6X6i3+3LMc/iv/ZMBttMBL+Wm/DetQd36KsKsFg5CtH3qy+1hCA/5w93mEIIAiL4fkM8ycw==", + "license": "MIT", + "dependencies": { + "@vanilla-extract/private": "^1.0.9" + } + }, + "node_modules/@vanilla-extract/private": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@vanilla-extract/private/-/private-1.0.9.tgz", + "integrity": "sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA==", + "license": "MIT" + }, + "node_modules/@vanilla-extract/sprinkles": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@vanilla-extract/sprinkles/-/sprinkles-1.6.5.tgz", + "integrity": "sha512-HOYidLONR/SeGk8NBAeI64I4gYdsMX9vJmniL13ZcLVwawyK0s2GUENEAcGA+GYLIoeyQB61UqmhqPodJry7zA==", + "license": "MIT", + "peerDependencies": { + "@vanilla-extract/css": "^1.0.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1722,17 +5273,1553 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@wagmi/connectors": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@wagmi/connectors/-/connectors-6.2.0.tgz", + "integrity": "sha512-2NfkbqhNWdjfibb4abRMrn7u6rPjEGolMfApXss6HCDVt9AW2oVC6k8Q5FouzpJezElxLJSagWz9FW1zaRlanA==", + "license": "MIT", + "dependencies": { + "@base-org/account": "2.4.0", + "@coinbase/wallet-sdk": "4.3.6", + "@gemini-wallet/core": "0.3.2", + "@metamask/sdk": "0.33.1", + "@safe-global/safe-apps-provider": "0.18.6", + "@safe-global/safe-apps-sdk": "9.1.0", + "@walletconnect/ethereum-provider": "2.21.1", + "cbw-sdk": "npm:@coinbase/wallet-sdk@3.9.3", + "porto": "0.2.35" + }, + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "@wagmi/core": "2.22.1", + "typescript": ">=5.0.4", + "viem": "2.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@wagmi/connectors/node_modules/ox": { + "version": "0.9.17", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.9.17.tgz", + "integrity": "sha512-rKAnhzhRU3Xh3hiko+i1ZxywZ55eWQzeS/Q4HRKLx2PqfHOolisZHErSsJVipGlmQKHW5qwOED/GighEw9dbLg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.0.9", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@wagmi/connectors/node_modules/porto": { + "version": "0.2.35", + "resolved": "https://registry.npmjs.org/porto/-/porto-0.2.35.tgz", + "integrity": "sha512-gu9FfjjvvYBgQXUHWTp6n3wkTxVtEcqFotM7i3GEZeoQbvLGbssAicCz6hFZ8+xggrJWwi/RLmbwNra50SMmUQ==", + "license": "MIT", + "dependencies": { + "hono": "^4.10.3", + "idb-keyval": "^6.2.1", + "mipd": "^0.0.7", + "ox": "^0.9.6", + "zod": "^4.1.5", + "zustand": "^5.0.1" + }, + "bin": { + "porto": "dist/cli/bin/index.js" + }, + "peerDependencies": { + "@tanstack/react-query": ">=5.59.0", + "@wagmi/core": ">=2.16.3", + "expo-auth-session": ">=7.0.8", + "expo-crypto": ">=15.0.7", + "expo-web-browser": ">=15.0.8", + "react": ">=18", + "react-native": ">=0.81.4", + "typescript": ">=5.4.0", + "viem": ">=2.37.0", + "wagmi": ">=2.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/react-query": { + "optional": true + }, + "expo-auth-session": { + "optional": true + }, + "expo-crypto": { + "optional": true + }, + "expo-web-browser": { + "optional": true + }, + "react": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + }, + "wagmi": { + "optional": true + } + } + }, + "node_modules/@wagmi/connectors/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@wagmi/core": { + "version": "2.22.1", + "resolved": "https://registry.npmjs.org/@wagmi/core/-/core-2.22.1.tgz", + "integrity": "sha512-cG/xwQWsBEcKgRTkQVhH29cbpbs/TdcUJVFXCyri3ZknxhMyGv0YEjTcrNpRgt2SaswL1KrvslSNYKKo+5YEAg==", + "license": "MIT", + "dependencies": { + "eventemitter3": "5.0.1", + "mipd": "0.0.7", + "zustand": "5.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "@tanstack/query-core": ">=5.0.0", + "typescript": ">=5.0.4", + "viem": "2.x" + }, + "peerDependenciesMeta": { + "@tanstack/query-core": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@wagmi/core/node_modules/zustand": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.0.tgz", + "integrity": "sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, + "node_modules/@walletconnect/core": { + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/@walletconnect/core/-/core-2.21.1.tgz", + "integrity": "sha512-Tp4MHJYcdWD846PH//2r+Mu4wz1/ZU/fr9av1UWFiaYQ2t2TPLDiZxjLw54AAEpMqlEHemwCgiRiAmjR1NDdTQ==", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/heartbeat": "1.2.2", + "@walletconnect/jsonrpc-provider": "1.0.14", + "@walletconnect/jsonrpc-types": "1.0.4", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/jsonrpc-ws-connection": "1.0.16", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/logger": "2.1.2", + "@walletconnect/relay-api": "1.0.11", + "@walletconnect/relay-auth": "1.1.0", + "@walletconnect/safe-json": "1.0.2", + "@walletconnect/time": "1.0.2", + "@walletconnect/types": "2.21.1", + "@walletconnect/utils": "2.21.1", + "@walletconnect/window-getters": "1.0.1", + "es-toolkit": "1.33.0", + "events": "3.3.0", + "uint8arrays": "3.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@walletconnect/core/node_modules/@walletconnect/keyvaluestorage": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@walletconnect/keyvaluestorage/-/keyvaluestorage-1.1.1.tgz", + "integrity": "sha512-V7ZQq2+mSxAq7MrRqDxanTzu2RcElfK1PfNYiaVnJgJ7Q7G7hTVwF8voIBx92qsRyGHZihrwNPHuZd1aKkd0rA==", + "license": "MIT", + "dependencies": { + "@walletconnect/safe-json": "^1.0.1", + "idb-keyval": "^6.2.1", + "unstorage": "^1.9.0" + }, + "peerDependencies": { + "@react-native-async-storage/async-storage": "1.x" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@walletconnect/core/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/core/node_modules/lru-cache": { + "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" + } + }, + "node_modules/@walletconnect/core/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/core/node_modules/unstorage": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", + "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/@walletconnect/environment": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@walletconnect/environment/-/environment-1.0.1.tgz", + "integrity": "sha512-T426LLZtHj8e8rYnKfzsw1aG6+M0BT1ZxayMdv/p8yM0MU+eJDISqNY3/bccxRr4LrF9csq02Rhqt08Ibl0VRg==", + "license": "MIT", + "dependencies": { + "tslib": "1.14.1" + } + }, + "node_modules/@walletconnect/environment/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@walletconnect/ethereum-provider": { + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/@walletconnect/ethereum-provider/-/ethereum-provider-2.21.1.tgz", + "integrity": "sha512-SSlIG6QEVxClgl1s0LMk4xr2wg4eT3Zn/Hb81IocyqNSGfXpjtawWxKxiC5/9Z95f1INyBD6MctJbL/R1oBwIw==", + "deprecated": "Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases", + "license": "Apache-2.0", + "dependencies": { + "@reown/appkit": "1.7.8", + "@walletconnect/jsonrpc-http-connection": "1.0.8", + "@walletconnect/jsonrpc-provider": "1.0.14", + "@walletconnect/jsonrpc-types": "1.0.4", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/sign-client": "2.21.1", + "@walletconnect/types": "2.21.1", + "@walletconnect/universal-provider": "2.21.1", + "@walletconnect/utils": "2.21.1", + "events": "3.3.0" + } + }, + "node_modules/@walletconnect/ethereum-provider/node_modules/@walletconnect/keyvaluestorage": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@walletconnect/keyvaluestorage/-/keyvaluestorage-1.1.1.tgz", + "integrity": "sha512-V7ZQq2+mSxAq7MrRqDxanTzu2RcElfK1PfNYiaVnJgJ7Q7G7hTVwF8voIBx92qsRyGHZihrwNPHuZd1aKkd0rA==", + "license": "MIT", + "dependencies": { + "@walletconnect/safe-json": "^1.0.1", + "idb-keyval": "^6.2.1", + "unstorage": "^1.9.0" + }, + "peerDependencies": { + "@react-native-async-storage/async-storage": "1.x" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@walletconnect/ethereum-provider/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/ethereum-provider/node_modules/lru-cache": { + "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" + } + }, + "node_modules/@walletconnect/ethereum-provider/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/ethereum-provider/node_modules/unstorage": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", + "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/@walletconnect/events": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@walletconnect/events/-/events-1.0.1.tgz", + "integrity": "sha512-NPTqaoi0oPBVNuLv7qPaJazmGHs5JGyO8eEAk5VGKmJzDR7AHzD4k6ilox5kxk1iwiOnFopBOOMLs86Oa76HpQ==", + "license": "MIT", + "dependencies": { + "keyvaluestorage-interface": "^1.0.0", + "tslib": "1.14.1" + } + }, + "node_modules/@walletconnect/events/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@walletconnect/heartbeat": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@walletconnect/heartbeat/-/heartbeat-1.2.2.tgz", + "integrity": "sha512-uASiRmC5MwhuRuf05vq4AT48Pq8RMi876zV8rr8cV969uTOzWdB/k+Lj5yI2PBtB1bGQisGen7MM1GcZlQTBXw==", + "license": "MIT", + "dependencies": { + "@walletconnect/events": "^1.0.1", + "@walletconnect/time": "^1.0.2", + "events": "^3.3.0" + } + }, + "node_modules/@walletconnect/jsonrpc-http-connection": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@walletconnect/jsonrpc-http-connection/-/jsonrpc-http-connection-1.0.8.tgz", + "integrity": "sha512-+B7cRuaxijLeFDJUq5hAzNyef3e3tBDIxyaCNmFtjwnod5AGis3RToNqzFU33vpVcxFhofkpE7Cx+5MYejbMGw==", + "license": "MIT", + "dependencies": { + "@walletconnect/jsonrpc-utils": "^1.0.6", + "@walletconnect/safe-json": "^1.0.1", + "cross-fetch": "^3.1.4", + "events": "^3.3.0" + } + }, + "node_modules/@walletconnect/jsonrpc-http-connection/node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/@walletconnect/jsonrpc-provider": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@walletconnect/jsonrpc-provider/-/jsonrpc-provider-1.0.14.tgz", + "integrity": "sha512-rtsNY1XqHvWj0EtITNeuf8PHMvlCLiS3EjQL+WOkxEOA4KPxsohFnBDeyPYiNm4ZvkQdLnece36opYidmtbmow==", + "license": "MIT", + "dependencies": { + "@walletconnect/jsonrpc-utils": "^1.0.8", + "@walletconnect/safe-json": "^1.0.2", + "events": "^3.3.0" + } + }, + "node_modules/@walletconnect/jsonrpc-types": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@walletconnect/jsonrpc-types/-/jsonrpc-types-1.0.4.tgz", + "integrity": "sha512-P6679fG/M+wuWg9TY8mh6xFSdYnFyFjwFelxyISxMDrlbXokorEVXYOxiqEbrU3x1BmBoCAJJ+vtEaEoMlpCBQ==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0", + "keyvaluestorage-interface": "^1.0.0" + } + }, + "node_modules/@walletconnect/jsonrpc-utils": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@walletconnect/jsonrpc-utils/-/jsonrpc-utils-1.0.8.tgz", + "integrity": "sha512-vdeb03bD8VzJUL6ZtzRYsFMq1eZQcM3EAzT0a3st59dyLfJ0wq+tKMpmGH7HlB7waD858UWgfIcudbPFsbzVdw==", + "license": "MIT", + "dependencies": { + "@walletconnect/environment": "^1.0.1", + "@walletconnect/jsonrpc-types": "^1.0.3", + "tslib": "1.14.1" + } + }, + "node_modules/@walletconnect/jsonrpc-utils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@walletconnect/jsonrpc-ws-connection": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@walletconnect/jsonrpc-ws-connection/-/jsonrpc-ws-connection-1.0.16.tgz", + "integrity": "sha512-G81JmsMqh5nJheE1mPst1W0WfVv0SG3N7JggwLLGnI7iuDZJq8cRJvQwLGKHn5H1WTW7DEPCo00zz5w62AbL3Q==", + "license": "MIT", + "dependencies": { + "@walletconnect/jsonrpc-utils": "^1.0.6", + "@walletconnect/safe-json": "^1.0.2", + "events": "^3.3.0", + "ws": "^7.5.1" + } + }, + "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/ws": { + "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" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@walletconnect/logger": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@walletconnect/logger/-/logger-2.1.2.tgz", + "integrity": "sha512-aAb28I3S6pYXZHQm5ESB+V6rDqIYfsnHaQyzFbwUUBFY4H0OXx/YtTl8lvhUNhMMfb9UxbwEBS253TlXUYJWSw==", + "license": "MIT", + "dependencies": { + "@walletconnect/safe-json": "^1.0.2", + "pino": "7.11.0" + } + }, + "node_modules/@walletconnect/relay-api": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@walletconnect/relay-api/-/relay-api-1.0.11.tgz", + "integrity": "sha512-tLPErkze/HmC9aCmdZOhtVmYZq1wKfWTJtygQHoWtgg722Jd4homo54Cs4ak2RUFUZIGO2RsOpIcWipaua5D5Q==", + "license": "MIT", + "dependencies": { + "@walletconnect/jsonrpc-types": "^1.0.2" + } + }, + "node_modules/@walletconnect/relay-auth": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@walletconnect/relay-auth/-/relay-auth-1.1.0.tgz", + "integrity": "sha512-qFw+a9uRz26jRCDgL7Q5TA9qYIgcNY8jpJzI1zAWNZ8i7mQjaijRnWFKsCHAU9CyGjvt6RKrRXyFtFOpWTVmCQ==", + "license": "MIT", + "dependencies": { + "@noble/curves": "1.8.0", + "@noble/hashes": "1.7.0", + "@walletconnect/safe-json": "^1.0.1", + "@walletconnect/time": "^1.0.2", + "uint8arrays": "^3.0.0" + } + }, + "node_modules/@walletconnect/relay-auth/node_modules/@noble/curves": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.0.tgz", + "integrity": "sha512-j84kjAbzEnQHaSIhRPUmB3/eVXu2k3dKPl2LOrR8fSOIL+89U+7lV117EWHtq/GHM3ReGHM46iRBdZfpc4HRUQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.7.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/relay-auth/node_modules/@noble/hashes": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.0.tgz", + "integrity": "sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/safe-json": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@walletconnect/safe-json/-/safe-json-1.0.2.tgz", + "integrity": "sha512-Ogb7I27kZ3LPC3ibn8ldyUr5544t3/STow9+lzz7Sfo808YD7SBWk7SAsdBFlYgP2zDRy2hS3sKRcuSRM0OTmA==", + "license": "MIT", + "dependencies": { + "tslib": "1.14.1" + } + }, + "node_modules/@walletconnect/safe-json/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@walletconnect/sign-client": { + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/@walletconnect/sign-client/-/sign-client-2.21.1.tgz", + "integrity": "sha512-QaXzmPsMnKGV6tc4UcdnQVNOz4zyXgarvdIQibJ4L3EmLat73r5ZVl4c0cCOcoaV7rgM9Wbphgu5E/7jNcd3Zg==", + "deprecated": "Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/core": "2.21.1", + "@walletconnect/events": "1.0.1", + "@walletconnect/heartbeat": "1.2.2", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/logger": "2.1.2", + "@walletconnect/time": "1.0.2", + "@walletconnect/types": "2.21.1", + "@walletconnect/utils": "2.21.1", + "events": "3.3.0" + } + }, + "node_modules/@walletconnect/time": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@walletconnect/time/-/time-1.0.2.tgz", + "integrity": "sha512-uzdd9woDcJ1AaBZRhqy5rNC9laqWGErfc4dxA9a87mPdKOgWMD85mcFo9dIYIts/Jwocfwn07EC6EzclKubk/g==", + "license": "MIT", + "dependencies": { + "tslib": "1.14.1" + } + }, + "node_modules/@walletconnect/time/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@walletconnect/types": { + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/@walletconnect/types/-/types-2.21.1.tgz", + "integrity": "sha512-UeefNadqP6IyfwWC1Yi7ux+ljbP2R66PLfDrDm8izmvlPmYlqRerJWJvYO4t0Vvr9wrG4Ko7E0c4M7FaPKT/sQ==", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/events": "1.0.1", + "@walletconnect/heartbeat": "1.2.2", + "@walletconnect/jsonrpc-types": "1.0.4", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/logger": "2.1.2", + "events": "3.3.0" + } + }, + "node_modules/@walletconnect/types/node_modules/@walletconnect/keyvaluestorage": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@walletconnect/keyvaluestorage/-/keyvaluestorage-1.1.1.tgz", + "integrity": "sha512-V7ZQq2+mSxAq7MrRqDxanTzu2RcElfK1PfNYiaVnJgJ7Q7G7hTVwF8voIBx92qsRyGHZihrwNPHuZd1aKkd0rA==", + "license": "MIT", + "dependencies": { + "@walletconnect/safe-json": "^1.0.1", + "idb-keyval": "^6.2.1", + "unstorage": "^1.9.0" + }, + "peerDependencies": { + "@react-native-async-storage/async-storage": "1.x" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@walletconnect/types/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/types/node_modules/lru-cache": { + "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" + } + }, + "node_modules/@walletconnect/types/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/types/node_modules/unstorage": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", + "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/@walletconnect/universal-provider": { + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/@walletconnect/universal-provider/-/universal-provider-2.21.1.tgz", + "integrity": "sha512-Wjx9G8gUHVMnYfxtasC9poGm8QMiPCpXpbbLFT+iPoQskDDly8BwueWnqKs4Mx2SdIAWAwuXeZ5ojk5qQOxJJg==", + "deprecated": "Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/events": "1.0.1", + "@walletconnect/jsonrpc-http-connection": "1.0.8", + "@walletconnect/jsonrpc-provider": "1.0.14", + "@walletconnect/jsonrpc-types": "1.0.4", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/logger": "2.1.2", + "@walletconnect/sign-client": "2.21.1", + "@walletconnect/types": "2.21.1", + "@walletconnect/utils": "2.21.1", + "es-toolkit": "1.33.0", + "events": "3.3.0" + } + }, + "node_modules/@walletconnect/universal-provider/node_modules/@walletconnect/keyvaluestorage": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@walletconnect/keyvaluestorage/-/keyvaluestorage-1.1.1.tgz", + "integrity": "sha512-V7ZQq2+mSxAq7MrRqDxanTzu2RcElfK1PfNYiaVnJgJ7Q7G7hTVwF8voIBx92qsRyGHZihrwNPHuZd1aKkd0rA==", + "license": "MIT", + "dependencies": { + "@walletconnect/safe-json": "^1.0.1", + "idb-keyval": "^6.2.1", + "unstorage": "^1.9.0" + }, + "peerDependencies": { + "@react-native-async-storage/async-storage": "1.x" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@walletconnect/universal-provider/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/universal-provider/node_modules/lru-cache": { + "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" + } + }, + "node_modules/@walletconnect/universal-provider/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/universal-provider/node_modules/unstorage": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", + "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/@walletconnect/utils": { + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/@walletconnect/utils/-/utils-2.21.1.tgz", + "integrity": "sha512-VPZvTcrNQCkbGOjFRbC24mm/pzbRMUq2DSQoiHlhh0X1U7ZhuIrzVtAoKsrzu6rqjz0EEtGxCr3K1TGRqDG4NA==", + "license": "Apache-2.0", + "dependencies": { + "@noble/ciphers": "1.2.1", + "@noble/curves": "1.8.1", + "@noble/hashes": "1.7.1", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/relay-api": "1.0.11", + "@walletconnect/relay-auth": "1.1.0", + "@walletconnect/safe-json": "1.0.2", + "@walletconnect/time": "1.0.2", + "@walletconnect/types": "2.21.1", + "@walletconnect/window-getters": "1.0.1", + "@walletconnect/window-metadata": "1.0.1", + "bs58": "6.0.0", + "detect-browser": "5.3.0", + "query-string": "7.1.3", + "uint8arrays": "3.1.0", + "viem": "2.23.2" + } + }, + "node_modules/@walletconnect/utils/node_modules/@noble/ciphers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.2.1.tgz", + "integrity": "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/utils/node_modules/@noble/curves": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.1.tgz", + "integrity": "sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.7.1" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/utils/node_modules/@noble/hashes": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz", + "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/utils/node_modules/@scure/bip32": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.6.2.tgz", + "integrity": "sha512-t96EPDMbtGgtb7onKKqxRLfE5g05k7uHnHRM2xdE6BP/ZmxaLtPek4J4KfVn/90IQNrU1IOAqMgiDtUdtbe3nw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.8.1", + "@noble/hashes": "~1.7.1", + "@scure/base": "~1.2.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/utils/node_modules/@scure/bip39": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.5.4.tgz", + "integrity": "sha512-TFM4ni0vKvCfBpohoh+/lY05i9gRbSwXWngAsF4CABQxoaOHijxuaZ2R6cStDQ5CHtHO9aGJTr4ksVJASRRyMA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.7.1", + "@scure/base": "~1.2.4" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/utils/node_modules/@walletconnect/keyvaluestorage": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@walletconnect/keyvaluestorage/-/keyvaluestorage-1.1.1.tgz", + "integrity": "sha512-V7ZQq2+mSxAq7MrRqDxanTzu2RcElfK1PfNYiaVnJgJ7Q7G7hTVwF8voIBx92qsRyGHZihrwNPHuZd1aKkd0rA==", + "license": "MIT", + "dependencies": { + "@walletconnect/safe-json": "^1.0.1", + "idb-keyval": "^6.2.1", + "unstorage": "^1.9.0" + }, + "peerDependencies": { + "@react-native-async-storage/async-storage": "1.x" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@walletconnect/utils/node_modules/abitype": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.8.tgz", + "integrity": "sha512-ZeiI6h3GnW06uYDLx0etQtX/p8E24UaHHBj57RSjK7YBFe7iuVn07EDpOeP451D06sF27VOz9JJPlIKJmXgkEg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3 >=3.22.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@walletconnect/utils/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/utils/node_modules/isows": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.6.tgz", + "integrity": "sha512-lPHCayd40oW98/I0uvgaHKWCSvkzY27LjWLbtzOm64yQ+G3Q5npjjbdppU65iZXkK1Zt+kH9pfegli0AYfwYYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/@walletconnect/utils/node_modules/lru-cache": { + "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" + } + }, + "node_modules/@walletconnect/utils/node_modules/ox": { + "version": "0.6.7", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.6.7.tgz", + "integrity": "sha512-17Gk/eFsFRAZ80p5eKqv89a57uXjd3NgIf1CaXojATPBuujVc/fQSVhBeAU9JCRB+k7J50WQAyWTxK19T9GgbA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.10.1", + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@scure/bip32": "^1.5.0", + "@scure/bip39": "^1.4.0", + "abitype": "^1.0.6", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@walletconnect/utils/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/utils/node_modules/unstorage": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", + "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/@walletconnect/utils/node_modules/viem": { + "version": "2.23.2", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.23.2.tgz", + "integrity": "sha512-NVmW/E0c5crMOtbEAqMF0e3NmvQykFXhLOc/CkLIXOlzHSA6KXVz3CYVmaKqBF8/xtjsjHAGjdJN3Ru1kFJLaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.8.1", + "@noble/hashes": "1.7.1", + "@scure/bip32": "1.6.2", + "@scure/bip39": "1.5.4", + "abitype": "1.0.8", + "isows": "1.0.6", + "ox": "0.6.7", + "ws": "8.18.0" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@walletconnect/utils/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "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/@walletconnect/window-getters": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@walletconnect/window-getters/-/window-getters-1.0.1.tgz", + "integrity": "sha512-vHp+HqzGxORPAN8gY03qnbTMnhqIwjeRJNOMOAzePRg4xVEEE2WvYsI9G2NMjOknA8hnuYbU3/hwLcKbjhc8+Q==", + "license": "MIT", + "dependencies": { + "tslib": "1.14.1" + } + }, + "node_modules/@walletconnect/window-getters/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@walletconnect/window-metadata": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@walletconnect/window-metadata/-/window-metadata-1.0.1.tgz", + "integrity": "sha512-9koTqyGrM2cqFRW517BPY/iEtUDx2r1+Pwwu5m7sJ7ka79wi3EyqhqcICk/yDmv6jAS1rjKgTKXlEhanYjijcA==", + "license": "MIT", + "dependencies": { + "@walletconnect/window-getters": "^1.0.1", + "tslib": "1.14.1" + } + }, + "node_modules/@walletconnect/window-metadata/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1744,7 +6831,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -1781,6 +6867,30 @@ "node": ">=12" } }, + "node_modules/async-mutex": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.2.6.tgz", + "integrity": "sha512-Hs4R+4SPgamu6rSGW8C7cV9gaWUKEHykfzCCvIRuaVv636Ju10ZdeUbvb4TBEW0INuq2DHZqXbK4Nd3yG4RaRw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/autoprefixer": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", @@ -1818,6 +6928,70 @@ "postcss": "^8.1.0" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "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.16.0", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios-retry": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", + "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", + "license": "Apache-2.0", + "dependencies": { + "is-retry-allowed": "^2.2.0" + }, + "peerDependencies": { + "axios": "0.x || 1.x" + } + }, + "node_modules/base-x": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", + "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.28", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.28.tgz", @@ -1841,6 +7015,19 @@ "require-from-string": "^2.0.2" } }, + "node_modules/big.js": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-6.2.2.tgz", + "integrity": "sha512-y/ie+Faknx7sZA5MfGA2xKlu0GDv8RWrXGsmlteyJQ2lvoKv9GBK/fpRMc2qlSoBAgNxrixICFCBefIq8WCQpQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/bigjs" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1854,6 +7041,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bn.js": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", + "license": "MIT" + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1901,6 +7100,52 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs58": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "license": "MIT", + "dependencies": { + "base-x": "^5.0.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/bufferutil": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1911,6 +7156,62 @@ "node": ">=8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -1942,6 +7243,33 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cbw-sdk": { + "name": "@coinbase/wallet-sdk", + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/@coinbase/wallet-sdk/-/wallet-sdk-3.9.3.tgz", + "integrity": "sha512-N/A2DRIf0Y3PHc1XAMvbBUu4zisna6qAdqABMZwBMNEfWrXpAwx16pZGkYCLGE+Rvv1edbcB2LYDRnACNcmCiw==", + "license": "Apache-2.0", + "dependencies": { + "bn.js": "^5.2.1", + "buffer": "^6.0.3", + "clsx": "^1.2.1", + "eth-block-tracker": "^7.1.0", + "eth-json-rpc-filters": "^6.0.0", + "eventemitter3": "^5.0.1", + "keccak": "^3.0.3", + "preact": "^10.16.0", + "sha.js": "^2.4.11" + } + }, + "node_modules/cbw-sdk/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1959,6 +7287,27 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -2007,6 +7356,56 @@ "node": ">= 6" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -2024,6 +7423,57 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie-es": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", + "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -2038,6 +7488,18 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -2062,9 +7524,33 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, + "node_modules/cuer": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/cuer/-/cuer-0.0.3.tgz", + "integrity": "sha512-f/UNxRMRCYtfLEGECAViByA3JNflZImOk11G9hwSd+44jvzrc99J35u5l+fbdQ2+ZG441GvOpaeGYBmWquZsbQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "qr": "~0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -2127,11 +7613,32 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2145,6 +7652,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -2152,6 +7668,29 @@ "dev": true, "license": "MIT" }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -2162,6 +7701,53 @@ "node": ">=6" } }, + "node_modules/deep-object-diff": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/deep-object-diff/-/deep-object-diff-1.1.9.tgz", + "integrity": "sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==", + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2172,6 +7758,53 @@ "node": ">=6" } }, + "node_modules/derive-valtio": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/derive-valtio/-/derive-valtio-0.1.0.tgz", + "integrity": "sha512-OCg2UsLbXK7GmmpzMXhYkdO64vhJ1ROUUGaTFyHjVwEdMEcTTRj7W1TxLbSBxdY8QLBPCcp66MTyaSy0RpO17A==", + "license": "MIT", + "peerDependencies": { + "valtio": "*" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-browser": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/detect-browser/-/detect-browser-5.3.0.tgz", + "integrity": "sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==", + "license": "MIT" + }, + "node_modules/detect-europe-js": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/detect-europe-js/-/detect-europe-js-0.1.2.tgz", + "integrity": "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2179,6 +7812,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -2194,6 +7833,64 @@ "license": "MIT", "peer": true }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/eciesjs": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.18.tgz", + "integrity": "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==", + "license": "MIT", + "dependencies": { + "@ecies/ciphers": "^0.2.5", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0" + }, + "engines": { + "bun": ">=1", + "deno": ">=2", + "node": ">=16" + } + }, + "node_modules/eciesjs/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.353", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", @@ -2201,11 +7898,62 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encode-utf8": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/encode-utf8/-/encode-utf8-1.0.3.tgz", + "integrity": "sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==", + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/engine.io-client": { + "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.20.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2218,6 +7966,43 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "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" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.33.0.tgz", + "integrity": "sha512-X13Q/ZSc+vsO1q600bvNK4bxgXMkHcf//RxCmYDaRY5DAcT+eoXjY5hoAPGMdRnWQjvyLEcyauG3b6hz76LNqg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -2277,6 +8062,199 @@ "@types/estree": "^1.0.0" } }, + "node_modules/eth-block-tracker": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/eth-block-tracker/-/eth-block-tracker-7.1.0.tgz", + "integrity": "sha512-8YdplnuE1IK4xfqpf4iU7oBxnOYAc35934o083G8ao+8WM8QQtt/mVlAY6yIAdY1eMeLqg4Z//PZjJGmWGPMRg==", + "license": "MIT", + "dependencies": { + "@metamask/eth-json-rpc-provider": "^1.0.0", + "@metamask/safe-event-emitter": "^3.0.0", + "@metamask/utils": "^5.0.1", + "json-rpc-random-id": "^1.0.1", + "pify": "^3.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/eth-block-tracker/node_modules/@metamask/utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@metamask/utils/-/utils-5.0.2.tgz", + "integrity": "sha512-yfmE79bRQtnMzarnKfX7AEJBwFTxvTyw3nBQlu/5rmGXrjAeAMltoGxO62TFurxrQAFMNa/fEjIHNvungZp0+g==", + "license": "ISC", + "dependencies": { + "@ethereumjs/tx": "^4.1.2", + "@types/debug": "^4.1.7", + "debug": "^4.3.4", + "semver": "^7.3.8", + "superstruct": "^1.0.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/eth-block-tracker/node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/eth-block-tracker/node_modules/semver": { + "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" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eth-json-rpc-filters": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/eth-json-rpc-filters/-/eth-json-rpc-filters-6.0.1.tgz", + "integrity": "sha512-ITJTvqoCw6OVMLs7pI8f4gG92n/St6x80ACtHodeS+IXmO0w+t1T5OOzfSt7KLSMLRkVUoexV7tztLgDxg+iig==", + "license": "ISC", + "dependencies": { + "@metamask/safe-event-emitter": "^3.0.0", + "async-mutex": "^0.2.6", + "eth-query": "^2.1.2", + "json-rpc-engine": "^6.1.0", + "pify": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/eth-json-rpc-filters/node_modules/pify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz", + "integrity": "sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eth-query": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/eth-query/-/eth-query-2.1.2.tgz", + "integrity": "sha512-srES0ZcvwkR/wd5OQBRA1bIJMww1skfGS0s8wlwK3/oNP4+wnds60krvu5R1QbpRQjMmpG5OMIWro5s7gvDPsA==", + "license": "ISC", + "dependencies": { + "json-rpc-random-id": "^1.0.0", + "xtend": "^4.0.1" + } + }, + "node_modules/eth-rpc-errors": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/eth-rpc-errors/-/eth-rpc-errors-4.0.3.tgz", + "integrity": "sha512-Z3ymjopaoft7JDoxZcEb3pwdGh7yiYMhOwm2doUt6ASXlMavpNlK6Cre0+IMl2VSGyEU9rkiperQhp5iRxn5Pg==", + "license": "MIT", + "dependencies": { + "fast-safe-stringify": "^2.0.6" + } + }, + "node_modules/ethereum-cryptography": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ethereum-cryptography/-/ethereum-cryptography-2.2.1.tgz", + "integrity": "sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg==", + "license": "MIT", + "dependencies": { + "@noble/curves": "1.4.2", + "@noble/hashes": "1.4.0", + "@scure/bip32": "1.4.0", + "@scure/bip39": "1.3.0" + } + }, + "node_modules/ethereum-cryptography/node_modules/@noble/curves": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.4.2.tgz", + "integrity": "sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.4.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethereum-cryptography/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethereum-cryptography/node_modules/@scure/base": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", + "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethereum-cryptography/node_modules/@scure/bip32": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.4.0.tgz", + "integrity": "sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.4.0", + "@noble/hashes": "~1.4.0", + "@scure/base": "~1.1.6" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethereum-cryptography/node_modules/@scure/bip39": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.3.0.tgz", + "integrity": "sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.4.0", + "@scure/base": "~1.1.6" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", + "license": "MIT" + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -2287,6 +8265,25 @@ "node": ">=12.0.0" } }, + "node_modules/extension-port-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/extension-port-stream/-/extension-port-stream-3.0.0.tgz", + "integrity": "sha512-an2S5quJMiy5bnZKEf6AkfH/7r8CzHvhchU40gxN+OM6HPhe7Z9T1FUychcf2M9PpPOO0Hf7BAEfJkw2TDIBDw==", + "license": "ISC", + "dependencies": { + "readable-stream": "^3.6.2 || ^4.4.2", + "webextension-polyfill": ">=0.10.0 <1.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -2317,6 +8314,21 @@ "node": ">= 6" } }, + "node_modules/fast-redact": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", + "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -2340,6 +8352,79 @@ "node": ">=8" } }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -2400,12 +8485,20 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2416,6 +8509,61 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2429,11 +8577,78 @@ "node": ">=10.13.0" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/h3": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", + "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.3", + "crossws": "^0.3.5", + "defu": "^6.1.6", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.4", + "radix3": "^1.1.2", + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -2442,6 +8657,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "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" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -2455,6 +8679,32 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/idb-keyval": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz", + "integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==", + "license": "Apache-2.0" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -2465,6 +8715,37 @@ "node": ">=8" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2478,6 +8759,24 @@ "node": ">=8" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-core-module": { "version": "2.16.2", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", @@ -2504,6 +8803,34 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2534,6 +8861,104 @@ "dev": true, "license": "MIT" }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-retry-allowed": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", + "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-standalone-pwa": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-standalone-pwa/-/is-standalone-pwa-0.1.1.tgz", + "integrity": "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -2544,6 +8969,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2662,6 +9096,31 @@ "node": ">=6" } }, + "node_modules/json-rpc-engine": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/json-rpc-engine/-/json-rpc-engine-6.1.0.tgz", + "integrity": "sha512-NEdLrtrq1jUZyfjkr9OCz9EzCNhnRyWtt1PAnvnhwy6e8XETS0Dtc+ZNCO2gvuAoKsIn2+vCSowXTYE4CkgnAQ==", + "license": "ISC", + "dependencies": { + "@metamask/safe-event-emitter": "^2.0.0", + "eth-rpc-errors": "^4.0.2" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/json-rpc-engine/node_modules/@metamask/safe-event-emitter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@metamask/safe-event-emitter/-/safe-event-emitter-2.0.0.tgz", + "integrity": "sha512-/kSXhY692qiV1MXu6EeOZvg5nECLclxNXcKCxJ3cXQgYuRymRHpdx/t7JXfsK+JLjwA1e1c1/SBrlQYpusC29Q==", + "license": "ISC" + }, + "node_modules/json-rpc-random-id": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-rpc-random-id/-/json-rpc-random-id-1.0.1.tgz", + "integrity": "sha512-RJ9YYNCkhVDBuP4zN5BBtYAzEl03yq/jIIsyif0JY9qyJuQQZNeDK7anAPKKlyEtLSj2s8h6hNh2F8zO5q7ScA==", + "license": "ISC" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2675,6 +9134,27 @@ "node": ">=6" } }, + "node_modules/keccak": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/keccak/-/keccak-3.0.4.tgz", + "integrity": "sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^2.0.0", + "node-gyp-build": "^4.2.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/keyvaluestorage-interface": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/keyvaluestorage-interface/-/keyvaluestorage-interface-1.0.0.tgz", + "integrity": "sha512-8t6Q3TclQ4uZynJY9IGr2+SsIGwK9JHcO6ootkHCGA0CrQCRy+VkouYNO2xicET6b9al7QKzpebNow+gkpCL8g==", + "license": "MIT" + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -2695,6 +9175,55 @@ "dev": true, "license": "MIT" }, + "node_modules/lit": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.0.tgz", + "integrity": "sha512-DGVsqsOIHBww2DqnuZzW7QsuCdahp50ojuDaBPC7jUDRpYoH0z7kHBBYZewRzer75FwtrkmkKk7iOAwSaWdBmw==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^2.1.0", + "lit-element": "^4.2.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-element": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz", + "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0", + "@lit/reactive-element": "^2.1.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-html": { + "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" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -2754,6 +9283,26 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -2761,6 +9310,15 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/media-query-parser": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/media-query-parser/-/media-query-parser-2.0.2.tgz", + "integrity": "sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2771,6 +9329,12 @@ "node": ">= 8" } }, + "node_modules/micro-ftch": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/micro-ftch/-/micro-ftch-0.3.1.tgz", + "integrity": "sha512-/0LLxhzP0tfiR5hcQebtudP56gUurs2CLkGarnCiB/OqEyUFQ6U3paQi/tgLv0hBJYt2rnr9MNpxz4fiiugstg==", + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -2785,6 +9349,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -2795,6 +9380,32 @@ "node": ">=4" } }, + "node_modules/mipd": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/mipd/-/mipd-0.0.7.tgz", + "integrity": "sha512-aAPZPNDQ3uMTdKbuO2YmAw2TxLHO0moa4YKAyETM/DTj5FloZo+a+8tU+iv4GmW+sOxKLSRwcSFuczk+Cpt6fg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wagmi-dev" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/modern-ahocorasick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/modern-ahocorasick/-/modern-ahocorasick-1.1.0.tgz", + "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", @@ -2814,9 +9425,14 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, + "node_modules/multiformats": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", + "license": "(Apache-2.0 AND MIT)" + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -2848,6 +9464,55 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-addon-api": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.2.tgz", + "integrity": "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-mock-http": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz", + "integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==", + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.38", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", @@ -2859,12 +9524,58 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/obj-multiplex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/obj-multiplex/-/obj-multiplex-1.0.0.tgz", + "integrity": "sha512-0GNJAOsHoBHeNTvl5Vt6IWnpUEcc3uSRxzBri7EDyIcMgYvnY2JL2qdeV5zTMjWQX5OHcD5amcW2HFfDh0gjIA==", + "license": "ISC", + "dependencies": { + "end-of-stream": "^1.4.0", + "once": "^1.4.0", + "readable-stream": "^2.3.3" + } + }, + "node_modules/obj-multiplex/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/obj-multiplex/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/obj-multiplex/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/obj-multiplex/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2885,6 +9596,113 @@ "node": ">= 6" } }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/on-exit-leak-free": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-0.2.0.tgz", + "integrity": "sha512-dqaz3u44QbRXQooZLTUKU41ZrzYrcvLISVgbrzbyCMxpmSLJvZ3ZamIJIZ29P6OhZIkNIQKosdeM6t1LYbA9hg==", + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openapi-fetch": { + "version": "0.13.8", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.13.8.tgz", + "integrity": "sha512-yJ4QKRyNxE44baQ9mY5+r/kAzZ8yXMemtNAOFwOzRXJscdjSxxzWSNlyBAr+o5JjkUw9Lc3W7OIoca0cY3PYnQ==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.0.15" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.15.tgz", + "integrity": "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==", + "license": "MIT" + }, + "node_modules/ox": { + "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", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -2911,6 +9729,15 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -2939,14 +9766,12 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -2965,6 +9790,44 @@ "node": ">=0.10.0" } }, + "node_modules/pino": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-7.11.0.tgz", + "integrity": "sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.0.0", + "on-exit-leak-free": "^0.2.0", + "pino-abstract-transport": "v0.5.0", + "pino-std-serializers": "^4.0.0", + "process-warning": "^1.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.1.0", + "safe-stable-stringify": "^2.1.0", + "sonic-boom": "^2.2.1", + "thread-stream": "^0.15.1" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-0.5.0.tgz", + "integrity": "sha512-+KAgmVeqXYbTtU2FScx1XS3kNyfZ5TrXY07V96QnUSFqo2gAqlvmaxH67Lj7SWazqsMabf+58ctdTcBgnOLUOQ==", + "license": "MIT", + "dependencies": { + "duplexify": "^4.1.2", + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-4.0.0.tgz", + "integrity": "sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -2975,6 +9838,33 @@ "node": ">= 6" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/pony-cause": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-2.1.11.tgz", + "integrity": "sha512-M7LhCsdNbNgiLYiP4WjsfLUuFmCfnjdF6jKe2R9NKl4WFN+HZPGHJZ9lnLP7f9ZnKe3U9nuWD0szirmj+migUg==", + "license": "0BSD", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -3138,6 +10028,16 @@ "dev": true, "license": "MIT" }, + "node_modules/preact": { + "version": "10.24.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.2.tgz", + "integrity": "sha512-1cSoF0aCC8uaARATfrlz4VCBqE8LwZwRfLgkxJOQwAlQt6ayTmi0D9OF7nXid1POI5SZidFuG9CnlXbDfLqY/Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prettier": { "version": "3.8.3", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", @@ -3184,6 +10084,43 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-1.0.0.tgz", + "integrity": "sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==", + "license": "MIT" + }, + "node_modules/proxy-compare": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.6.0.tgz", + "integrity": "sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "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", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3194,6 +10131,60 @@ "node": ">=6" } }, + "node_modules/qr": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/qr/-/qr-0.6.0.tgz", + "integrity": "sha512-P23VoX7SipHALdiIYG+D+LT/6n22dNKwV92FAb3d+Nlki/5WisSsfLt0UDFz2XEBtuwrECTznvu+chKKFCSYhA==", + "license": "(MIT OR Apache-2.0)", + "engines": { + "node": ">= 20.19.0" + } + }, + "node_modules/qrcode": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.3.tgz", + "integrity": "sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "encode-utf8": "^1.0.3", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "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", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -3215,6 +10206,18 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -3258,6 +10261,53 @@ "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-router": { "version": "6.30.3", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", @@ -3290,6 +10340,28 @@ "react-dom": ">=16.8" } }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -3300,6 +10372,20 @@ "pify": "^2.3.0" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -3313,6 +10399,15 @@ "node": ">=8.10.0" } }, + "node_modules/real-require": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.1.0.tgz", + "integrity": "sha512-r/H9MzAWtrv8aSVjPCMFpDMl5q66GqtmmRkRjpHTsp4zBAa+snZyiQNlMONiUmEJcsnaw0wCauJ2GWODr/aFkg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -3327,6 +10422,15 @@ "node": ">=8" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3337,6 +10441,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -3439,6 +10549,52 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -3471,6 +10627,49 @@ "semver": "bin/semver.js" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3478,6 +10677,43 @@ "dev": true, "license": "ISC" }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-2.8.0.tgz", + "integrity": "sha512-kuonw1YOYYNOve5iHdSahXPOK49GqwA+LZhI6Wz/l0rP57iKyXXIHaRagOBHAPmGwJC6od2Z9zgvZ5loSgMlVg==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3488,6 +10724,24 @@ "node": ">=0.10.0" } }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3502,6 +10756,56 @@ "dev": true, "license": "MIT" }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -3538,6 +10842,15 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/superstruct": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-1.0.4.tgz", + "integrity": "sha512-7JpaAoX2NGyoFlI9NBh66BQXGONc+uE+MRS5i2iOBKuS4e+ccgMDjATgZldkah+33DakBxDHiss9kvUcGAO8UQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -3619,6 +10932,15 @@ "node": ">=0.8" } }, + "node_modules/thread-stream": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-0.15.2.tgz", + "integrity": "sha512-UkEhKIg2pD+fjkHQKyJO3yoIvAP3N6RlNFt2dUhcS1FGvCD1cQa1M/PGknCLFIyZdtJOWQjejp7bdNqmN7zwdA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.1.0" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3731,6 +11053,20 @@ "dev": true, "license": "MIT" }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3757,6 +11093,12 @@ "node": ">=16" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -3770,11 +11112,25 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -3784,6 +11140,78 @@ "node": ">=14.17" } }, + "node_modules/ua-is-frozen": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ua-is-frozen/-/ua-is-frozen-0.1.2.tgz", + "integrity": "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, + "node_modules/ua-parser-js": { + "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", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "AGPL-3.0-or-later", + "dependencies": { + "detect-europe-js": "^0.1.2", + "is-standalone-pwa": "^0.1.1", + "ua-is-frozen": "^0.1.2" + }, + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/uint8arrays": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.1.0.tgz", + "integrity": "sha512-ei5rfKtoRO8OyOIor2Rz5fhzjThwIHJZ3uyDPnDHTXbP0aMQ1RN/6AI5B5d9dBxJOU+BvOAk7ZQ1xphsX8Lrog==", + "license": "MIT", + "dependencies": { + "multiformats": "^9.4.2" + } + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici": { "version": "7.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", @@ -3794,6 +11222,12 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "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": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3825,13 +11259,165 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", + "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/valtio": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/valtio/-/valtio-1.13.2.tgz", + "integrity": "sha512-Qik0o+DSy741TmkqmRfjq+0xpZBXi/Y6+fXZLn0xNF1z/waFMbE3rkivv5Zcf9RrMUp6zswf2J7sbh2KBlba5A==", + "license": "MIT", + "dependencies": { + "derive-valtio": "0.1.0", + "proxy-compare": "2.6.0", + "use-sync-external-store": "1.2.0" + }, + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/valtio/node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/viem": { + "version": "2.52.0", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.52.0.tgz", + "integrity": "sha512-py2QPYe9e1f4DmPJCsXF7zHmyZ0PkJrBxdQZ5dvNXvzy3UzWkUn7dNfC0TMeNm6Qv1tKw3b6qXXExpx6L0oMbw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.14.27", + "ws": "8.20.1" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -3994,6 +11580,80 @@ "node": ">=18" } }, + "node_modules/wagmi": { + "version": "2.19.5", + "resolved": "https://registry.npmjs.org/wagmi/-/wagmi-2.19.5.tgz", + "integrity": "sha512-RQUfKMv6U+EcSNNGiPbdkDtJwtuFxZWLmvDiQmjjBgkuPulUwDJsKhi7gjynzJdsx2yDqhHCXkKsbbfbIsHfcQ==", + "license": "MIT", + "dependencies": { + "@wagmi/connectors": "6.2.0", + "@wagmi/core": "2.22.1", + "use-sync-external-store": "1.4.0" + }, + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "@tanstack/react-query": ">=5.0.0", + "react": ">=18", + "typescript": ">=5.0.4", + "viem": "2.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/webextension-polyfill": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/webextension-polyfill/-/webextension-polyfill-0.10.0.tgz", + "integrity": "sha512-c5s35LgVa5tFaHhrZDnr3FpQpjj1BB+RXhLTYUxGqBVN460HkbM8TBtEqdXWbpTKfzwCcjAZVF7zXCYSKtcp9g==", + "license": "MPL-2.0" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/which-typed-array": { + "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.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -4011,6 +11671,47 @@ "node": ">=8" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "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" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -4028,12 +11729,108 @@ "dev": true, "license": "MIT" }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zustand": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.3.tgz", + "integrity": "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index ec43e26..54afc91 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,16 @@ "test:watch": "vitest" }, "dependencies": { + "@rainbow-me/rainbowkit": "^2.2.11", + "@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" + "react-router-dom": "^6.28.0", + "viem": "^2.52.0", + "wagmi": "^2.19.5" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", diff --git a/src/App.tsx b/src/App.tsx index 0141b7a..786339c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,8 @@ import { I18nProvider } from "./i18n"; import { MotionProvider } from "./motion"; import { ToastProvider } from "./components/Toast"; import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide"; +import { RainbowWalletProvider } from "./wallet/RainbowWalletProvider"; +import { WalletProvider } from "./wallet/WalletProvider"; import { PublicLayout } from "./layouts/PublicLayout"; import { LocalizedHomePage } from "./pages/LocalizedHome"; import { Browse } from "./pages/Browse"; @@ -28,89 +30,107 @@ export default function App() { - - - - - - - - - }> - {/* English (root, no prefix) */} - } - /> - } /> - } - /> - } - /> - } - /> - } /> - } - /> - } /> - - {/* Each non-English language gets its own nested tree. */} - {localizedHomeRoutes.map((route) => ( - + + + + + + + + + + + }> + {/* English (root, no prefix) */} - } + path="/" + element={} /> - } /> + } /> } /> } /> } /> - } /> + } /> } /> - } /> + } + /> + + {/* Each non-English language gets its own nested tree. */} + {localizedHomeRoutes.map((route) => ( + + + } + /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + ))} - ))} - - {adminEnabled ? ( - AdminRouteTree() - ) : ( - } - /> - )} + {adminEnabled ? ( + AdminRouteTree() + ) : ( + } + /> + )} - } /> - - - - - - - + } + /> + + + + + + + + + diff --git a/src/locales/en.ts b/src/locales/en.ts index 16636d7..1003dc7 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -143,6 +143,9 @@ export const enDict: Dict = { favoritesComingSoon: "Coming Soon", favoritesComingSoonDesc: "Sign-in and favorites are in development. Stay tuned.", + walletLoginSuccess: "Wallet connected", + walletLoginFailed: "Wallet login failed", + walletDisconnected: "Wallet disconnected", featureUnavailable: "Not available yet", featureUnavailableDesc: "This feature is not available yet.", confirm: "Got it", diff --git a/src/locales/id.ts b/src/locales/id.ts index 7223cd5..7bb236b 100644 --- a/src/locales/id.ts +++ b/src/locales/id.ts @@ -143,6 +143,9 @@ export const idDict: Dict = { favoritesComingSoon: "Segera Hadir", favoritesComingSoonDesc: "Fitur masuk dan favorit sedang dikembangkan. Nantikan.", + walletLoginSuccess: "Dompet terhubung", + walletLoginFailed: "Login dompet gagal", + walletDisconnected: "Dompet terputus", featureUnavailable: "Belum tersedia", featureUnavailableDesc: "Fitur ini belum tersedia.", confirm: "Mengerti", diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 42c37a8..a6ccceb 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -143,6 +143,9 @@ export const jaDict: Dict = { favorites: "お気に入り", favoritesComingSoon: "近日公開", favoritesComingSoonDesc: "ログインとお気に入り機能は開発中です。お楽しみに。", + walletLoginSuccess: "ウォレットを接続しました", + walletLoginFailed: "ウォレットログインに失敗しました", + walletDisconnected: "ウォレットを切断しました", featureUnavailable: "未公開", featureUnavailableDesc: "この機能はまだご利用いただけません。", confirm: "了解", diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 9c45d40..2bda02f 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -143,6 +143,9 @@ export const koDict: Dict = { favoritesComingSoon: "출시 예정", favoritesComingSoonDesc: "로그인과 즐겨찾기 기능을 개발 중입니다. 많은 기대 부탁드립니다.", + walletLoginSuccess: "지갑이 연결되었습니다", + walletLoginFailed: "지갑 로그인에 실패했습니다", + walletDisconnected: "지갑 연결이 해제되었습니다", featureUnavailable: "준비 중", featureUnavailableDesc: "이 기능은 아직 사용할 수 없습니다.", confirm: "확인", diff --git a/src/locales/ms.ts b/src/locales/ms.ts index 2e41365..edced34 100644 --- a/src/locales/ms.ts +++ b/src/locales/ms.ts @@ -143,6 +143,9 @@ export const msDict: Dict = { favoritesComingSoon: "Akan Hadir", favoritesComingSoonDesc: "Ciri log masuk dan kegemaran sedang dibangunkan. Nantikan.", + walletLoginSuccess: "Dompet disambungkan", + walletLoginFailed: "Log masuk dompet gagal", + walletDisconnected: "Dompet diputuskan", featureUnavailable: "Belum tersedia", featureUnavailableDesc: "Ciri ini belum tersedia.", confirm: "Faham", diff --git a/src/locales/vi.ts b/src/locales/vi.ts index 0fe7df3..61c7017 100644 --- a/src/locales/vi.ts +++ b/src/locales/vi.ts @@ -143,6 +143,9 @@ export const viDict: Dict = { favoritesComingSoon: "Sắp ra mắt", favoritesComingSoonDesc: "Tính năng đăng nhập và yêu thích đang phát triển. Hãy chờ đón.", + walletLoginSuccess: "Đã kết nối ví", + walletLoginFailed: "Đăng nhập ví thất bại", + walletDisconnected: "Đã ngắt kết nối ví", featureUnavailable: "Chưa khả dụng", featureUnavailableDesc: "Tính năng này hiện chưa khả dụng.", confirm: "Đã hiểu", diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 44924f2..4d6547d 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -140,6 +140,9 @@ export const zhDict: Dict = { favorites: "我的收藏", favoritesComingSoon: "功能即将推出", favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。", + walletLoginSuccess: "钱包已连接", + walletLoginFailed: "钱包登录失败", + walletDisconnected: "钱包已断开", featureUnavailable: "未开放", featureUnavailableDesc: "该功能暂未开放。", confirm: "知道了", diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index dfec89e..60f8ed3 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -4,6 +4,7 @@ interface ImportMetaEnv { readonly VITE_API_URL: string; readonly VITE_API_PREFIX?: string; readonly VITE_ADMIN_UI_PREFIX?: string; + readonly VITE_WALLETCONNECT_PROJECT_ID?: string; /** When `"true"`, bundle admin UI only (no public pages); use with `VITE_ADMIN_UI_PREFIX` or default secret prefix. */ readonly VITE_ADMIN_ONLY?: string; } diff --git a/src/wallet/RainbowWalletProvider.tsx b/src/wallet/RainbowWalletProvider.tsx new file mode 100644 index 0000000..7a596a9 --- /dev/null +++ b/src/wallet/RainbowWalletProvider.tsx @@ -0,0 +1,68 @@ +import "@rainbow-me/rainbowkit/styles.css"; + +import { + RainbowKitProvider, + connectorsForWallets, + darkTheme, +} from "@rainbow-me/rainbowkit"; +import { + imTokenWallet, + metaMaskWallet, + tokenPocketWallet, +} from "@rainbow-me/rainbowkit/wallets"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState, type ReactNode } from "react"; +import { http, createConfig, WagmiProvider } from "wagmi"; +import { mainnet } from "wagmi/chains"; + +const projectId = + import.meta.env.VITE_WALLETCONNECT_PROJECT_ID || "ark-library-dev-only"; + +const connectors = connectorsForWallets( + [ + { + groupName: "ARK Library", + wallets: [metaMaskWallet, imTokenWallet, tokenPocketWallet], + }, + ], + { + appName: "ARK Library", + projectId, + }, +); + +export const wagmiConfig = createConfig({ + chains: [mainnet], + connectors, + ssr: false, + transports: { + [mainnet.id]: http(), + }, +}); + +export function RainbowWalletProvider({ children }: { children: ReactNode }) { + const [queryClient] = useState(() => new QueryClient()); + + return ( + + + + {children} + + + + ); +} + +export function hasWalletConnectProjectId(): boolean { + return Boolean(import.meta.env.VITE_WALLETCONNECT_PROJECT_ID); +} diff --git a/src/wallet/WalletProvider.tsx b/src/wallet/WalletProvider.tsx new file mode 100644 index 0000000..39f9a49 --- /dev/null +++ b/src/wallet/WalletProvider.tsx @@ -0,0 +1,136 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { useToast } from "../components/Toast"; +import { useI18n } from "../i18n"; +import { fetchWalletMe } from "./api"; +import { signInWithInjectedWallet } from "./injected"; +import { clearWalletToken, readWalletToken, writeWalletToken } from "./token"; + +type WalletStatus = "loading" | "loggedOut" | "loggedIn"; + +type WalletContextValue = { + address: string | null; + token: string | null; + status: WalletStatus; + loginModalOpen: boolean; + openLoginModal: () => void; + closeLoginModal: () => void; + signInInjected: () => Promise; + completeLogin: (token: string, wallet: string) => void; + logout: () => void; +}; + +const WalletContext = createContext(null); + +export function shortenAddress(address: string): string { + if (address.length <= 12) return address; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +} + +export function WalletProvider({ children }: { children: ReactNode }) { + const { t } = useI18n(); + const { showToast } = useToast(); + const [token, setToken] = useState(() => readWalletToken()); + const [address, setAddress] = useState(null); + const [status, setStatus] = useState( + token ? "loading" : "loggedOut", + ); + const [loginModalOpen, setLoginModalOpen] = useState(false); + + useEffect(() => { + let cancelled = false; + if (!token) { + setStatus("loggedOut"); + setAddress(null); + return; + } + + setStatus("loading"); + fetchWalletMe(token) + .then((me) => { + if (cancelled) return; + setAddress(me.wallet); + setStatus("loggedIn"); + }) + .catch(() => { + if (cancelled) return; + clearWalletToken(); + setToken(null); + setAddress(null); + setStatus("loggedOut"); + }); + + return () => { + cancelled = true; + }; + }, [token]); + + const completeLogin = useCallback((nextToken: string, wallet: string) => { + writeWalletToken(nextToken); + setToken(nextToken); + setAddress(wallet); + setStatus("loggedIn"); + setLoginModalOpen(false); + }, []); + + const signInInjected = useCallback(async () => { + try { + const res = await signInWithInjectedWallet(); + completeLogin(res.token, res.wallet); + showToast(t("walletLoginSuccess")); + } catch (error) { + const message = + error instanceof Error ? error.message : t("walletLoginFailed"); + showToast(message || t("walletLoginFailed"), "error"); + throw error; + } + }, [completeLogin, showToast, t]); + + const logout = useCallback(() => { + clearWalletToken(); + setToken(null); + setAddress(null); + setStatus("loggedOut"); + showToast(t("walletDisconnected")); + }, [showToast, t]); + + const value = useMemo( + () => ({ + address, + token, + status, + loginModalOpen, + openLoginModal: () => setLoginModalOpen(true), + closeLoginModal: () => setLoginModalOpen(false), + signInInjected, + completeLogin, + logout, + }), + [ + address, + completeLogin, + loginModalOpen, + logout, + signInInjected, + status, + token, + ], + ); + + return ( + {children} + ); +} + +export function useWallet() { + const ctx = useContext(WalletContext); + if (!ctx) throw new Error("useWallet must be used within WalletProvider"); + return ctx; +} diff --git a/src/wallet/api.ts b/src/wallet/api.ts new file mode 100644 index 0000000..861cf2c --- /dev/null +++ b/src/wallet/api.ts @@ -0,0 +1,72 @@ +import { apiBase, getJSONAuth, postJSON } from "../api"; + +export type WalletNonceResponse = { + nonce: string; + message: string; +}; + +export type WalletVerifyResponse = { + token: string; + wallet: string; +}; + +export type WalletMeResponse = { + wallet: string; + role: "user"; +}; + +export type TokenPocketLoginRequest = { + actionId: string; + nonce: string; + message: string; + qrUrl: string; + expiresAt: string; +}; + +export type TokenPocketLoginResult = + | { + status: "pending" | "expired" | "failed"; + message?: string; + error?: string; + } + | { + status: "completed"; + address: string; + message: string; + signature: string; + }; + +export function requestWalletNonce( + address: string, +): Promise { + return postJSON("/api/auth/wallet/nonce", { address }); +} + +export function verifyWalletSignature(params: { + address: string; + message: string; + signature: string; +}): Promise { + return postJSON("/api/auth/wallet/verify", params); +} + +export function fetchWalletMe(token: string): Promise { + return getJSONAuth("/api/auth/wallet/me", token); +} + +export function createTokenPocketLoginRequest(): Promise { + return postJSON( + "/api/auth/wallet/tp-login-request", + {}, + ); +} + +export async function fetchTokenPocketLoginResult( + actionId: string, +): Promise { + const res = await fetch( + `${apiBase}/api/auth/wallet/tp-result?actionId=${encodeURIComponent(actionId)}`, + ); + if (!res.ok) throw new Error(await res.text()); + return res.json() as Promise; +} diff --git a/src/wallet/injected.ts b/src/wallet/injected.ts new file mode 100644 index 0000000..cc16f66 --- /dev/null +++ b/src/wallet/injected.ts @@ -0,0 +1,39 @@ +import { requestWalletNonce, verifyWalletSignature } from "./api"; + +export type EthereumProvider = { + request: (args: { + method: string; + params?: unknown[]; + }) => Promise; +}; + +export function getInjectedEthereum(): EthereumProvider | null { + if (typeof window === "undefined") return null; + const maybeWindow = window as typeof window & { ethereum?: EthereumProvider }; + return maybeWindow.ethereum ?? null; +} + +export async function signInWithInjectedWallet(): Promise<{ + token: string; + wallet: string; +}> { + const ethereum = getInjectedEthereum(); + if (!ethereum) throw new Error("No injected wallet found"); + + const accounts = await ethereum.request({ + method: "eth_requestAccounts", + }); + const address = accounts[0]; + if (!address) throw new Error("No wallet account returned"); + + const nonce = await requestWalletNonce(address); + const signature = await ethereum.request({ + method: "personal_sign", + params: [nonce.message, address], + }); + return verifyWalletSignature({ + address, + message: nonce.message, + signature, + }); +} diff --git a/src/wallet/token.ts b/src/wallet/token.ts new file mode 100644 index 0000000..c27f308 --- /dev/null +++ b/src/wallet/token.ts @@ -0,0 +1,28 @@ +const walletTokenKey = "ark-wallet-token:v1"; + +export function readWalletToken(): string | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage.getItem(walletTokenKey); + } catch { + return null; + } +} + +export function writeWalletToken(token: string): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(walletTokenKey, token); + } catch { + // Ignore storage failures; session will only live in memory. + } +} + +export function clearWalletToken(): void { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(walletTokenKey); + } catch { + // Ignore storage failures. + } +} From 43700d9fdc14a22e0e68a9727744b7828967bd6e Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 00:32:46 +0800 Subject: [PATCH 04/89] feat: add wallet login modal --- src/App.tsx | 2 + src/layouts/PublicLayout.tsx | 7 + src/locales/en.ts | 27 ++++ src/locales/id.ts | 27 ++++ src/locales/ja.ts | 28 ++++ src/locales/ko.ts | 27 ++++ src/locales/ms.ts | 27 ++++ src/locales/vi.ts | 27 ++++ src/locales/zh-CN.ts | 26 +++ src/wallet/WalletButton.tsx | 78 +++++++++ src/wallet/WalletLoginModal.tsx | 279 ++++++++++++++++++++++++++++++++ src/wallet/deepLinks.ts | 29 ++++ 12 files changed, 584 insertions(+) create mode 100644 src/wallet/WalletButton.tsx create mode 100644 src/wallet/WalletLoginModal.tsx create mode 100644 src/wallet/deepLinks.ts diff --git a/src/App.tsx b/src/App.tsx index 786339c..8f32be0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { MotionProvider } from "./motion"; import { ToastProvider } from "./components/Toast"; import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide"; import { RainbowWalletProvider } from "./wallet/RainbowWalletProvider"; +import { WalletLoginModal } from "./wallet/WalletLoginModal"; import { WalletProvider } from "./wallet/WalletProvider"; import { PublicLayout } from "./layouts/PublicLayout"; import { LocalizedHomePage } from "./pages/LocalizedHome"; @@ -37,6 +38,7 @@ export default function App() { + diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index 297b46c..dfa375e 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -19,6 +19,7 @@ import { stripLangPrefix, } from "../languageRoutes"; import { useLocalizedPath } from "../useLocalizedPath"; +import { WalletButton } from "../wallet/WalletButton"; type PublicNavWhich = | "home" @@ -657,6 +658,9 @@ export function PublicLayout() { ariaLabel={t("langLabel")} className="hidden h-10 w-36 md:block lg:w-40" /> +
+ +
+ {open ? ( +
+
+ {wallet.address} +
+ +
+ ) : null} + + ); + } + + return ( + + ); +} diff --git a/src/wallet/WalletLoginModal.tsx b/src/wallet/WalletLoginModal.tsx new file mode 100644 index 0000000..77521ad --- /dev/null +++ b/src/wallet/WalletLoginModal.tsx @@ -0,0 +1,279 @@ +import { useConnectModal } from "@rainbow-me/rainbowkit"; +import { QRCodeSVG } from "qrcode.react"; +import { useEffect, useRef, useState } from "react"; +import { useAccount, useSignMessage } from "wagmi"; +import { useToast } from "../components/Toast"; +import { useI18n } from "../i18n"; +import { + createTokenPocketLoginRequest, + fetchTokenPocketLoginResult, + requestWalletNonce, + verifyWalletSignature, + type TokenPocketLoginRequest, +} from "./api"; +import { openWalletDeepLink } from "./deepLinks"; +import { useWallet } from "./WalletProvider"; + +const pollIntervalMs = 1800; + +type ModalState = "idle" | "tpLoading" | "tpPolling" | "rainbowSigning"; + +export function WalletLoginModal() { + const { t } = useI18n(); + const { showToast } = useToast(); + const wallet = useWallet(); + const { openConnectModal } = useConnectModal(); + const { address, isConnected } = useAccount(); + const { signMessageAsync } = useSignMessage(); + const [state, setState] = useState("idle"); + const [error, setError] = useState(""); + const [tpRequest, setTpRequest] = useState( + null, + ); + const [rainbowPending, setRainbowPending] = useState(false); + const rainbowSigningRef = useRef(false); + + const close = () => { + if (state === "tpLoading" || state === "rainbowSigning") return; + wallet.closeLoginModal(); + setError(""); + }; + + useEffect(() => { + if (!wallet.loginModalOpen || !tpRequest) return; + if (state !== "tpPolling") return; + + let cancelled = false; + const poll = async () => { + try { + const result = await fetchTokenPocketLoginResult(tpRequest.actionId); + if (cancelled) return; + if (result.status === "completed") { + const verified = await verifyWalletSignature({ + address: result.address, + message: result.message, + signature: result.signature, + }); + if (cancelled) return; + wallet.completeLogin(verified.token, verified.wallet); + showToast(t("walletLoginSuccess")); + setState("idle"); + setTpRequest(null); + return; + } + if (result.status === "expired" || result.status === "failed") { + setState("idle"); + setError(result.error || t("walletTpExpired")); + } + } catch { + if (!cancelled) setError(t("walletLoginFailed")); + } + }; + + void poll(); + const timer = window.setInterval(() => void poll(), pollIntervalMs); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [state, tpRequest, t, showToast, wallet]); + + useEffect(() => { + if (!rainbowPending || !isConnected || !address) return; + if (rainbowSigningRef.current) return; + rainbowSigningRef.current = true; + setState("rainbowSigning"); + setError(""); + + void (async () => { + try { + const nonce = await requestWalletNonce(address); + const signature = await signMessageAsync({ message: nonce.message }); + const verified = await verifyWalletSignature({ + address, + message: nonce.message, + signature, + }); + wallet.completeLogin(verified.token, verified.wallet); + showToast(t("walletLoginSuccess")); + } catch (err) { + const message = + err instanceof Error ? err.message : t("walletLoginFailed"); + setError(message || t("walletLoginFailed")); + showToast(t("walletLoginFailed"), "error"); + } finally { + setState("idle"); + setRainbowPending(false); + rainbowSigningRef.current = false; + } + })(); + }, [ + address, + isConnected, + rainbowPending, + showToast, + signMessageAsync, + t, + wallet, + ]); + + if (!wallet.loginModalOpen) return null; + + const startInjected = async () => { + setError(""); + setState("idle"); + await wallet.signInInjected().catch(() => undefined); + }; + + const startTokenPocketQr = async () => { + setError(""); + setState("tpLoading"); + try { + const req = await createTokenPocketLoginRequest(); + setTpRequest(req); + setState("tpPolling"); + } catch { + setState("idle"); + setError(t("walletTpQrFailed")); + } + }; + + const startRainbowFallback = () => { + setError(""); + setRainbowPending(true); + openConnectModal?.(); + if (!openConnectModal) setError(t("walletRainbowUnavailable")); + }; + + return ( +
+
+
+
+

+ {t("walletLoginTitle")} +

+

+ {t("walletLoginDesc")} +

+
+ +
+ +
+ + +
+
+
+

+ {t("walletTokenPocketQr")} +

+

+ {t("walletTokenPocketQrDesc")} +

+
+ +
+ {tpRequest ? ( +
+ +

+ {t("walletQrUseAnotherDevice")} +

+
+ ) : null} +
+ +
+ + + +
+ +
+
+
+

+ {t("walletRainbowFallback")} +

+

+ {t("walletRainbowFallbackDesc")} +

+
+ +
+

+ {t("walletNetworkWarning")} +

+
+
+ + {error ? ( +

+ {error} +

+ ) : null} +
+
+ ); +} diff --git a/src/wallet/deepLinks.ts b/src/wallet/deepLinks.ts new file mode 100644 index 0000000..5446b08 --- /dev/null +++ b/src/wallet/deepLinks.ts @@ -0,0 +1,29 @@ +type WalletKind = "tokenPocket" | "metaMask" | "imToken"; + +function currentDappUrl(): string { + if (typeof window === "undefined") return "https://ark-library.com"; + return window.location.href; +} + +export function walletDeepLink( + kind: WalletKind, + dappUrl = currentDappUrl(), +): string { + switch (kind) { + case "tokenPocket": + return `tpdapp://open?params=${encodeURIComponent( + JSON.stringify({ url: dappUrl, chain: "ETH" }), + )}`; + case "metaMask": + return `https://metamask.app.link/dapp/${dappUrl.replace(/^https?:\/\//, "")}`; + case "imToken": + return `imtokenv2://navigate/DappView?url=${encodeURIComponent(dappUrl)}`; + default: + return dappUrl; + } +} + +export function openWalletDeepLink(kind: WalletKind): void { + if (typeof window === "undefined") return; + window.location.href = walletDeepLink(kind); +} From 337e8f7e67c9076c05d631fcfff6d6f6a0387c4f Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 00:36:11 +0800 Subject: [PATCH 05/89] feat: add favorites state and buttons --- src/App.tsx | 194 +++++++++--------- src/api.ts | 2 + src/components/PopularRankList.tsx | 2 + src/components/RecommendedCard.tsx | 6 + .../messageStream/MessageBubble.tsx | 6 + .../messageStream/MessageStream.tsx | 6 + src/favorites/FavoriteButton.tsx | 61 ++++++ src/favorites/FavoritesProvider.tsx | 167 +++++++++++++++ src/favorites/api.ts | 98 +++++++++ src/locales/en.ts | 6 + src/locales/id.ts | 6 + src/locales/ja.ts | 6 + src/locales/ko.ts | 6 + src/locales/ms.ts | 6 + src/locales/vi.ts | 6 + src/locales/zh-CN.ts | 6 + 16 files changed, 491 insertions(+), 93 deletions(-) create mode 100644 src/favorites/FavoriteButton.tsx create mode 100644 src/favorites/FavoritesProvider.tsx create mode 100644 src/favorites/api.ts diff --git a/src/App.tsx b/src/App.tsx index 8f32be0..f851274 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { I18nProvider } from "./i18n"; import { MotionProvider } from "./motion"; import { ToastProvider } from "./components/Toast"; import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide"; +import { FavoritesProvider } from "./favorites/FavoritesProvider"; import { RainbowWalletProvider } from "./wallet/RainbowWalletProvider"; import { WalletLoginModal } from "./wallet/WalletLoginModal"; import { WalletProvider } from "./wallet/WalletProvider"; @@ -33,104 +34,111 @@ export default function App() { - - - - - - - - - - }> - {/* English (root, no prefix) */} - } - /> - } /> - } - /> - } - /> - } - /> - } /> - } - /> - } - /> + + + + + + + + + + + }> + {/* English (root, no prefix) */} + + } + /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> - {/* Each non-English language gets its own nested tree. */} - {localizedHomeRoutes.map((route) => ( - - - } - /> - } /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - ))} - + {/* Each non-English language gets its own nested tree. */} + {localizedHomeRoutes.map((route) => ( + + + } + /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + ))} + + + {adminEnabled ? ( + AdminRouteTree() + ) : ( + } + /> + )} - {adminEnabled ? ( - AdminRouteTree() - ) : ( } /> - )} - - } - /> - - - - - - - + + + + + + + + diff --git a/src/api.ts b/src/api.ts index 50a900c..f643958 100644 --- a/src/api.ts +++ b/src/api.ts @@ -261,6 +261,8 @@ export type Resource = { isRecommended: boolean; publishedAt?: string; updatedAt: string; + favoriteCount?: number; + availability?: "available" | "unavailable"; tags?: string[]; }; diff --git a/src/components/PopularRankList.tsx b/src/components/PopularRankList.tsx index 2c3b7a1..b71ce1a 100644 --- a/src/components/PopularRankList.tsx +++ b/src/components/PopularRankList.tsx @@ -24,6 +24,7 @@ import type { Post } from "../types/post"; import { downloadAttachment } from "./messageStream/utils/downloadFile"; import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide"; import { useToast } from "./Toast"; +import { FavoriteButton } from "../favorites/FavoriteButton"; const MEDALS = ["🥇", "🥈", "🥉"]; const MAX_ITEMS = 5; @@ -174,6 +175,7 @@ function PopularRankRow({
+ {r.isDownloadable ? (
; @@ -53,6 +54,11 @@ export function MessageBubble({ isVisual ? "p-0" : "px-4 py-3" }`} > + {post.linkPreview ? (
diff --git a/src/components/messageStream/MessageStream.tsx b/src/components/messageStream/MessageStream.tsx index c81bb6b..6ef9fe9 100644 --- a/src/components/messageStream/MessageStream.tsx +++ b/src/components/messageStream/MessageStream.tsx @@ -9,6 +9,7 @@ import { FilterChips } from "./FilterChips"; import { MessageBubble } from "./MessageBubble"; import { useGroupedByDay } from "./hooks/useGroupedByDay"; import { usePostStream } from "./hooks/usePostStream"; +import { useFavorites } from "../../favorites/FavoritesProvider"; export type MessageStreamProps = { scope: PostScope; @@ -30,9 +31,14 @@ export function MessageStream({ scope }: MessageStreamProps) { const { items, isLoading, error, hasMore, loadMore, reset } = usePostStream(params); + const { ensureFavoriteIds } = useFavorites(); const groups = useGroupedByDay(items, lang); const retryLabel = lang === "zh-CN" ? "重试" : "Retry"; + useEffect(() => { + void ensureFavoriteIds(items.map((item) => item.id)).catch(() => undefined); + }, [ensureFavoriteIds, items]); + const sentinelRef = useRef(null); const filterBarRef = useRef(null); const hasMoreRef = useRef(hasMore); diff --git a/src/favorites/FavoriteButton.tsx b/src/favorites/FavoriteButton.tsx new file mode 100644 index 0000000..260cd9c --- /dev/null +++ b/src/favorites/FavoriteButton.tsx @@ -0,0 +1,61 @@ +import { Heart, LoaderCircle } from "lucide-react"; +import { useEffect } from "react"; +import { useI18n } from "../i18n"; +import { useFavorites } from "./FavoritesProvider"; + +type FavoriteButtonProps = { + resourceId: string; + className?: string; + size?: "sm" | "md"; +}; + +export function FavoriteButton({ + resourceId, + className = "", + size = "md", +}: FavoriteButtonProps) { + const { t } = useI18n(); + const favorites = useFavorites(); + const status = favorites.statusFor(resourceId); + const pending = favorites.pendingIds.has(resourceId); + const isFavorite = status === "favorited"; + + useEffect(() => { + void favorites.ensureFavoriteIds([resourceId]).catch(() => undefined); + }, [favorites, resourceId]); + + const dimension = size === "sm" ? "h-9 w-9" : "h-10 w-10"; + + return ( + + ); +} diff --git a/src/favorites/FavoritesProvider.tsx b/src/favorites/FavoritesProvider.tsx new file mode 100644 index 0000000..b62982b --- /dev/null +++ b/src/favorites/FavoritesProvider.tsx @@ -0,0 +1,167 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { useToast } from "../components/Toast"; +import { useI18n } from "../i18n"; +import { useWallet } from "../wallet/WalletProvider"; +import { addFavorite, getFavoriteIds, removeFavorite } from "./api"; + +type FavoriteStatus = "unknown" | "favorited" | "notFavorited"; + +type FavoritesContextValue = { + favoriteIds: Set; + pendingIds: Set; + statusFor: (resourceId: string) => FavoriteStatus; + ensureFavoriteIds: (resourceIds: string[]) => Promise; + toggleFavorite: (resourceId: string) => Promise; + markFavorite: (resourceId: string, favorited: boolean) => void; +}; + +const FavoritesContext = createContext(null); + +export function FavoritesProvider({ children }: { children: ReactNode }) { + const { t } = useI18n(); + const { showToast } = useToast(); + const wallet = useWallet(); + const [favoriteIds, setFavoriteIds] = useState>(() => new Set()); + const [knownIds, setKnownIds] = useState>(() => new Set()); + const [pendingIds, setPendingIds] = useState>(() => new Set()); + const pendingAfterLoginRef = useRef(null); + + useEffect(() => { + if (wallet.status === "loggedOut") { + setFavoriteIds(new Set()); + setKnownIds(new Set()); + setPendingIds(new Set()); + pendingAfterLoginRef.current = null; + } + }, [wallet.status]); + + const markFavorite = useCallback((resourceId: string, favorited: boolean) => { + setKnownIds((prev) => new Set(prev).add(resourceId)); + setFavoriteIds((prev) => { + const next = new Set(prev); + if (favorited) next.add(resourceId); + else next.delete(resourceId); + return next; + }); + }, []); + + const ensureFavoriteIds = useCallback( + async (resourceIds: string[]) => { + if (!wallet.token || wallet.status !== "loggedIn") return; + const missing = [...new Set(resourceIds)].filter( + (id) => !knownIds.has(id), + ); + if (missing.length === 0) return; + const ids = await getFavoriteIds(wallet.token, missing); + setKnownIds((prev) => { + const next = new Set(prev); + missing.forEach((id) => next.add(id)); + return next; + }); + setFavoriteIds((prev) => { + const next = new Set(prev); + ids.forEach((id) => next.add(id)); + return next; + }); + }, + [knownIds, wallet.status, wallet.token], + ); + + const runFavoriteMutation = useCallback( + async (resourceId: string) => { + if (!wallet.token) return; + const currentlyFavorite = favoriteIds.has(resourceId); + setPendingIds((prev) => new Set(prev).add(resourceId)); + markFavorite(resourceId, !currentlyFavorite); + try { + if (currentlyFavorite) await removeFavorite(wallet.token, resourceId); + else await addFavorite(wallet.token, resourceId); + showToast( + currentlyFavorite ? t("favoriteRemoved") : t("favoriteAdded"), + ); + } catch (error) { + markFavorite(resourceId, currentlyFavorite); + showToast(t("favoriteFailed"), "error"); + throw error; + } finally { + setPendingIds((prev) => { + const next = new Set(prev); + next.delete(resourceId); + return next; + }); + } + }, + [favoriteIds, markFavorite, showToast, t, wallet.token], + ); + + const toggleFavorite = useCallback( + async (resourceId: string) => { + if (!wallet.token || wallet.status !== "loggedIn") { + pendingAfterLoginRef.current = resourceId; + wallet.openLoginModal(); + showToast(t("favoriteLoginRequired")); + return; + } + await runFavoriteMutation(resourceId); + }, + [runFavoriteMutation, showToast, t, wallet], + ); + + useEffect(() => { + if (wallet.status !== "loggedIn" || !wallet.token) return; + const pending = pendingAfterLoginRef.current; + if (!pending) return; + pendingAfterLoginRef.current = null; + void runFavoriteMutation(pending).catch(() => undefined); + }, [runFavoriteMutation, wallet.status, wallet.token]); + + const statusFor = useCallback( + (resourceId: string): FavoriteStatus => { + if (favoriteIds.has(resourceId)) return "favorited"; + if (knownIds.has(resourceId)) return "notFavorited"; + return "unknown"; + }, + [favoriteIds, knownIds], + ); + + const value = useMemo( + () => ({ + favoriteIds, + pendingIds, + statusFor, + ensureFavoriteIds, + toggleFavorite, + markFavorite, + }), + [ + ensureFavoriteIds, + favoriteIds, + markFavorite, + pendingIds, + statusFor, + toggleFavorite, + ], + ); + + return ( + + {children} + + ); +} + +export function useFavorites() { + const ctx = useContext(FavoritesContext); + if (!ctx) + throw new Error("useFavorites must be used within FavoritesProvider"); + return ctx; +} diff --git a/src/favorites/api.ts b/src/favorites/api.ts new file mode 100644 index 0000000..0d5dd37 --- /dev/null +++ b/src/favorites/api.ts @@ -0,0 +1,98 @@ +import { apiBase, type Resource } from "../api"; + +export type FavoriteSort = "favorited_at" | "published_at" | "hot"; + +export type FavoriteItem = { + favoritedAt: string; + resource: Resource; +}; + +export type FavoriteListResponse = { + items: FavoriteItem[]; + page: number; + limit: number; + total: number; +}; + +export type FavoriteIdsResponse = { + ids: string[]; +}; + +export type FavoriteMutationResponse = { + ok: boolean; + resourceId: string; + favorited: boolean; + favoritedAt?: string; + favoriteCount: number; +}; + +function authHeaders(token: string): HeadersInit { + return { Authorization: `Bearer ${token}` }; +} + +async function parseJSON(res: Response): Promise { + if (!res.ok) throw new Error(await res.text()); + return res.json() as Promise; +} + +export async function listFavorites( + token: string, + params: { + sort?: FavoriteSort; + page?: number; + limit?: number; + category?: string; + q?: string; + includeUnavailable?: boolean; + lang?: string; + } = {}, +): Promise { + const sp = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value === undefined || value === "") return; + sp.set(key, String(value)); + }); + const suffix = sp.toString() ? `?${sp}` : ""; + const res = await fetch(`${apiBase}/api/me/favorites${suffix}`, { + headers: authHeaders(token), + }); + return parseJSON(res); +} + +export async function getFavoriteIds( + token: string, + resourceIds: string[], +): Promise { + if (resourceIds.length === 0) return []; + const uniqueIds = [...new Set(resourceIds)].slice(0, 100); + const res = await fetch( + `${apiBase}/api/me/favorites/ids?resourceIds=${encodeURIComponent( + uniqueIds.join(","), + )}`, + { headers: authHeaders(token) }, + ); + const data = await parseJSON(res); + return data.ids; +} + +export async function addFavorite( + token: string, + resourceId: string, +): Promise { + const res = await fetch(`${apiBase}/api/me/favorites/${resourceId}`, { + method: "POST", + headers: authHeaders(token), + }); + return parseJSON(res); +} + +export async function removeFavorite( + token: string, + resourceId: string, +): Promise { + const res = await fetch(`${apiBase}/api/me/favorites/${resourceId}`, { + method: "DELETE", + headers: authHeaders(token), + }); + return parseJSON(res); +} diff --git a/src/locales/en.ts b/src/locales/en.ts index f23fb32..2f432ce 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -139,6 +139,12 @@ export const enDict: Dict = { adminSearchQuery: "Query", adminSearchTime: "Time", adminSearchId: "ID", + favoriteAdd: "Add to favorites", + favoriteRemove: "Remove from favorites", + favoriteAdded: "Added to favorites", + favoriteRemoved: "Removed from favorites", + favoriteFailed: "Could not update favorites", + favoriteLoginRequired: "Connect your wallet to save favorites", favorites: "My Favorites", favoritesComingSoon: "Coming Soon", favoritesComingSoonDesc: diff --git a/src/locales/id.ts b/src/locales/id.ts index ad902e4..49070be 100644 --- a/src/locales/id.ts +++ b/src/locales/id.ts @@ -139,6 +139,12 @@ export const idDict: Dict = { adminSearchQuery: "Kueri", adminSearchTime: "Waktu", adminSearchId: "ID", + favoriteAdd: "Tambah ke favorit", + favoriteRemove: "Hapus dari favorit", + favoriteAdded: "Ditambahkan ke favorit", + favoriteRemoved: "Dihapus dari favorit", + favoriteFailed: "Tidak dapat memperbarui favorit", + favoriteLoginRequired: "Hubungkan dompet untuk menyimpan favorit", favorites: "Favorit Saya", favoritesComingSoon: "Segera Hadir", favoritesComingSoonDesc: diff --git a/src/locales/ja.ts b/src/locales/ja.ts index d2bd593..0ff78ba 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -140,6 +140,12 @@ export const jaDict: Dict = { adminSearchQuery: "検索キーワード", adminSearchTime: "時刻", adminSearchId: "ID", + favoriteAdd: "お気に入りに追加", + favoriteRemove: "お気に入りから削除", + favoriteAdded: "お気に入りに追加しました", + favoriteRemoved: "お気に入りから削除しました", + favoriteFailed: "お気に入りを更新できませんでした", + favoriteLoginRequired: "お気に入り保存にはウォレット接続が必要です", favorites: "お気に入り", favoritesComingSoon: "近日公開", favoritesComingSoonDesc: "ログインとお気に入り機能は開発中です。お楽しみに。", diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 513735c..56e948e 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -139,6 +139,12 @@ export const koDict: Dict = { adminSearchQuery: "검색어", adminSearchTime: "시간", adminSearchId: "ID", + favoriteAdd: "즐겨찾기에 추가", + favoriteRemove: "즐겨찾기에서 제거", + favoriteAdded: "즐겨찾기에 추가되었습니다", + favoriteRemoved: "즐겨찾기에서 제거되었습니다", + favoriteFailed: "즐겨찾기를 업데이트할 수 없습니다", + favoriteLoginRequired: "즐겨찾기를 저장하려면 지갑을 연결하세요", favorites: "내 즐겨찾기", favoritesComingSoon: "출시 예정", favoritesComingSoonDesc: diff --git a/src/locales/ms.ts b/src/locales/ms.ts index b2c7cd7..10a93b4 100644 --- a/src/locales/ms.ts +++ b/src/locales/ms.ts @@ -139,6 +139,12 @@ export const msDict: Dict = { adminSearchQuery: "Kata kunci", adminSearchTime: "Masa", adminSearchId: "ID", + favoriteAdd: "Tambah ke kegemaran", + favoriteRemove: "Buang daripada kegemaran", + favoriteAdded: "Ditambah ke kegemaran", + favoriteRemoved: "Dibuang daripada kegemaran", + favoriteFailed: "Tidak dapat mengemas kini kegemaran", + favoriteLoginRequired: "Sambung dompet untuk menyimpan kegemaran", favorites: "Kegemaran Saya", favoritesComingSoon: "Akan Hadir", favoritesComingSoonDesc: diff --git a/src/locales/vi.ts b/src/locales/vi.ts index 2ac19ff..800b656 100644 --- a/src/locales/vi.ts +++ b/src/locales/vi.ts @@ -139,6 +139,12 @@ export const viDict: Dict = { adminSearchQuery: "Từ khóa", adminSearchTime: "Thời gian", adminSearchId: "ID", + favoriteAdd: "Thêm vào yêu thích", + favoriteRemove: "Xóa khỏi yêu thích", + favoriteAdded: "Đã thêm vào yêu thích", + favoriteRemoved: "Đã xóa khỏi yêu thích", + favoriteFailed: "Không thể cập nhật yêu thích", + favoriteLoginRequired: "Kết nối ví để lưu yêu thích", favorites: "Yêu thích của tôi", favoritesComingSoon: "Sắp ra mắt", favoritesComingSoonDesc: diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 5697609..cee4257 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -137,6 +137,12 @@ export const zhDict: Dict = { adminSearchQuery: "查询词", adminSearchTime: "时间", adminSearchId: "编号", + favoriteAdd: "加入收藏", + favoriteRemove: "取消收藏", + favoriteAdded: "已加入收藏", + favoriteRemoved: "已取消收藏", + favoriteFailed: "无法更新收藏", + favoriteLoginRequired: "请先连接钱包再收藏", favorites: "我的收藏", favoritesComingSoon: "功能即将推出", favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。", From de93e883c9605c9e2f6b5d72179380233f0db53a Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 00:39:36 +0800 Subject: [PATCH 06/89] feat: build favorites page --- src/locales/en.ts | 16 ++ src/locales/id.ts | 15 ++ src/locales/ja.ts | 14 ++ src/locales/ko.ts | 14 ++ src/locales/ms.ts | 15 ++ src/locales/vi.ts | 14 ++ src/locales/zh-CN.ts | 13 ++ src/pages/Favorites/index.tsx | 386 +++++++++++++++++++++++++++++++--- 8 files changed, 460 insertions(+), 27 deletions(-) diff --git a/src/locales/en.ts b/src/locales/en.ts index 2f432ce..31662f6 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -145,6 +145,22 @@ export const enDict: Dict = { favoriteRemoved: "Removed from favorites", favoriteFailed: "Could not update favorites", favoriteLoginRequired: "Connect your wallet to save favorites", + favoritesLoginDesc: + "Connect your wallet to view and manage your saved ARK resources.", + favoritesLibraryTitle: "Saved resources", + favoritesEmptyTitle: "No favorites yet", + favoritesEmptyDesc: + "Browse resources and tap the heart icon to save them here.", + favoritesNoFilteredTitle: "No matching favorites", + favoritesNoFilteredDesc: + "Try changing your search, category, or sort filters.", + favoritesFilterAllCategories: "All categories", + favoritesSortFavoritedAt: "Recently saved", + favoritesSortPublishedAt: "Newest published", + favoritesSortHot: "Hot resources", + favoritesSearchPlaceholder: "Search your favorites", + favoritesUnavailable: "Unavailable", + favoritesClearFilters: "Clear filters", favorites: "My Favorites", favoritesComingSoon: "Coming Soon", favoritesComingSoonDesc: diff --git a/src/locales/id.ts b/src/locales/id.ts index 49070be..87c975e 100644 --- a/src/locales/id.ts +++ b/src/locales/id.ts @@ -145,6 +145,21 @@ export const idDict: Dict = { favoriteRemoved: "Dihapus dari favorit", favoriteFailed: "Tidak dapat memperbarui favorit", favoriteLoginRequired: "Hubungkan dompet untuk menyimpan favorit", + favoritesLoginDesc: + "Hubungkan dompet untuk melihat dan mengelola sumber ARK yang disimpan.", + favoritesLibraryTitle: "Sumber tersimpan", + favoritesEmptyTitle: "Belum ada favorit", + favoritesEmptyDesc: + "Jelajahi sumber dan ketuk ikon hati untuk menyimpannya di sini.", + favoritesNoFilteredTitle: "Tidak ada favorit yang cocok", + favoritesNoFilteredDesc: "Coba ubah pencarian, kategori, atau urutan.", + favoritesFilterAllCategories: "Semua kategori", + favoritesSortFavoritedAt: "Baru disimpan", + favoritesSortPublishedAt: "Terbaru diterbitkan", + favoritesSortHot: "Sumber populer", + favoritesSearchPlaceholder: "Cari favorit Anda", + favoritesUnavailable: "Tidak tersedia", + favoritesClearFilters: "Hapus filter", favorites: "Favorit Saya", favoritesComingSoon: "Segera Hadir", favoritesComingSoonDesc: diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 0ff78ba..ea55119 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -146,6 +146,20 @@ export const jaDict: Dict = { favoriteRemoved: "お気に入りから削除しました", favoriteFailed: "お気に入りを更新できませんでした", favoriteLoginRequired: "お気に入り保存にはウォレット接続が必要です", + favoritesLoginDesc: + "ウォレットを接続すると、保存した ARK 資料を表示・管理できます。", + favoritesLibraryTitle: "保存した資料", + favoritesEmptyTitle: "お気に入りはまだありません", + favoritesEmptyDesc: "資料を閲覧してハートを押すと、ここに保存されます。", + favoritesNoFilteredTitle: "一致するお気に入りがありません", + favoritesNoFilteredDesc: "検索、カテゴリ、並び順を変更してみてください。", + favoritesFilterAllCategories: "すべてのカテゴリ", + favoritesSortFavoritedAt: "最近保存", + favoritesSortPublishedAt: "新しい公開順", + favoritesSortHot: "人気資料", + favoritesSearchPlaceholder: "お気に入りを検索", + favoritesUnavailable: "利用不可", + favoritesClearFilters: "フィルターをクリア", favorites: "お気に入り", favoritesComingSoon: "近日公開", favoritesComingSoonDesc: "ログインとお気に入り機能は開発中です。お楽しみに。", diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 56e948e..202c443 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -145,6 +145,20 @@ export const koDict: Dict = { favoriteRemoved: "즐겨찾기에서 제거되었습니다", favoriteFailed: "즐겨찾기를 업데이트할 수 없습니다", favoriteLoginRequired: "즐겨찾기를 저장하려면 지갑을 연결하세요", + favoritesLoginDesc: + "지갑을 연결하면 저장한 ARK 자료를 보고 관리할 수 있습니다.", + favoritesLibraryTitle: "저장한 자료", + favoritesEmptyTitle: "아직 즐겨찾기가 없습니다", + favoritesEmptyDesc: "자료를 둘러보다가 하트 아이콘을 눌러 여기에 저장하세요.", + favoritesNoFilteredTitle: "일치하는 즐겨찾기가 없습니다", + favoritesNoFilteredDesc: "검색어, 카테고리 또는 정렬을 변경해 보세요.", + favoritesFilterAllCategories: "모든 카테고리", + favoritesSortFavoritedAt: "최근 저장", + favoritesSortPublishedAt: "최신 게시", + favoritesSortHot: "인기 자료", + favoritesSearchPlaceholder: "내 즐겨찾기 검색", + favoritesUnavailable: "사용 불가", + favoritesClearFilters: "필터 지우기", favorites: "내 즐겨찾기", favoritesComingSoon: "출시 예정", favoritesComingSoonDesc: diff --git a/src/locales/ms.ts b/src/locales/ms.ts index 10a93b4..61e2d3a 100644 --- a/src/locales/ms.ts +++ b/src/locales/ms.ts @@ -145,6 +145,21 @@ export const msDict: Dict = { favoriteRemoved: "Dibuang daripada kegemaran", favoriteFailed: "Tidak dapat mengemas kini kegemaran", favoriteLoginRequired: "Sambung dompet untuk menyimpan kegemaran", + favoritesLoginDesc: + "Sambung dompet untuk melihat dan mengurus sumber ARK yang disimpan.", + favoritesLibraryTitle: "Sumber disimpan", + favoritesEmptyTitle: "Belum ada kegemaran", + favoritesEmptyDesc: + "Lihat sumber dan tekan ikon hati untuk menyimpannya di sini.", + favoritesNoFilteredTitle: "Tiada kegemaran sepadan", + favoritesNoFilteredDesc: "Cuba ubah carian, kategori atau susunan.", + favoritesFilterAllCategories: "Semua kategori", + favoritesSortFavoritedAt: "Baru disimpan", + favoritesSortPublishedAt: "Terbaru diterbitkan", + favoritesSortHot: "Sumber popular", + favoritesSearchPlaceholder: "Cari kegemaran anda", + favoritesUnavailable: "Tidak tersedia", + favoritesClearFilters: "Kosongkan penapis", favorites: "Kegemaran Saya", favoritesComingSoon: "Akan Hadir", favoritesComingSoonDesc: diff --git a/src/locales/vi.ts b/src/locales/vi.ts index 800b656..800d778 100644 --- a/src/locales/vi.ts +++ b/src/locales/vi.ts @@ -145,6 +145,20 @@ export const viDict: Dict = { favoriteRemoved: "Đã xóa khỏi yêu thích", favoriteFailed: "Không thể cập nhật yêu thích", favoriteLoginRequired: "Kết nối ví để lưu yêu thích", + favoritesLoginDesc: "Kết nối ví để xem và quản lý tài nguyên ARK đã lưu.", + favoritesLibraryTitle: "Tài nguyên đã lưu", + favoritesEmptyTitle: "Chưa có mục yêu thích", + favoritesEmptyDesc: + "Duyệt tài nguyên và bấm biểu tượng trái tim để lưu tại đây.", + favoritesNoFilteredTitle: "Không có mục phù hợp", + favoritesNoFilteredDesc: "Hãy đổi từ khóa, danh mục hoặc cách sắp xếp.", + favoritesFilterAllCategories: "Tất cả danh mục", + favoritesSortFavoritedAt: "Lưu gần đây", + favoritesSortPublishedAt: "Mới xuất bản", + favoritesSortHot: "Tài nguyên hot", + favoritesSearchPlaceholder: "Tìm trong yêu thích", + favoritesUnavailable: "Không khả dụng", + favoritesClearFilters: "Xóa bộ lọc", favorites: "Yêu thích của tôi", favoritesComingSoon: "Sắp ra mắt", favoritesComingSoonDesc: diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index cee4257..abe2d98 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -143,6 +143,19 @@ export const zhDict: Dict = { favoriteRemoved: "已取消收藏", favoriteFailed: "无法更新收藏", favoriteLoginRequired: "请先连接钱包再收藏", + favoritesLoginDesc: "连接钱包后即可查看和管理你收藏的 ARK 资料。", + favoritesLibraryTitle: "已收藏资料", + favoritesEmptyTitle: "还没有收藏", + favoritesEmptyDesc: "浏览资料时点击爱心,就可以把常用内容保存到这里。", + favoritesNoFilteredTitle: "没有符合条件的收藏", + favoritesNoFilteredDesc: "试着调整搜索、分类或排序条件。", + favoritesFilterAllCategories: "全部分类", + favoritesSortFavoritedAt: "最近收藏", + favoritesSortPublishedAt: "最新发布", + favoritesSortHot: "热门资料", + favoritesSearchPlaceholder: "搜索我的收藏", + favoritesUnavailable: "已下架", + favoritesClearFilters: "清除筛选", favorites: "我的收藏", favoritesComingSoon: "功能即将推出", favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。", diff --git a/src/pages/Favorites/index.tsx b/src/pages/Favorites/index.tsx index 46dadc5..6be9937 100644 --- a/src/pages/Favorites/index.tsx +++ b/src/pages/Favorites/index.tsx @@ -1,43 +1,375 @@ -import { Heart } from "lucide-react"; +import { Heart, Search, SlidersHorizontal, X } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; -import { useI18n } from "../../i18n"; +import { + assetUrl, + getJSON, + itemsOrEmpty, + readJSONCache, + type Category, + type Resource, +} from "../../api"; +import { FavoriteButton } from "../../favorites/FavoriteButton"; +import { listFavorites, type FavoriteSort } from "../../favorites/api"; +import { useFavorites } from "../../favorites/FavoritesProvider"; +import { langQuery, useI18n, type Lang } from "../../i18n"; import { homePathForLang } from "../../languageRoutes"; import { Reveal } from "../../motion"; import { useSetPageTitle } from "../../components/PageTitleContext"; +import { Skeleton } from "../../components/Skeleton"; +import { useWallet } from "../../wallet/WalletProvider"; +import { useLocalizedPath } from "../../useLocalizedPath"; +import { cleanCategoryDisplayName } from "../../utils/categoryDisplay"; +import { formatDateYmd } from "../../utils/format"; + +const pageSize = 24; + +function useCategories(lang: Lang) { + const [categories, setCategories] = useState([]); + + useEffect(() => { + const url = `/api/categories?lang=${encodeURIComponent(langQuery(lang))}`; + const cached = readJSONCache(url); + if (cached) setCategories(itemsOrEmpty(cached)); + let cancelled = false; + getJSON(url) + .then((items) => { + if (!cancelled) setCategories(itemsOrEmpty(items)); + }) + .catch(() => { + if (!cancelled && !cached) setCategories([]); + }); + return () => { + cancelled = true; + }; + }, [lang]); + + return categories; +} + +function FavoriteResourceCard({ resource }: { resource: Resource }) { + const { t } = useI18n(); + const lp = useLocalizedPath(); + const unavailable = resource.availability === "unavailable"; + const cover = resource.coverImage || resource.previewUrl; + const content = ( +
+
+ {cover && !unavailable ? ( + + ) : ( +
+ +
+ )} + {unavailable ? ( + + {t("favoritesUnavailable")} + + ) : null} +
+ +
+

+ {resource.title} +

+ {resource.description ? ( +

+ {resource.description} +

+ ) : null} +
+ + {cleanCategoryDisplayName(resource.categoryName)} + + {resource.type} + · + + {typeof resource.favoriteCount === "number" ? ( + · ♥ {resource.favoriteCount} + ) : null} +
+
+ + +
+ ); + + if (unavailable) return content; + return ( + + {content} + + ); +} export default function Favorites() { const { lang, t } = useI18n(); - // Show "我的收藏" in the global header, consistent with the other pages. + const wallet = useWallet(); + const { markFavorite } = useFavorites(); + const categories = useCategories(lang); + const [sort, setSort] = useState("favorited_at"); + const [category, setCategory] = useState(""); + const [queryInput, setQueryInput] = useState(""); + const [query, setQuery] = useState(""); + const [page, setPage] = useState(1); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + useSetPageTitle(t("favorites")); + useEffect(() => { + setPage(1); + }, [sort, category, query]); + + useEffect(() => { + if (!wallet.token || wallet.status !== "loggedIn") { + setItems([]); + setTotal(0); + return; + } + let cancelled = false; + setLoading(true); + setError(""); + listFavorites(wallet.token, { + sort, + category, + q: query, + page, + limit: pageSize, + includeUnavailable: true, + lang: langQuery(lang), + }) + .then((data) => { + if (cancelled) return; + const resources = itemsOrEmpty(data.items).map((item) => item.resource); + setItems(resources); + setTotal(data.total); + resources.forEach((resource) => markFavorite(resource.id, true)); + }) + .catch((err) => { + if (!cancelled) + setError(err instanceof Error ? err.message : t("loadFailed")); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [ + category, + lang, + markFavorite, + page, + query, + sort, + t, + wallet.status, + wallet.token, + ]); + + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const hasFilters = Boolean(category || query || sort !== "favorited_at"); + + const sortOptions = useMemo( + () => [ + { value: "favorited_at" as const, label: t("favoritesSortFavoritedAt") }, + { value: "published_at" as const, label: t("favoritesSortPublishedAt") }, + { value: "hot" as const, label: t("favoritesSortHot") }, + ], + [t], + ); + + if (wallet.status !== "loggedIn") { + return ( + +
+ +
+

+ {t("favorites")} +

+

+ {t("favoritesLoginDesc")} +

+ +
+ ); + } + return ( - -
- + +
+
+
+

+ {t("favorites")} +

+

+ {t("favoritesLibraryTitle")} +

+
+ + {t("backToHome")} + +
+ +
{ + event.preventDefault(); + setQuery(queryInput.trim()); + }} + > + + + + + + + +
+ + {hasFilters ? ( + + ) : null}
-

- {t("favorites")} -

+ {loading ? ( +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+ ) : error ? ( +
+ {error} +
+ ) : items.length === 0 ? ( +
+ +

+ {hasFilters + ? t("favoritesNoFilteredTitle") + : t("favoritesEmptyTitle")} +

+

+ {hasFilters + ? t("favoritesNoFilteredDesc") + : t("favoritesEmptyDesc")} +

+
+ ) : ( +
+ {items.map((resource) => ( + + ))} +
+ )} -

- {t("favoritesComingSoon")} -

- -

- {t("favoritesComingSoonDesc")} -

- - - {t("backToHome")} - + {totalPages > 1 ? ( +
+ + + {t("pageIndicator") + .replace("{{c}}", String(page)) + .replace("{{p}}", String(totalPages))} + + +
+ ) : null}
); } From 4900256423c33177c155ab5b8d033be1d12e82bf Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 00:41:06 +0800 Subject: [PATCH 07/89] feat: add favorites to latest rows --- src/components/LatestUpdateRow.tsx | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/components/LatestUpdateRow.tsx b/src/components/LatestUpdateRow.tsx index cc90c3d..e78caa9 100644 --- a/src/components/LatestUpdateRow.tsx +++ b/src/components/LatestUpdateRow.tsx @@ -5,9 +5,10 @@ import { useI18n } from "../i18n"; import { useLocalizedPath } from "../useLocalizedPath"; import { resourceTypeLabel } from "../resourceTypeLabels"; import { formatDateYmd } from "../utils/format"; +import { FavoriteButton } from "../favorites/FavoriteButton"; const LATEST_CARD_CLASS = - "flex min-h-[106px] items-start gap-4 overflow-hidden rounded-xl border border-ark-line bg-ark-panel p-4 outline-none transition hover:border-ark-gold/45 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg md:min-h-[138px] md:p-5"; + "relative flex min-h-[106px] items-start gap-4 overflow-hidden rounded-xl border border-ark-line bg-ark-panel p-4 outline-none transition hover:border-ark-gold/45 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg md:min-h-[138px] md:p-5"; export function LatestUpdateRow({ r, @@ -21,15 +22,20 @@ export function LatestUpdateRow({ const dateStr = formatDateYmd(r.updatedAt); return ( - -
+
+ +
-
+
{r.title}
@@ -43,7 +49,12 @@ export function LatestUpdateRow({
- + +
); } From 05c2252b49481d494c033e40d6579e5ea79daa8f Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 00:45:58 +0800 Subject: [PATCH 08/89] fix: close mobile menu before wallet login --- src/layouts/PublicLayout.tsx | 2 +- src/wallet/WalletButton.tsx | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index dfa375e..febd2fd 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -723,7 +723,7 @@ export function PublicLayout() { {t("popular")}
- + setOpen(false)} />
) : null} diff --git a/src/wallet/WalletButton.tsx b/src/wallet/WalletButton.tsx index 26061c3..d75f94f 100644 --- a/src/wallet/WalletButton.tsx +++ b/src/wallet/WalletButton.tsx @@ -2,7 +2,13 @@ import { useEffect, useRef, useState } from "react"; import { useI18n } from "../i18n"; import { shortenAddress, useWallet } from "./WalletProvider"; -export function WalletButton({ compact = false }: { compact?: boolean }) { +export function WalletButton({ + compact = false, + onOpenLogin, +}: { + compact?: boolean; + onOpenLogin?: () => void; +}) { const { t } = useI18n(); const wallet = useWallet(); const [open, setOpen] = useState(false); @@ -66,7 +72,10 @@ export function WalletButton({ compact = false }: { compact?: boolean }) { return (
- - -
-
-
-

- {t("walletTokenPocketQr")} -

-

- {t("walletTokenPocketQrDesc")} -

-
- -
- {tpRequest ? ( -
- -

- {t("walletQrUseAnotherDevice")} -

-
- ) : null} +

+ {mobileDevice ? t("walletChooseMobile") : t("walletChooseDesktop")} +

+
+ {walletOptions.map((option) => { + const signingThis = + state === "signing" && selectedWallet === option.kind; + return ( + + ); + })}
- -
- - - -
- -
-
-
-

- {t("walletRainbowFallback")} -

-

- {t("walletRainbowFallbackDesc")} -

-
- -
-

- {t("walletNetworkWarning")} + {!mobileDevice ? ( +

+ {t("walletDesktopHint")}

-
+ ) : null}
{error ? ( diff --git a/src/wallet/WalletProvider.tsx b/src/wallet/WalletProvider.tsx index 39f9a49..af43668 100644 --- a/src/wallet/WalletProvider.tsx +++ b/src/wallet/WalletProvider.tsx @@ -10,7 +10,7 @@ import { import { useToast } from "../components/Toast"; import { useI18n } from "../i18n"; import { fetchWalletMe } from "./api"; -import { signInWithInjectedWallet } from "./injected"; +import { signInWithInjectedWallet, type WalletKind } from "./injected"; import { clearWalletToken, readWalletToken, writeWalletToken } from "./token"; type WalletStatus = "loading" | "loggedOut" | "loggedIn"; @@ -22,7 +22,7 @@ type WalletContextValue = { loginModalOpen: boolean; openLoginModal: () => void; closeLoginModal: () => void; - signInInjected: () => Promise; + signInInjected: (kind?: WalletKind) => Promise; completeLogin: (token: string, wallet: string) => void; logout: () => void; }; @@ -80,18 +80,21 @@ export function WalletProvider({ children }: { children: ReactNode }) { setLoginModalOpen(false); }, []); - const signInInjected = useCallback(async () => { - try { - const res = await signInWithInjectedWallet(); - completeLogin(res.token, res.wallet); - showToast(t("walletLoginSuccess")); - } catch (error) { - const message = - error instanceof Error ? error.message : t("walletLoginFailed"); - showToast(message || t("walletLoginFailed"), "error"); - throw error; - } - }, [completeLogin, showToast, t]); + const signInInjected = useCallback( + async (kind?: WalletKind) => { + try { + const res = await signInWithInjectedWallet(kind); + completeLogin(res.token, res.wallet); + showToast(t("walletLoginSuccess")); + } catch (error) { + const message = + error instanceof Error ? error.message : t("walletLoginFailed"); + showToast(message || t("walletLoginFailed"), "error"); + throw error; + } + }, + [completeLogin, showToast, t], + ); const logout = useCallback(() => { clearWalletToken(); diff --git a/src/wallet/deepLinks.ts b/src/wallet/deepLinks.ts index 69ec5f6..33da445 100644 --- a/src/wallet/deepLinks.ts +++ b/src/wallet/deepLinks.ts @@ -12,7 +12,7 @@ export function walletDeepLink( switch (kind) { case "tokenPocket": return `tpdapp://open?params=${encodeURIComponent( - JSON.stringify({ url: dappUrl, chain: "ETH" }), + JSON.stringify({ url: dappUrl, chain: "BSC" }), )}`; case "metaMask": return `https://metamask.app.link/dapp/${encodeURIComponent( diff --git a/src/wallet/injected.ts b/src/wallet/injected.ts index cc16f66..16fab04 100644 --- a/src/wallet/injected.ts +++ b/src/wallet/injected.ts @@ -1,6 +1,14 @@ import { requestWalletNonce, verifyWalletSignature } from "./api"; +export type WalletKind = "tokenPocket" | "metaMask" | "imToken"; + +const bnbChainIdHex = "0x38"; + export type EthereumProvider = { + isMetaMask?: boolean; + isTokenPocket?: boolean; + isImToken?: boolean; + providers?: EthereumProvider[]; request: (args: { method: string; params?: unknown[]; @@ -13,13 +21,54 @@ export function getInjectedEthereum(): EthereumProvider | null { return maybeWindow.ethereum ?? null; } -export async function signInWithInjectedWallet(): Promise<{ +export function getInjectedWallet(kind?: WalletKind): EthereumProvider | null { + const ethereum = getInjectedEthereum(); + if (!ethereum || !kind) return ethereum; + const providers = ethereum.providers?.length + ? ethereum.providers + : [ethereum]; + const match = providers.find((provider) => { + if (kind === "metaMask") return provider.isMetaMask; + if (kind === "tokenPocket") return provider.isTokenPocket; + if (kind === "imToken") return provider.isImToken; + return false; + }); + return match ?? null; +} + +async function ensureBnbChain(ethereum: EthereumProvider): Promise { + try { + await ethereum.request({ + method: "wallet_switchEthereumChain", + params: [{ chainId: bnbChainIdHex }], + }); + } catch (error) { + const code = (error as { code?: number | string }).code; + if (code !== 4902 && code !== "4902") throw error; + await ethereum.request({ + method: "wallet_addEthereumChain", + params: [ + { + blockExplorerUrls: ["https://bscscan.com"], + chainId: bnbChainIdHex, + chainName: "BNB Smart Chain", + nativeCurrency: { decimals: 18, name: "BNB", symbol: "BNB" }, + rpcUrls: ["https://bsc-dataseed.binance.org"], + }, + ], + }); + } +} + +export async function signInWithInjectedWallet(kind?: WalletKind): Promise<{ token: string; wallet: string; }> { - const ethereum = getInjectedEthereum(); + const ethereum = getInjectedWallet(kind); if (!ethereum) throw new Error("No injected wallet found"); + await ensureBnbChain(ethereum); + const accounts = await ethereum.request({ method: "eth_requestAccounts", }); From f935f122f9032511007db6ae145c70f87c5d38d9 Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 03:20:06 +0800 Subject: [PATCH 14/89] docs: design wallet login + favorites redesign and backend checklist Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/backend-requirements-wallet-favorites.md | 133 +++++++++++++ ...-wallet-login-favorites-redesign-design.md | 183 ++++++++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 docs/backend-requirements-wallet-favorites.md create mode 100644 docs/superpowers/specs/2026-06-02-wallet-login-favorites-redesign-design.md diff --git a/docs/backend-requirements-wallet-favorites.md b/docs/backend-requirements-wallet-favorites.md new file mode 100644 index 0000000..c65bf13 --- /dev/null +++ b/docs/backend-requirements-wallet-favorites.md @@ -0,0 +1,133 @@ +# 后端需求与核对文档:钱包登录 + 收藏 + +> 面向后端(`Arkie-Library-Backend`)。 +> 本文是在前端审计「登录 + 收藏」bug 与 UI 重设计时,对后端现有实现的逐条核对,以及由此产生的后端待办。 +> +> **核心结论:后端目前几乎已经满足前端全部需求。本次重设计与 bug 修复基本是纯前端工作。后端真正需要新增的只有少量「可选项」,外加几处需要确认的契约。请不要把已完成的功能再派一遍。** + +日期:2026-06-02 +相关分支:`terry-wallet-login` + +--- + +## 0. 一句话给后端 + +> 钱包认证、TokenPocket 扫码、收藏列表/筛选/分页/可用性,**都已实现且符合前端契约**。 +> 下面 §1 是「已完成、勿动」的核对;§2 是「真正可能要后端做的事」;§3 是「前端会改但与后端无关,别误接」。 + +--- + +## 1. 已实现并符合前端契约(✅ 无需改动) + +逐条核对自后端源码(`internal/handlers/wallet_auth.go`、`wallet_tp.go`、`favorites.go`、`public.go`、`cmd/server/main.go`)。 + +### 1.1 钱包认证 + +| 端点 | 状态 | 说明 | +|---|---|---| +| `POST /api/auth/wallet/nonce` | ✅ | 返回 `{nonce, message}`,message 含一次性码,写入 `wallet_auth_nonces`,TTL 15 分钟 | +| `POST /api/auth/wallet/verify` | ✅ | EIP-191 `personal_sign` 验签恢复地址,签发 JWT | +| `GET /api/auth/wallet/me` | ✅ | Bearer JWT → `{wallet, role:"user"}` | + +关键事实(对前端 bug 很重要): + +- **验签完全链无关。** `recoverPersonalSign` 只做 EIP-191 文本哈希恢复,不校验任何 chainId。签名消息文案是 + `"ARK Database — wallet sign-in … Sign this message to log in. No transaction or gas fee."`,**不引用任何链**。 + → 因此前端登录时强制切到 BNB 链(`ensureBnbChain`)是**多余的**,删除它**不影响后端**。这是一项纯前端修复。 +- JWT:HS256,**有效期 30 天**(`SignUserWallet(..., 30*24h)`),无状态。 +- nonce 用后即删,过期自动清理。 + +### 1.2 TokenPocket 扫码登录 + +| 端点 | 状态 | +|---|---| +| `POST /api/auth/wallet/tp-login-request` | ✅ 生成 actionId/nonce/message/qrUrl,写 `wallet_tp_login_requests` | +| `POST /api/auth/wallet/tp-callback` | ✅ 钱包回调写入签名,校验 `callbackToken` | +| `GET /api/auth/wallet/tp-result?actionId=` | ✅ 轮询返回 `pending/completed/expired/failed` | + +→ 前端把扫码从「手机端」挪到「桌面端」只是 UI 位置调整,**后端无需改动**。 + +### 1.3 收藏 + +| 端点 | 状态 | 支持的能力 | +|---|---|---| +| `GET /api/me/favorites` | ✅ | `q`(title/description/body_text/tag ILIKE)、`category`(slug)、`sort`(`favorited_at`/`published_at`/`hot`)、`includeUnavailable`(默认 true)、`page`/`limit`(≤100)、返回 `total`、tags、`favoriteCount`、`availability` | +| `GET /api/me/favorites/ids?resourceIds=` | ✅ | 批量查询收藏状态 | +| `POST /api/me/favorites/{id}` | ✅ | 加收藏,返回 `{ok,resourceId,favorited,favoriteCount}` | +| `DELETE /api/me/favorites/{id}` | ✅ | 取消收藏,`favorite_count` 不低于 `favorite_base_count` | + +关键事实: + +- **下架资源可用性已支持。** `scanFavoriteItem` 会把 `status!='published' 或 is_public=false` 的资源标为 + `availability:"unavailable"`,且默认 `includeUnavailable=true` 仍返回。→ 前端「不可用资源卡片」逻辑后端已就绪。 +- `sort=hot` 定义 = `download_count + favorite_count + share_count` 降序。 +- 鉴权失败统一返回 **401**。 + +--- + +## 2. 真正可能需要后端做的事 + +按优先级。除 2.1 外多为**可选/按产品决定**。 + +### 2.1 【需确认】CORS 允许前端源 + Authorization 头 + +前端通过 `apiBase` 调 `/api/me/favorites`,并带 `Authorization: Bearer `。 +若前端与 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 全部为前端工作,无需后端介入。 diff --git a/docs/superpowers/specs/2026-06-02-wallet-login-favorites-redesign-design.md b/docs/superpowers/specs/2026-06-02-wallet-login-favorites-redesign-design.md new file mode 100644 index 0000000..d6aa7ed --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-wallet-login-favorites-redesign-design.md @@ -0,0 +1,183 @@ +# 钱包登录 + 收藏:重设计与 Bug 修复 设计文档 + +日期:2026-06-02 +分支:`terry-wallet-login` +范围:登录弹窗、Header/菜单钱包入口、收藏按钮、我的收藏页面、收藏入口 +关联: +- 需求简报 `.unipi/docs/generated/2026-06-01-wallet-favorites-ui-redesign-requirements.md` +- 后端核对 `docs/backend-requirements-wallet-favorites.md` + +--- + +## 1. 目标 + +把已上线的「钱包登录 + 收藏」从「功能能跑但设计未完善、桌面/手机均有 bug」提升到完成度合格: + +1. 修复登录流程中的真实功能 bug(强制切链、桌面误导、手机死路)。 +2. 按已批准的极简原则重做登录弹窗。 +3. 补齐完全缺失的「我的收藏」入口。 +4. 打磨收藏按钮状态与收藏页(移动端筛选、空/错误/不可用状态)。 + +**关键事实**:经核对后端,钱包认证、TokenPocket 扫码、收藏接口(筛选/排序/分页/可用性/计数)均已实现并符合契约。**本次为纯前端工作**,后端仅需确认 CORS(见后端文档 §2.1)。 + +--- + +## 2. 登录架构(决策已定) + +三条路径共存,但 UI 上分主次: + +| 路径 | 用途 | UI 位置 | +|---|---|---| +| `window.ethereum` 注入登录 | 桌面插件 / 钱包内置浏览器 | **主路径** | +| TokenPocket 自写扫码(deep link + 轮询) | 中国稳定扫码 | 「其他方式」折叠区 | +| RainbowKit / WalletConnect | MetaMask / imToken 扫码兜底 | 「其他方式」折叠区 | + +**决策**:保留并**真正接上** RainbowKit(当前为未被调用的死代码)。 +**前置项**:需在环境变量配置有效的 `VITE_WALLETCONNECT_PROJECT_ID`(当前默认 `ark-library-dev-only` 无效)。WalletConnect 兜底在部分中国网络不稳定,UI 需提示。 + +签名验证链无关(后端 EIP-191 personal_sign recover,消息不引用任何链)。 + +--- + +## 3. Bug 修复清单(前端) + +| # | 严重度 | 问题 | 修复 | +|---|---|---|---| +| B1 | 🟠 | 每次登录强制切 BNB 链(`ensureBnbChain`),多一个换网络弹窗,常见失败点 | 删除强制切链;`personal_sign` 不需要链 | +| B2 | 🟠 | 桌面弹窗摆 3 个钱包按钮,点 TP/imToken 误弹「请安装」 | 桌面只留 1 个主操作「使用浏览器钱包登录」 | +| B3 | 🟠 | 桌面无扫码(TP 扫码被包在仅手机分支) | 扫码移入桌面「其他方式」 | +| B4 | 🟠 | 手机「打开钱包 App」是死路:无反馈、App 未装无兜底 | 加跳转反馈 + 未安装兜底(提示去下载) | +| B5 | 🔴 | RainbowKit 整套加载但从未被登录流程调用 | 真正接成「其他方式」扫码兜底 | +| B6 | 🔴 | 全站无「我的收藏」入口,页面只能手敲 URL | 加 3 处入口(见 §5) | +| B7 | 🔴 | 钱包下拉只有地址 + 断开 | 下拉加「我的收藏」 | +| B8 | 🟡 | 收藏 token 过期只弹失败 toast | 401 时自动登出并引导重新登录 | +| B9 | 🟡 | `isMobileDevice` 把触屏 Mac/iPad 判为手机 | 收紧检测,避免桌面被推进 App 跳转流 | +| B10 | 🟡 | 收藏页加载失败无重试 | 错误态加重试按钮 | +| B11 | 🟡 | WalletConnect projectId 默认无效值 | 用 env,缺失时禁用扫码兜底并提示 | + +--- + +## 4. 登录弹窗设计 + +### 4.1 桌面版 + +结构(自上而下): +1. 标题「连接钱包」 +2. 说明「签名仅用于验证钱包地址,不会发起交易,也不需要 Gas」 +3. **主按钮**「使用浏览器钱包登录」(金色)→ `window.ethereum` 注入流程 +4. 辅助说明「请使用已安装钱包插件的浏览器,例如 MetaMask」 +5. 折叠「其他登录方式」(**默认折叠**),展开后: + - TokenPocket 扫码(第一项,中国常用) + - MetaMask / imToken 扫码(WalletConnect,附不稳定提示) +6. 关闭按钮 +7. 错误区(红色) + +### 4.2 手机版 + +结构: +1. 标题「连接钱包」+ 说明「请在钱包 App 中打开本站并签名登录,无交易、无 Gas」 +2. 若检测到注入钱包:**「使用当前钱包登录」**主按钮 +3. 否则:分组「打开钱包 App」+ 三个按钮(TokenPocket / MetaMask / imToken),带品牌图标 + - 点按尝试 deep link;未跳转/未安装 → 提示去下载(**不再死路**,修 B4) +4. 折叠「其他方式(扫码)」默认折叠 +5. 关闭按钮 + 错误区 + +### 4.3 通用 +- 钱包按钮配品牌彩色图标。 +- 多语言预留文字长度(en/zh-CN/zh-TW/ko/ja/vi/id/ms),按钮不溢出。 +- 弹窗在手机上可滚动、不被遮挡。 + +--- + +## 5. 钱包入口与收藏入口 + +### 5.1 Header 钱包入口 +- 未登录:`Connect Wallet / 连接钱包` 主按钮(桌面右侧 / 手机菜单内)。 +- 已登录:短地址 `0x12…ab34` + 绿点;点击展开下拉。 + +### 5.2 钱包下拉(已登录,修 B7) +顺序:完整地址 → **♥ 我的收藏**(新增)→ 断开连接。 + +### 5.3 「我的收藏」入口策略:**始终显示(方案 B)** +- 桌面钱包下拉、手机菜单中**始终**显示「我的收藏」入口。 +- 未登录点击 → 落到 `/favorites` 的「连接钱包查看收藏」引导页(现状已有,保留)。 +- 手机菜单导航项中加「♥ 我的收藏」。 + +### 5.4 收藏按钮触发登录(保留现状逻辑) +未登录点 ♥ → 打开登录弹窗 → 登录成功后自动补上本次收藏(`pendingAfterLogin` 已实现)。 + +--- + +## 6. 收藏按钮(`FavoriteButton`) + +四态,一眼可分: +- 未收藏:空心 ♡,低对比底。 +- 已收藏:实心 ♥,品牌金填充。 +- 加载中:转圈(`LoaderCircle`)。 +- 请求中:禁用 + 降透明。 + +行为: +- 点击 `preventDefault + stopPropagation`,不误触进详情(已实现,保留)。 +- 增加点击微动效(`active:scale`)。 +- 乐观更新,失败回滚 + 错误 toast(已实现,保留)。 + +摆放:推荐卡 / 最新 / 热门 / 内容流 / 收藏页卡片右上角,不挡主内容、不与下载/预览混淆。 + +--- + +## 7. 我的收藏页面 `/favorites` + +### 7.1 未登录 +图标 + 标题 + 说明 + 「连接钱包」CTA(现状已有,保留视觉打磨)。 + +### 7.2 已登录 +- **桌面**筛选一行:搜索 + 排序 + 分类 + 搜索按钮(现状保留)。 +- **移动端**:搜索框单独一行;排序/分类收进**「筛选抽屉」**,解决现状 4 控件挤压(新增)。 +- 列表:收藏资源卡(封面/标题/描述/分类/类型/更新时间/收藏数/收藏按钮)。 +- 分页:上一页/下一页 + 页码(现状保留)。 +- 「清除筛选」当存在筛选时显示。 + +### 7.3 状态 +- **不可用/下架**:黄边 + 「不可用」标 + 不可点进详情 + 保留移除按钮(后端 `availability` 已支持)。 +- **空状态**:区分「还没有收藏」与「筛选无结果」,后者给清除筛选入口。 +- **错误**:加载失败提示 +(新增)**重试按钮**(修 B10)。 +- **加载**:4 张 skeleton,布局不跳。 + +排序选项:最近收藏 / 最近发布 / 热门(后端 `favorited_at`/`published_at`/`hot` 已支持)。 + +--- + +## 8. 多语言 +所有新增/改动文案覆盖 8 语言(en、zh-CN、zh-TW、ko、ja、vi、id、ms),key 写入 `src/locales/*`。移动端按钮预留长文本。 + +--- + +## 9. 验收清单 + +登录: +- [ ] 桌面弹窗只有 1 个主操作;扫码在折叠区。 +- [ ] 手机可打开 TP/MetaMask/imToken;未安装有兜底。 +- [ ] 登录不再强制切链。 +- [ ] RainbowKit 真正接通(projectId 有效时);无效时扫码兜底禁用并提示。 +- [ ] 已登录显示短地址,可断开。 + +收藏: +- [ ] 钱包下拉、手机菜单均有「我的收藏」入口(始终显示)。 +- [ ] 收藏按钮四态清楚,不与卡片点击冲突。 +- [ ] 未登录点收藏 → 引导登录 → 自动补收藏。 +- [ ] token 过期自动登出并引导重登。 + +收藏页: +- [ ] 桌面一行筛选;移动端筛选抽屉。 +- [ ] 不可用/空/错误(含重试)/骨架屏 完整。 +- [ ] Desktop 与 mobile 均验证。 + +质量门槛(实现后):`npx tsc --noEmit`、`npm run format:check`、`npm test` 全绿。 + +--- + +## 10. 不做(YAGNI) +- 服务端登出 / token 撤销(保持无状态 JWT)。 +- ENS、链上读取、交易。 +- 收藏分组/文件夹、批量操作(本期不做)。 +- 收藏 `q` 全文索引优化(数据量小,暂不做)。 From 7abe4a868c27170a2bc7179755aa8077c8932ad2 Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 03:43:13 +0800 Subject: [PATCH 15/89] 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) --- src/favorites/FavoritesProvider.tsx | 24 +- src/favorites/api.ts | 17 +- src/layouts/PublicLayout.tsx | 30 ++- src/locales/en.ts | 16 +- src/locales/id.ts | 35 ++- src/locales/ja.ts | 31 +++ src/locales/ko.ts | 33 ++- src/locales/ms.ts | 35 ++- src/locales/vi.ts | 34 ++- src/locales/zh-CN.ts | 16 +- src/pages/Favorites/index.tsx | 104 +++++---- src/wallet/WalletBrandIcon.tsx | 33 +++ src/wallet/WalletButton.tsx | 12 + src/wallet/WalletLoginModal.tsx | 325 ++++++++++++++++++++++------ src/wallet/deepLinks.ts | 10 + src/wallet/injected.ts | 31 +-- src/wallet/useWalletConnectLogin.ts | 84 +++++++ 17 files changed, 715 insertions(+), 155 deletions(-) create mode 100644 src/wallet/WalletBrandIcon.tsx create mode 100644 src/wallet/useWalletConnectLogin.ts diff --git a/src/favorites/FavoritesProvider.tsx b/src/favorites/FavoritesProvider.tsx index 3c4c082..f497938 100644 --- a/src/favorites/FavoritesProvider.tsx +++ b/src/favorites/FavoritesProvider.tsx @@ -11,7 +11,12 @@ import { import { useToast } from "../components/Toast"; import { useI18n } from "../i18n"; import { useWallet } from "../wallet/WalletProvider"; -import { addFavorite, getFavoriteIds, removeFavorite } from "./api"; +import { + addFavorite, + getFavoriteIds, + isFavoritesAuthError, + removeFavorite, +} from "./api"; type FavoriteStatus = "unknown" | "favorited" | "notFavorited"; @@ -30,7 +35,13 @@ export function FavoritesProvider({ children }: { children: ReactNode }) { const { t } = useI18n(); const { showToast } = useToast(); const wallet = useWallet(); - const { address, openLoginModal, status, token } = wallet; + const { address, logout, openLoginModal, status, token } = wallet; + + const handleAuthError = useCallback(() => { + logout(); + openLoginModal(); + showToast(t("favoriteSessionExpired")); + }, [logout, openLoginModal, showToast, t]); const [favoriteIds, setFavoriteIds] = useState>(() => new Set()); const [knownIds, setKnownIds] = useState>(() => new Set()); const [pendingIds, setPendingIds] = useState>(() => new Set()); @@ -113,6 +124,8 @@ export function FavoritesProvider({ children }: { children: ReactNode }) { ids.forEach((id) => next.add(id)); return next; }); + } catch (error) { + if (isFavoritesAuthError(error)) handleAuthError(); } finally { requestIds.forEach((id) => inFlightIdsRef.current.delete(id)); if (queuedIdsRef.current.size > 0 && batchTimerRef.current === null) { @@ -122,7 +135,7 @@ export function FavoritesProvider({ children }: { children: ReactNode }) { }, 0); } } - }, []); + }, [handleAuthError]); const ensureFavoriteIds = useCallback( async (resourceIds: string[]) => { @@ -158,7 +171,8 @@ export function FavoritesProvider({ children }: { children: ReactNode }) { ); } catch (error) { markFavorite(resourceId, currentlyFavorite); - showToast(t("favoriteFailed"), "error"); + if (isFavoritesAuthError(error)) handleAuthError(); + else showToast(t("favoriteFailed"), "error"); throw error; } finally { setPendingIds((prev) => { @@ -168,7 +182,7 @@ export function FavoritesProvider({ children }: { children: ReactNode }) { }); } }, - [favoriteIds, markFavorite, showToast, t, token], + [favoriteIds, handleAuthError, markFavorite, showToast, t, token], ); const toggleFavorite = useCallback( diff --git a/src/favorites/api.ts b/src/favorites/api.ts index 0d5dd37..a25f47d 100644 --- a/src/favorites/api.ts +++ b/src/favorites/api.ts @@ -30,8 +30,23 @@ function authHeaders(token: string): HeadersInit { return { Authorization: `Bearer ${token}` }; } +/** HTTP error that preserves the status code so callers can react to 401s. */ +export class FavoriteHttpError extends Error { + readonly status: number; + constructor(status: number, message: string) { + super(message || `Request failed (${status})`); + this.name = "FavoriteHttpError"; + this.status = status; + } +} + +/** True when an error means the wallet session is no longer authorized. */ +export function isFavoritesAuthError(error: unknown): boolean { + return error instanceof FavoriteHttpError && error.status === 401; +} + async function parseJSON(res: Response): Promise { - if (!res.ok) throw new Error(await res.text()); + if (!res.ok) throw new FavoriteHttpError(res.status, await res.text()); return res.json() as Promise; } diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index febd2fd..0b1e969 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -1,4 +1,10 @@ -import { ChevronDown, Menu, Search as SearchIcon, X } from "lucide-react"; +import { + ChevronDown, + Heart, + Menu, + Search as SearchIcon, + X, +} from "lucide-react"; import { AnimatePresence, m } from "framer-motion"; import { useEffect, useRef, useState } from "react"; import { Link, useLocation, useNavigate, useOutlet } from "react-router-dom"; @@ -637,6 +643,19 @@ export function PublicLayout() {
+ + +
+ +
+ + - - - +
) : items.length === 0 ? (
diff --git a/src/wallet/WalletBrandIcon.tsx b/src/wallet/WalletBrandIcon.tsx new file mode 100644 index 0000000..86eeaed --- /dev/null +++ b/src/wallet/WalletBrandIcon.tsx @@ -0,0 +1,33 @@ +import type { WalletKind } from "./injected"; + +type Brand = { bg: string; label: string }; + +const brands: Record = { + tokenPocket: { bg: "#2980FE", label: "TP" }, + metaMask: { bg: "#F6851B", label: "M" }, + imToken: { bg: "#11C4D1", label: "im" }, +}; + +/** + * Lightweight brand badge for wallet buttons — a rounded square tinted with the + * wallet's brand color and its monogram. Keeps bundle small while making each + * wallet visually distinguishable. + */ +export function WalletBrandIcon({ + kind, + size = 28, +}: { + kind: WalletKind; + size?: number; +}) { + const brand = brands[kind]; + return ( + + ); +} diff --git a/src/wallet/WalletButton.tsx b/src/wallet/WalletButton.tsx index d75f94f..33deaf7 100644 --- a/src/wallet/WalletButton.tsx +++ b/src/wallet/WalletButton.tsx @@ -1,5 +1,8 @@ +import { Heart } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { Link } from "react-router-dom"; import { useI18n } from "../i18n"; +import { useLocalizedPath } from "../useLocalizedPath"; import { shortenAddress, useWallet } from "./WalletProvider"; export function WalletButton({ @@ -10,6 +13,7 @@ export function WalletButton({ onOpenLogin?: () => void; }) { const { t } = useI18n(); + const lp = useLocalizedPath(); const wallet = useWallet(); const [open, setOpen] = useState(false); const rootRef = useRef(null); @@ -53,6 +57,14 @@ export function WalletButton({
{wallet.address}
+ setOpen(false)} + className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm font-medium text-neutral-100 transition hover:bg-ark-gold/10 hover:text-ark-gold" + > + + {t("favorites")} + + {!mobileDevice ? ( +

+ {t("walletDesktopHint")} +

+ ) : null} + + ) : ( +
+

+ {t("walletOpenWalletApp")} +

+ {appWallets.map((option) => ( - ); - })} + ))} + {openingWallet ? ( +
+

{withWallet("walletOpening", openingWallet)}

+

{t("walletAppNotInstalled")}

+
+ + {withWallet("walletDownloadApp", openingWallet)} + + +
+
+ ) : null} +
+ )} + +
+ + + {showOther ? ( +
+ {/* TokenPocket QR — stable path for China users (works on desktop too). */} +
+

+ {t("walletTokenPocketQr")} +

+

+ {t("walletTokenPocketQrDesc")} +

+ + {tpRequest ? ( +
+ +

+ {t("walletQrUseAnotherDevice")} +

+
+ ) : null} +
+ + {/* MetaMask / imToken QR via WalletConnect — gated on a real project id. */} +
+

+ {t("walletRainbowFallback")} +

+

+ {t("walletRainbowFallbackDesc")} +

+ {wc.available ? ( + <> + +

+ {t("walletNetworkWarning")} +

+ + ) : ( +

+ {t("walletRainbowUnavailable")} +

+ )} +
+
+ ) : null}
- {!mobileDevice ? ( -

- {t("walletDesktopHint")} -

- ) : null}
- {error ? ( + {error || wc.error ? (

- {error} + {error || wc.error}

) : null}
); } + +function walletNameKey(kind: WalletKind): string { + if (kind === "tokenPocket") return "walletTokenPocket"; + if (kind === "metaMask") return "walletMetaMask"; + return "walletImToken"; +} diff --git a/src/wallet/deepLinks.ts b/src/wallet/deepLinks.ts index 33da445..08bdc35 100644 --- a/src/wallet/deepLinks.ts +++ b/src/wallet/deepLinks.ts @@ -29,3 +29,13 @@ export function openWalletDeepLink(kind: WalletKind): void { if (typeof window === "undefined") return; window.location.href = walletDeepLink(kind); } + +const downloadUrls: Record = { + tokenPocket: "https://www.tokenpocket.pro/en/download/app", + metaMask: "https://metamask.io/download/", + imToken: "https://token.im/download", +}; + +export function walletDownloadUrl(kind: WalletKind): string { + return downloadUrls[kind]; +} diff --git a/src/wallet/injected.ts b/src/wallet/injected.ts index 16fab04..ef40c8b 100644 --- a/src/wallet/injected.ts +++ b/src/wallet/injected.ts @@ -2,8 +2,6 @@ import { requestWalletNonce, verifyWalletSignature } from "./api"; export type WalletKind = "tokenPocket" | "metaMask" | "imToken"; -const bnbChainIdHex = "0x38"; - export type EthereumProvider = { isMetaMask?: boolean; isTokenPocket?: boolean; @@ -36,30 +34,6 @@ export function getInjectedWallet(kind?: WalletKind): EthereumProvider | null { return match ?? null; } -async function ensureBnbChain(ethereum: EthereumProvider): Promise { - try { - await ethereum.request({ - method: "wallet_switchEthereumChain", - params: [{ chainId: bnbChainIdHex }], - }); - } catch (error) { - const code = (error as { code?: number | string }).code; - if (code !== 4902 && code !== "4902") throw error; - await ethereum.request({ - method: "wallet_addEthereumChain", - params: [ - { - blockExplorerUrls: ["https://bscscan.com"], - chainId: bnbChainIdHex, - chainName: "BNB Smart Chain", - nativeCurrency: { decimals: 18, name: "BNB", symbol: "BNB" }, - rpcUrls: ["https://bsc-dataseed.binance.org"], - }, - ], - }); - } -} - export async function signInWithInjectedWallet(kind?: WalletKind): Promise<{ token: string; wallet: string; @@ -67,8 +41,9 @@ export async function signInWithInjectedWallet(kind?: WalletKind): Promise<{ const ethereum = getInjectedWallet(kind); if (!ethereum) throw new Error("No injected wallet found"); - await ensureBnbChain(ethereum); - + // Login is signature-only (EIP-191 personal_sign). The backend verifies the + // recovered address and never inspects chainId, so we deliberately do NOT + // switch or add any chain — that only adds a failure-prone wallet popup. const accounts = await ethereum.request({ method: "eth_requestAccounts", }); diff --git a/src/wallet/useWalletConnectLogin.ts b/src/wallet/useWalletConnectLogin.ts new file mode 100644 index 0000000..4240053 --- /dev/null +++ b/src/wallet/useWalletConnectLogin.ts @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useConnectModal } from "@rainbow-me/rainbowkit"; +import { useAccount, useDisconnect, useSignMessage } from "wagmi"; +import { requestWalletNonce, verifyWalletSignature } from "./api"; +import { hasWalletConnectProjectId } from "./RainbowWalletProvider"; +import { useWallet } from "./WalletProvider"; + +export type WalletConnectLoginState = "idle" | "connecting" | "signing"; + +/** + * MetaMask / imToken QR fallback via RainbowKit + WalletConnect. + * + * Flow: open the RainbowKit connect modal (WalletConnect QR) -> once an account + * is connected, request a nonce, sign it with `personal_sign` through wagmi, + * verify against the backend and complete our own JWT login. The wagmi/WC + * session is only needed for the signature, so we disconnect right after. + * + * Entirely gated behind a real `VITE_WALLETCONNECT_PROJECT_ID`: when it is + * missing `available` is false and `start` is a no-op, so callers can hide or + * disable the entry instead of triggering a connect with a fake project id. + */ +export function useWalletConnectLogin() { + const available = hasWalletConnectProjectId(); + const { completeLogin } = useWallet(); + const { address, isConnected } = useAccount(); + const { signMessageAsync } = useSignMessage(); + const { disconnect } = useDisconnect(); + const { openConnectModal } = useConnectModal(); + const [state, setState] = useState("idle"); + const [error, setError] = useState(""); + const pendingRef = useRef(false); + + const reset = useCallback(() => { + pendingRef.current = false; + setState("idle"); + setError(""); + }, []); + + const start = useCallback(() => { + if (!available) return; + setError(""); + pendingRef.current = true; + setState("connecting"); + // When already connected, openConnectModal is undefined; the effect below + // picks up the existing account and proceeds straight to signing. + openConnectModal?.(); + }, [available, openConnectModal]); + + useEffect(() => { + if (!pendingRef.current || !isConnected || !address) return; + pendingRef.current = false; + setState("signing"); + let cancelled = false; + void (async () => { + try { + const nonce = await requestWalletNonce(address); + const signature = await signMessageAsync({ message: nonce.message }); + const verified = await verifyWalletSignature({ + address, + message: nonce.message, + signature, + }); + if (cancelled) return; + completeLogin(verified.token, verified.wallet); + setState("idle"); + } catch (err) { + if (!cancelled) { + setError( + err instanceof Error ? err.message : "WalletConnect login failed", + ); + setState("idle"); + } + } finally { + // We only needed a one-off signature, not a persistent wagmi session. + disconnect(); + } + })(); + return () => { + cancelled = true; + }; + }, [address, completeLogin, disconnect, isConnected, signMessageAsync]); + + return { available, state, error, start, reset }; +} From ed04e1fb7e8b3301b5b82ec9eade68d6fb017377 Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 04:00:30 +0800 Subject: [PATCH 16/89] 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) --- docs/backend-requirements-wallet-favorites.md | 30 ++- src/components/Toast.tsx | 2 +- src/locales/en.ts | 7 + src/locales/id.ts | 7 + src/locales/ja.ts | 7 + src/locales/ko.ts | 7 + src/locales/ms.ts | 7 + src/locales/vi.ts | 7 + src/locales/zh-CN.ts | 7 + src/wallet/WalletLoginModal.tsx | 206 +++++++++++------- 10 files changed, 204 insertions(+), 83 deletions(-) diff --git a/docs/backend-requirements-wallet-favorites.md b/docs/backend-requirements-wallet-favorites.md index c65bf13..a8eb317 100644 --- a/docs/backend-requirements-wallet-favorites.md +++ b/docs/backend-requirements-wallet-favorites.md @@ -5,11 +5,39 @@ > > **核心结论:后端目前几乎已经满足前端全部需求。本次重设计与 bug 修复基本是纯前端工作。后端真正需要新增的只有少量「可选项」,外加几处需要确认的契约。请不要把已完成的功能再派一遍。** -日期:2026-06-02 +日期:2026-06-02(2026-06-02 更新:新增 §0.5 部署阻塞) 相关分支:`terry-wallet-login` --- +## 0.5 🔴 关键阻塞:线上后端是旧版本,收藏与 TokenPocket 端点未部署 + +> 源码(`Arkie-Library-Backend`)里这些端点都有;但**当前线上 / dev 代理指向的后端**(`https://ark-library.com/apnew`)是**旧构建**,下列路由全部 404。 +> **前端已就绪,但收藏与 TokenPocket 扫码登录在生产上无法工作,直到后端把含这些路由的版本部署上线。** 这正是用户实测「收藏用不了、扫码登录用不了」的根因。 + +curl 实证(经 vite dev 代理打到线上后端,2026-06-02): + +| 端点 | 方法 | 实测状态 | 结论 | +|---|---|---|---| +| `/api/auth/wallet/nonce` | POST | **200** | ✅ 已部署 | +| `/api/auth/wallet/verify` | POST | **400**(入参错误)| ✅ 已部署 | +| `/api/auth/wallet/me` | GET | **401**(需鉴权)| ✅ 已部署 | +| `/api/auth/wallet/tp-login-request` | POST | **404** | ❌ 未部署 | +| `/api/auth/wallet/tp-result` | GET | **404** | ❌ 未部署 | +| `/api/auth/wallet/tp-callback` | POST | **404** | ❌ 未部署 | +| `/api/me/favorites` | GET | **404**(应为 401)| ❌ 未部署 | +| `/api/me/favorites/ids` | GET | **404** | ❌ 未部署 | +| `/api/me/favorites/{id}` | POST | **404** | ❌ 未部署 | + +**后端动作(必做,按优先级最高):** +1. 部署包含 favorites(`internal/handlers/favorites.go`)+ TokenPocket(`internal/handlers/wallet_tp.go`)路由的后端版本(`cmd/server/main.go` 已注册这些路由)。 +2. 为 TokenPocket 登录设置 `PUBLIC_BASE_URL`(`buildTokenPocketSignURL` 需要它生成回调 URL,否则 tp-login-request 会 500)。 +3. 确保 `wallet_tp_login_requests` / `user_favorites` 等表已迁移(`EnsureWalletAuthSchema` 会建表)。 + +> 复测命令:`curl -s -o /dev/null -w "%{http_code}" -X POST https://ark-library.com/apnew/api/auth/wallet/tp-login-request -d '{}'` 应返回 200(且 body 的 `qrUrl` 形如 `tpoutside://pull.activity?param=...`)。 + +--- + ## 0. 一句话给后端 > 钱包认证、TokenPocket 扫码、收藏列表/筛选/分页/可用性,**都已实现且符合前端契约**。 diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index d03bfe3..71f79b7 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -61,7 +61,7 @@ export function ToastProvider({ children }: { children: ReactNode }) { {children}
diff --git a/src/locales/en.ts b/src/locales/en.ts index 8592c5b..48d990f 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -183,8 +183,15 @@ export const enDict: Dict = { walletTokenPocket: "TokenPocket", walletMetaMask: "MetaMask", walletImToken: "imToken", + walletTokenPocketLogin: "TokenPocket login", + walletTpMobileDesc: + "Open TokenPocket to sign, then come back here to finish. You stay in this browser instead of the wallet's in-app browser.", + walletTpLoginBtn: "Log in with TokenPocket", + walletTpWaiting: "Waiting for your signature in TokenPocket…", + walletTpReopen: "Reopen TokenPocket", favoritesFilters: "Filters", favoriteSessionExpired: "Your session expired. Please sign in again.", + loadFailed: "Could not load your favorites.", walletChooseDesktop: "Choose the wallet you want to use. On desktop, install the matching browser extension.", walletChooseMobile: "Choose a wallet app to open this site.", diff --git a/src/locales/id.ts b/src/locales/id.ts index 12d34ce..d49f526 100644 --- a/src/locales/id.ts +++ b/src/locales/id.ts @@ -183,8 +183,15 @@ export const idDict: Dict = { walletTokenPocket: "TokenPocket", walletMetaMask: "MetaMask", walletImToken: "imToken", + walletTokenPocketLogin: "Masuk TokenPocket", + walletTpMobileDesc: + "Buka TokenPocket untuk menandatangani, lalu kembali ke sini untuk menyelesaikan. Anda tetap di browser ini, bukan browser dalam aplikasi dompet.", + walletTpLoginBtn: "Masuk dengan TokenPocket", + walletTpWaiting: "Menunggu tanda tangan Anda di TokenPocket…", + walletTpReopen: "Buka kembali TokenPocket", favoritesFilters: "Filter", favoriteSessionExpired: "Sesi Anda telah berakhir. Silakan masuk lagi.", + loadFailed: "Gagal memuat favorit Anda.", walletChooseDesktop: "Pilih dompet yang ingin digunakan. Di desktop, pasang ekstensi browser yang sesuai.", walletChooseMobile: "Pilih aplikasi dompet untuk membuka situs ini.", diff --git a/src/locales/ja.ts b/src/locales/ja.ts index d51c6c1..fba8bdf 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -203,9 +203,16 @@ export const jaDict: Dict = { walletTokenPocket: "TokenPocket", walletMetaMask: "MetaMask", walletImToken: "imToken", + walletTokenPocketLogin: "TokenPocket ログイン", + walletTpMobileDesc: + "TokenPocket で署名するとこのページに戻ってログインが完了します。ウォレット内ブラウザには移動せず、現在のブラウザのままです。", + walletTpLoginBtn: "TokenPocket でログイン", + walletTpWaiting: "TokenPocket での署名を待っています…", + walletTpReopen: "TokenPocket を再度開く", favoritesFilters: "フィルター", favoriteSessionExpired: "セッションの有効期限が切れました。もう一度ログインしてください。", + loadFailed: "お気に入りを読み込めませんでした。", walletChooseDesktop: "使用するウォレットを選択してください。デスクトップの場合は対応するブラウザ拡張機能をインストールしてください。", walletChooseMobile: "このサイトを開くウォレットアプリを選択してください。", diff --git a/src/locales/ko.ts b/src/locales/ko.ts index c935aa2..b89fc14 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -181,8 +181,15 @@ export const koDict: Dict = { walletTokenPocket: "TokenPocket", walletMetaMask: "MetaMask", walletImToken: "imToken", + walletTokenPocketLogin: "TokenPocket 로그인", + walletTpMobileDesc: + "TokenPocket에서 서명하면 이 페이지로 돌아와 로그인이 완료됩니다. 지갑 내장 브라우저로 이동하지 않고 현재 브라우저에 머무릅니다.", + walletTpLoginBtn: "TokenPocket으로 로그인", + walletTpWaiting: "TokenPocket에서 서명을 기다리는 중…", + walletTpReopen: "TokenPocket 다시 열기", favoritesFilters: "필터", favoriteSessionExpired: "세션이 만료되었습니다. 다시 로그인해 주세요.", + loadFailed: "즐겨찾기를 불러오지 못했습니다.", walletChooseDesktop: "사용할 지갑을 선택하세요. 데스크톱에서는 해당 브라우저 확장 프로그램을 설치하세요.", walletChooseMobile: "이 사이트를 열 지갑 앱을 선택하세요.", diff --git a/src/locales/ms.ts b/src/locales/ms.ts index 04702d4..f993c9c 100644 --- a/src/locales/ms.ts +++ b/src/locales/ms.ts @@ -183,8 +183,15 @@ export const msDict: Dict = { walletTokenPocket: "TokenPocket", walletMetaMask: "MetaMask", walletImToken: "imToken", + walletTokenPocketLogin: "Log masuk TokenPocket", + walletTpMobileDesc: + "Buka TokenPocket untuk menandatangani, kemudian kembali ke sini untuk selesai. Anda kekal dalam pelayar ini, bukan pelayar dalam aplikasi dompet.", + walletTpLoginBtn: "Log masuk dengan TokenPocket", + walletTpWaiting: "Menunggu tandatangan anda dalam TokenPocket…", + walletTpReopen: "Buka semula TokenPocket", favoritesFilters: "Penapis", favoriteSessionExpired: "Sesi anda telah tamat. Sila log masuk semula.", + loadFailed: "Gagal memuatkan kegemaran anda.", walletChooseDesktop: "Pilih dompet yang ingin anda gunakan. Pada desktop, pasang sambungan pelayar yang sepadan.", walletChooseMobile: "Pilih aplikasi dompet untuk membuka laman ini.", diff --git a/src/locales/vi.ts b/src/locales/vi.ts index 792108f..fcb5be7 100644 --- a/src/locales/vi.ts +++ b/src/locales/vi.ts @@ -181,8 +181,15 @@ export const viDict: Dict = { walletTokenPocket: "TokenPocket", walletMetaMask: "MetaMask", walletImToken: "imToken", + walletTokenPocketLogin: "Đăng nhập TokenPocket", + walletTpMobileDesc: + "Mở TokenPocket để ký, rồi quay lại đây để hoàn tất. Bạn vẫn ở trong trình duyệt này thay vì trình duyệt trong ví.", + walletTpLoginBtn: "Đăng nhập bằng TokenPocket", + walletTpWaiting: "Đang chờ bạn ký trong TokenPocket…", + walletTpReopen: "Mở lại TokenPocket", favoritesFilters: "Bộ lọc", favoriteSessionExpired: "Phiên đăng nhập đã hết hạn. Vui lòng đăng nhập lại.", + loadFailed: "Không thể tải mục yêu thích của bạn.", walletChooseDesktop: "Chọn ví bạn muốn dùng. Trên máy tính, hãy cài tiện ích mở rộng trình duyệt tương ứng.", walletChooseMobile: "Chọn ứng dụng ví để mở trang này.", diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 69b418b..a638318 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -174,8 +174,15 @@ export const zhDict: Dict = { walletTokenPocket: "TokenPocket", walletMetaMask: "MetaMask", walletImToken: "imToken", + walletTokenPocketLogin: "TokenPocket 登录", + walletTpMobileDesc: + "在 TokenPocket 中签名后会自动返回本页面完成登录,留在当前浏览器,不会跳进钱包内置浏览器。", + walletTpLoginBtn: "使用 TokenPocket 登录", + walletTpWaiting: "等待你在 TokenPocket 中完成签名…", + walletTpReopen: "重新打开 TokenPocket", favoritesFilters: "筛选", favoriteSessionExpired: "登录已过期,请重新登录。", + loadFailed: "无法加载收藏,请稍后重试。", walletChooseDesktop: "选择你要使用的钱包。电脑端需要先安装对应浏览器插件。", walletChooseMobile: "选择钱包 App 打开本站。", walletDesktopHint: diff --git a/src/wallet/WalletLoginModal.tsx b/src/wallet/WalletLoginModal.tsx index 47db105..0ea3266 100644 --- a/src/wallet/WalletLoginModal.tsx +++ b/src/wallet/WalletLoginModal.tsx @@ -1,4 +1,5 @@ import { QRCodeSVG } from "qrcode.react"; +import { LoaderCircle } from "lucide-react"; import { useEffect, useState } from "react"; import { useI18n } from "../i18n"; import { @@ -132,6 +133,10 @@ export function WalletLoginModal() { const signInjected = async () => { setError(""); + if (!getInjectedEthereum()) { + setError(t("walletNoBrowserWalletDesc")); + return; + } setState("signing"); await signInInjected() .catch((err) => { @@ -146,13 +151,19 @@ export function WalletLoginModal() { openWalletDeepLink(kind); }; - const startTokenPocketQr = async () => { + // TokenPocket login. The backend returns a `tpoutside://pull.activity` deep + // link (a one-off SIGN request, not a dApp-browser link). On mobile we open + // it directly so TokenPocket only asks for a signature and then returns to + // THIS browser — the poll below finishes login here, no in-app browser. On + // desktop we render it as a QR to scan from a phone. + const startTokenPocketLogin = async () => { setError(""); setState("tpLoading"); try { const req = await createTokenPocketLoginRequest(); setTpRequest(req); setState("tpPolling"); + if (mobileDevice) window.location.href = req.qrUrl; } catch { setState("idle"); setError(t("walletTpQrFailed")); @@ -189,68 +200,81 @@ export function WalletLoginModal() {
- {!mobileDevice || hasInjected ? ( - <> + {/* Injected wallet: browser extension (desktop) or in-wallet browser. */} + {hasInjected ? ( + + ) : !mobileDevice ? ( + + ) : null} + + {/* TokenPocket login — universal path that returns to this browser. */} +
+

+ {t("walletTokenPocketLogin")} +

+

+ {mobileDevice + ? t("walletTpMobileDesc") + : t("walletTokenPocketQrDesc")} +

+ {!tpRequest ? ( - {!mobileDevice ? ( -

- {t("walletDesktopHint")} + ) : mobileDevice ? ( +

+

+ + {t("walletTpWaiting")}

- ) : null} - - ) : ( -
-

- {t("walletOpenWalletApp")} -

- {appWallets.map((option) => ( - ))} - {openingWallet ? ( -
-

{withWallet("walletOpening", openingWallet)}

-

{t("walletAppNotInstalled")}

-
- - {withWallet("walletDownloadApp", openingWallet)} - - -
-
- ) : null} -
- )} +
+ ) : ( +
+ +

+ {t("walletQrUseAnotherDevice")} +

+
+ )} +
+ {/* Other methods: open a wallet app (mobile) and WalletConnect QR. */}
- {tpRequest ? ( -
- -

- {t("walletQrUseAnotherDevice")} -

-
- ) : null} -
+ {mobileDevice && !hasInjected ? ( +
+

+ {t("walletOpenWalletApp")} +

+ {appWallets.map((option) => ( + + ))} + {openingWallet ? ( +
+

{withWallet("walletOpening", openingWallet)}

+

{t("walletAppNotInstalled")}

+
+ + {withWallet("walletDownloadApp", openingWallet)} + + +
+
+ ) : null} +
+ ) : null} - {/* MetaMask / imToken QR via WalletConnect — gated on a real project id. */} -
+ {/* MetaMask / imToken QR via WalletConnect — needs a real id. */} +

{t("walletRainbowFallback")}

From 8821058c0a1e5bd8374e6d15ed0ece770ea4ab9c Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 04:30:27 +0800 Subject: [PATCH 17/89] 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) --- src/wallet/WalletLoginModal.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wallet/WalletLoginModal.tsx b/src/wallet/WalletLoginModal.tsx index 0ea3266..681eb42 100644 --- a/src/wallet/WalletLoginModal.tsx +++ b/src/wallet/WalletLoginModal.tsx @@ -97,6 +97,7 @@ export function WalletLoginModal() { } if (result.status === "expired" || result.status === "failed") { setState("idle"); + setTpRequest(null); setError(result.error || t("walletTpExpired")); } } catch (err) { From 11599e54eadec35821a3a5ad77b43a8253adcba8 Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 10:33:59 +0800 Subject: [PATCH 18/89] 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) --- src/wallet/WalletLoginModal.tsx | 35 +++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/wallet/WalletLoginModal.tsx b/src/wallet/WalletLoginModal.tsx index 681eb42..a0faaed 100644 --- a/src/wallet/WalletLoginModal.tsx +++ b/src/wallet/WalletLoginModal.tsx @@ -146,6 +146,18 @@ export function WalletLoginModal() { .finally(() => setState("idle")); }; + // Desktop: open the RainbowKit modal — it discovers every installed wallet + // via EIP-6963 (robust when several extensions fight over window.ethereum) + // and also offers a WalletConnect QR. Fall back to the raw injected flow only + // when WalletConnect has no project id configured. + const connectBrowserWallet = () => { + if (wc.available) { + wc.start(); + return; + } + void signInjected(); + }; + const openApp = (kind: WalletKind) => { setError(""); setOpeningWallet(kind); @@ -201,27 +213,30 @@ export function WalletLoginModal() {
- {/* Injected wallet: browser extension (desktop) or in-wallet browser. */} - {hasInjected ? ( + {/* Browser wallet: in a wallet's in-app browser we sign directly with + the injected provider; on desktop we open the RainbowKit picker so + the user can choose among installed extensions reliably. */} + {mobileDevice && hasInjected ? ( ) : !mobileDevice ? ( ) : null} From e1b24aa0f99aaeb537929af3195ea729af21e85f Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 10:38:29 +0800 Subject: [PATCH 19/89] fix: keep desktop browser-wallet on direct injected; add login diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/wallet/WalletLoginModal.tsx | 37 +++++++----------------------- src/wallet/injected.ts | 40 +++++++++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 31 deletions(-) diff --git a/src/wallet/WalletLoginModal.tsx b/src/wallet/WalletLoginModal.tsx index a0faaed..2356f4e 100644 --- a/src/wallet/WalletLoginModal.tsx +++ b/src/wallet/WalletLoginModal.tsx @@ -146,18 +146,6 @@ export function WalletLoginModal() { .finally(() => setState("idle")); }; - // Desktop: open the RainbowKit modal — it discovers every installed wallet - // via EIP-6963 (robust when several extensions fight over window.ethereum) - // and also offers a WalletConnect QR. Fall back to the raw injected flow only - // when WalletConnect has no project id configured. - const connectBrowserWallet = () => { - if (wc.available) { - wc.start(); - return; - } - void signInjected(); - }; - const openApp = (kind: WalletKind) => { setError(""); setOpeningWallet(kind); @@ -213,29 +201,20 @@ export function WalletLoginModal() {
- {/* Browser wallet: in a wallet's in-app browser we sign directly with - the injected provider; on desktop we open the RainbowKit picker so - the user can choose among installed extensions reliably. */} - {mobileDevice && hasInjected ? ( + {/* Browser wallet: sign directly with the injected provider — the + reliable path for a BNB-chain extension (desktop) or a wallet's + in-app browser. No WalletConnect relay involved. */} + {!mobileDevice || hasInjected ? ( - ) : !mobileDevice ? ( - ) : null} diff --git a/src/wallet/injected.ts b/src/wallet/injected.ts index ef40c8b..036d3a9 100644 --- a/src/wallet/injected.ts +++ b/src/wallet/injected.ts @@ -34,30 +34,66 @@ export function getInjectedWallet(kind?: WalletKind): EthereumProvider | null { return match ?? null; } +/** Diagnostic: log what injected providers the browser exposes. */ +export function logWalletProviders(): void { + const ethereum = getInjectedEthereum(); + const list = ( + ethereum?.providers?.length + ? ethereum.providers + : ethereum + ? [ethereum] + : [] + ).map((p) => ({ + isMetaMask: Boolean(p.isMetaMask), + isTokenPocket: Boolean(p.isTokenPocket), + isImToken: Boolean(p.isImToken), + })); + // eslint-disable-next-line no-console + console.info("[wallet-login] providers", { + hasEthereum: Boolean(ethereum), + count: list.length, + list, + }); +} + export async function signInWithInjectedWallet(kind?: WalletKind): Promise<{ token: string; wallet: string; }> { + /* eslint-disable no-console */ + console.info("[wallet-login] start injected", { kind }); + logWalletProviders(); const ethereum = getInjectedWallet(kind); - if (!ethereum) throw new Error("No injected wallet found"); + if (!ethereum) { + console.warn("[wallet-login] no injected provider found"); + throw new Error("No injected wallet found"); + } // Login is signature-only (EIP-191 personal_sign). The backend verifies the // recovered address and never inspects chainId, so we deliberately do NOT // switch or add any chain — that only adds a failure-prone wallet popup. + console.info("[wallet-login] requesting accounts (eth_requestAccounts)…"); const accounts = await ethereum.request({ method: "eth_requestAccounts", }); + console.info("[wallet-login] accounts", accounts); const address = accounts[0]; if (!address) throw new Error("No wallet account returned"); + console.info("[wallet-login] requesting nonce for", address); const nonce = await requestWalletNonce(address); + console.info("[wallet-login] got nonce, requesting personal_sign…"); const signature = await ethereum.request({ method: "personal_sign", params: [nonce.message, address], }); - return verifyWalletSignature({ + console.info("[wallet-login] signed, verifying with backend…"); + const result = await verifyWalletSignature({ address, message: nonce.message, signature, }); + console.info("[wallet-login] verified, wallet =", result.wallet); + return result; + /* eslint-enable no-console */ } From 4e33c7deef72ea3c67b30b2933f2e6e4eb1f788f Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 10:42:57 +0800 Subject: [PATCH 20/89] docs: add wallet/favorites UI redesign requirements brief Co-Authored-By: Claude Opus 4.8 (1M context) --- ...llet-favorites-ui-redesign-requirements.md | 556 ++++++++++++++++++ 1 file changed, 556 insertions(+) create mode 100644 .unipi/docs/generated/2026-06-01-wallet-favorites-ui-redesign-requirements.md diff --git a/.unipi/docs/generated/2026-06-01-wallet-favorites-ui-redesign-requirements.md b/.unipi/docs/generated/2026-06-01-wallet-favorites-ui-redesign-requirements.md new file mode 100644 index 0000000..6dfd618 --- /dev/null +++ b/.unipi/docs/generated/2026-06-01-wallet-favorites-ui-redesign-requirements.md @@ -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 状态组件 From f0209eb894b970de11bc1d97a5cca0d50ab402c6 Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 21:05:01 +0800 Subject: [PATCH 21/89] fix(wallet): improve mobile login and logout flows --- src/locales/en.ts | 2 + src/locales/id.ts | 2 + src/locales/ja.ts | 2 + src/locales/ko.ts | 2 + src/locales/ms.ts | 2 + src/locales/vi.ts | 2 + src/locales/zh-CN.ts | 2 + src/wallet/WalletButton.tsx | 18 ++ src/wallet/WalletLoginModal.tsx | 475 ++++++++++++---------------- src/wallet/useWalletConnectLogin.ts | 98 +++++- 10 files changed, 317 insertions(+), 288 deletions(-) diff --git a/src/locales/en.ts b/src/locales/en.ts index fb94a67..a3205a7 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -186,6 +186,8 @@ export const enDict: Dict = { walletTokenPocket: "TokenPocket", walletMetaMask: "MetaMask", walletImToken: "imToken", + walletBack: "Back", + walletChooseMethod: "Choose how to log in", walletTokenPocketLogin: "TokenPocket login", walletTpMobileDesc: "Open TokenPocket to sign, then come back here to finish. You stay in this browser instead of the wallet's in-app browser.", diff --git a/src/locales/id.ts b/src/locales/id.ts index 9633c86..dbbb5b4 100644 --- a/src/locales/id.ts +++ b/src/locales/id.ts @@ -186,6 +186,8 @@ export const idDict: Dict = { walletTokenPocket: "TokenPocket", walletMetaMask: "MetaMask", walletImToken: "imToken", + walletBack: "Kembali", + walletChooseMethod: "Pilih cara masuk", walletTokenPocketLogin: "Masuk TokenPocket", walletTpMobileDesc: "Buka TokenPocket untuk menandatangani, lalu kembali ke sini untuk menyelesaikan. Anda tetap di browser ini, bukan browser dalam aplikasi dompet.", diff --git a/src/locales/ja.ts b/src/locales/ja.ts index db8d2b8..6ef629d 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -206,6 +206,8 @@ export const jaDict: Dict = { walletTokenPocket: "TokenPocket", walletMetaMask: "MetaMask", walletImToken: "imToken", + walletBack: "戻る", + walletChooseMethod: "ログイン方法を選択", walletTokenPocketLogin: "TokenPocket ログイン", walletTpMobileDesc: "TokenPocket で署名するとこのページに戻ってログインが完了します。ウォレット内ブラウザには移動せず、現在のブラウザのままです。", diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 0f20c9a..53c897a 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -183,6 +183,8 @@ export const koDict: Dict = { walletTokenPocket: "TokenPocket", walletMetaMask: "MetaMask", walletImToken: "imToken", + walletBack: "뒤로", + walletChooseMethod: "로그인 방법 선택", walletTokenPocketLogin: "TokenPocket 로그인", walletTpMobileDesc: "TokenPocket에서 서명하면 이 페이지로 돌아와 로그인이 완료됩니다. 지갑 내장 브라우저로 이동하지 않고 현재 브라우저에 머무릅니다.", diff --git a/src/locales/ms.ts b/src/locales/ms.ts index 68bac45..c7c0bbd 100644 --- a/src/locales/ms.ts +++ b/src/locales/ms.ts @@ -185,6 +185,8 @@ export const msDict: Dict = { walletTokenPocket: "TokenPocket", walletMetaMask: "MetaMask", walletImToken: "imToken", + walletBack: "Kembali", + walletChooseMethod: "Pilih cara log masuk", walletTokenPocketLogin: "Log masuk TokenPocket", walletTpMobileDesc: "Buka TokenPocket untuk menandatangani, kemudian kembali ke sini untuk selesai. Anda kekal dalam pelayar ini, bukan pelayar dalam aplikasi dompet.", diff --git a/src/locales/vi.ts b/src/locales/vi.ts index 8ffc601..2ed9b56 100644 --- a/src/locales/vi.ts +++ b/src/locales/vi.ts @@ -183,6 +183,8 @@ export const viDict: Dict = { walletTokenPocket: "TokenPocket", walletMetaMask: "MetaMask", walletImToken: "imToken", + walletBack: "Quay lại", + walletChooseMethod: "Chọn cách đăng nhập", walletTokenPocketLogin: "Đăng nhập TokenPocket", walletTpMobileDesc: "Mở TokenPocket để ký, rồi quay lại đây để hoàn tất. Bạn vẫn ở trong trình duyệt này thay vì trình duyệt trong ví.", diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index a5e2e34..561adcf 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -176,6 +176,8 @@ export const zhDict: Dict = { walletTokenPocket: "TokenPocket", walletMetaMask: "MetaMask", walletImToken: "imToken", + walletBack: "返回", + walletChooseMethod: "选择登录方式", walletTokenPocketLogin: "TokenPocket 登录", walletTpMobileDesc: "在 TokenPocket 中签名后会自动返回本页面完成登录,留在当前浏览器,不会跳进钱包内置浏览器。", diff --git a/src/wallet/WalletButton.tsx b/src/wallet/WalletButton.tsx index 33deaf7..edb6258 100644 --- a/src/wallet/WalletButton.tsx +++ b/src/wallet/WalletButton.tsx @@ -37,6 +37,24 @@ export function WalletButton({ }, [open]); if (wallet.status === "loggedIn" && wallet.address) { + if (compact) { + return ( +
+
+ + {shortenAddress(wallet.address)} +
+ +
+ ); + } + return (
+ ) : null} +
+

+ {t("walletLoginTitle")} +

+

+ {step === "wallet" + ? t("walletLoginDesc") + : t("walletChooseMethod")} +

+
-
- {/* Browser wallet: sign directly with the injected provider — the - reliable path for a BNB-chain extension (desktop) or a wallet's - in-app browser. No WalletConnect relay involved. */} - {!mobileDevice || hasInjected ? ( - - ) : null} - - {/* TokenPocket login — universal path that returns to this browser. */} -
-

- {t("walletTokenPocketLogin")} -

-

- {mobileDevice - ? t("walletTpMobileDesc") - : t("walletTokenPocketQrDesc")} -

- {!tpRequest ? ( + {/* Step 1 — choose a wallet. */} + {step === "wallet" ? ( +
+ {wallets.map((kind) => ( - ) : mobileDevice ? ( -
-

- - {t("walletTpWaiting")} -

+ ))} +
+ ) : selected ? ( +
+ {/* Selected wallet header. */} +
+ + + {walletName(selected)} + +
+ + {!mobileDevice && !injectedAvailable(selected) ? ( +

+ {t("walletInstallSelected").replace( + "{wallet}", + walletName(selected), + )} +

+ ) : null} + + {/* Method: browser wallet (injected). */} + {(() => { + const ok = injectedAvailable(selected); + return ( -
- ) : ( -
- -

- {t("walletQrUseAnotherDevice")} -

-
- )} -
- - {/* Other methods: open a wallet app (mobile) and WalletConnect QR. */} -
- - - {showOther ? ( -
- {mobileDevice && !hasInjected ? ( -
-

- {t("walletOpenWalletApp")} -

- {appWallets.map((option) => ( - - ))} - {openingWallet ? ( -
-

{withWallet("walletOpening", openingWallet)}

-

{t("walletAppNotInstalled")}

-
- - {withWallet("walletDownloadApp", openingWallet)} - - -
-
+ + {state === "signing" ? ( + ) : null} -
- ) : null} + {mobileDevice ? t("walletUseCurrent") : t("walletInjected")} + + + {ok + ? t("walletInjectedDesc") + : mobileDevice + ? t("walletOpenWalletAppDesc") + : t("walletInstallSelected").replace( + "{wallet}", + walletName(selected), + )} + + + ); + })()} - {/* MetaMask / imToken QR via WalletConnect — needs a real id. */} -
-

- {t("walletRainbowFallback")} -

-

- {t("walletRainbowFallbackDesc")} -

- {wc.available ? ( - <> - -

- {t("walletNetworkWarning")} -

- - ) : ( -

- {t("walletRainbowUnavailable")} + {/* Method: scan to log in. */} + {(() => { + const ok = qrAvailable(); + const isTp = selected === "tokenPocket"; + const qrBusy = wc.state !== "idle"; + const qrLabel = + wc.state === "connecting" + ? t("walletConnecting") + : wc.state === "signing" + ? t("walletSigning") + : isTp && mobileDevice + ? t("walletTpLoginBtn") + : t("walletQrLogin"); + return ( +

+ + + {ok ? ( +

+ {t("walletNetworkWarning")}

- )} + ) : null} + + {wc.qrUri ? ( +
+ +

+ {mobileDevice + ? t("walletTpWaiting") + : t("walletQrUseAnotherDevice")} +

+
+ ) : null}
-
- ) : null} + ); + })()}
-
+ ) : null} {error || wc.error ? (

diff --git a/src/wallet/useWalletConnectLogin.ts b/src/wallet/useWalletConnectLogin.ts index 4240053..17ee12e 100644 --- a/src/wallet/useWalletConnectLogin.ts +++ b/src/wallet/useWalletConnectLogin.ts @@ -1,12 +1,20 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { useConnectModal } from "@rainbow-me/rainbowkit"; -import { useAccount, useDisconnect, useSignMessage } from "wagmi"; +import { useAccount, useConnect, useDisconnect, useSignMessage } from "wagmi"; +import { bsc } from "wagmi/chains"; import { requestWalletNonce, verifyWalletSignature } from "./api"; import { hasWalletConnectProjectId } from "./RainbowWalletProvider"; +import type { WalletKind } from "./injected"; import { useWallet } from "./WalletProvider"; export type WalletConnectLoginState = "idle" | "connecting" | "signing"; +function isMobileDevice(): boolean { + if (typeof navigator === "undefined") return false; + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test( + navigator.userAgent || "", + ); +} + /** * MetaMask / imToken QR fallback via RainbowKit + WalletConnect. * @@ -24,27 +32,90 @@ export function useWalletConnectLogin() { const { completeLogin } = useWallet(); const { address, isConnected } = useAccount(); const { signMessageAsync } = useSignMessage(); + const { connectAsync, connectors } = useConnect(); const { disconnect } = useDisconnect(); - const { openConnectModal } = useConnectModal(); const [state, setState] = useState("idle"); const [error, setError] = useState(""); + const [qrUri, setQrUri] = useState(""); const pendingRef = useRef(false); + const cleanupMessageRef = useRef<(() => void) | null>(null); const reset = useCallback(() => { pendingRef.current = false; + cleanupMessageRef.current?.(); + cleanupMessageRef.current = null; setState("idle"); setError(""); + setQrUri(""); }, []); - const start = useCallback(() => { - if (!available) return; - setError(""); - pendingRef.current = true; - setState("connecting"); - // When already connected, openConnectModal is undefined; the effect below - // picks up the existing account and proceeds straight to signing. - openConnectModal?.(); - }, [available, openConnectModal]); + const start = useCallback( + async (preferredWallet?: WalletKind) => { + if (!available) return; + setError(""); + setQrUri(""); + pendingRef.current = true; + setState("connecting"); + + const connector = + connectors.find((item) => item.id === preferredWallet) ?? + connectors.find((item) => item.id === "walletConnect") ?? + connectors.find((item) => item.type === "walletConnect"); + + if (!connector) { + pendingRef.current = false; + setQrUri(""); + setState("idle"); + setError("WalletConnect is not available"); + return; + } + + console.info("[wallet-login] walletconnect connector", { + preferredWallet, + connectorId: connector.id, + connectorName: connector.name, + connectorType: connector.type, + }); + + const onMessage = (message: { type: string; data?: unknown }) => { + if ( + message.type !== "display_uri" || + typeof message.data !== "string" + ) { + return; + } + console.info("[wallet-login] walletconnect display_uri", { + preferredWallet, + connectorId: connector.id, + }); + setQrUri(message.data); + if (preferredWallet === "tokenPocket" && isMobileDevice()) { + window.location.href = `tpoutside://wc?uri=${encodeURIComponent( + message.data, + )}`; + } + }; + + cleanupMessageRef.current?.(); + connector.emitter.on("message", onMessage); + cleanupMessageRef.current = () => + connector.emitter.off("message", onMessage); + + try { + await connector.disconnect().catch(() => undefined); + await connectAsync({ chainId: bsc.id, connector }); + } catch (err) { + pendingRef.current = false; + setState("idle"); + setError( + err instanceof Error ? err.message : "WalletConnect login failed", + ); + cleanupMessageRef.current?.(); + cleanupMessageRef.current = null; + } + }, + [available, connectAsync, connectors], + ); useEffect(() => { if (!pendingRef.current || !isConnected || !address) return; @@ -68,6 +139,7 @@ export function useWalletConnectLogin() { setError( err instanceof Error ? err.message : "WalletConnect login failed", ); + setQrUri(""); setState("idle"); } } finally { @@ -80,5 +152,5 @@ export function useWalletConnectLogin() { }; }, [address, completeLogin, disconnect, isConnected, signMessageAsync]); - return { available, state, error, start, reset }; + return { available, state, error, qrUri, start, reset }; } From 243e98b82933d2588838de1464785df02d6da657 Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 21:10:58 +0800 Subject: [PATCH 22/89] fix(wallet): simplify mobile login choices --- src/wallet/WalletLoginModal.tsx | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/wallet/WalletLoginModal.tsx b/src/wallet/WalletLoginModal.tsx index 1062b10..bfef98f 100644 --- a/src/wallet/WalletLoginModal.tsx +++ b/src/wallet/WalletLoginModal.tsx @@ -3,6 +3,7 @@ import { ChevronLeft, ChevronRight, LoaderCircle, X } from "lucide-react"; import { useEffect, useState } from "react"; import { useI18n } from "../i18n"; +import { openWalletDeepLink } from "./deepLinks"; import { getInjectedWallet, type WalletKind } from "./injected"; import { useWallet } from "./WalletProvider"; import { useWalletConnectLogin } from "./useWalletConnectLogin"; @@ -197,9 +198,26 @@ export function WalletLoginModal() {

) : null} - {/* Method: browser wallet (injected). */} + {mobileDevice ? ( + + ) : null} + + {/* Method: browser wallet (injected). Hidden on normal mobile browsers, + because only wallet in-app browsers expose an injected wallet. */} {(() => { const ok = injectedAvailable(selected); + if (mobileDevice) return null; return ( - ) : null} -
-

- {t("walletLoginTitle")} -

-

- {step === "wallet" - ? t("walletLoginDesc") - : t("walletChooseMethod")} -

-
+
+

+ {t("walletLoginTitle")} +

+

+ {t("walletLoginDesc")} +

- {/* Step 1 — choose a wallet. */} - {step === "wallet" ? ( -
- {wallets.map((kind) => ( +
+ {wallets.map((kind) => { + const active = selected === kind; + const connecting = active && wc.state === "connecting"; + const signing = active && wc.state === "signing"; + return ( - ))} -
- ) : selected ? ( -
- {/* Selected wallet header. */} -
- - - {walletName(selected)} - -
- - {!mobileDevice && !injectedAvailable(selected) ? ( -

- {t("walletInstallSelected").replace( - "{wallet}", - walletName(selected), - )} -

- ) : null} - - {mobileDevice ? ( - - ) : null} - - {/* Method: browser wallet (injected). Hidden on normal mobile browsers, - because only wallet in-app browsers expose an injected wallet. */} - {(() => { - const ok = injectedAvailable(selected); - if (mobileDevice) return null; - return ( - - ); - })()} + + {connecting || signing ? ( + + ) : null} + + ); + })} +
- {/* Method: scan to log in. */} - {(() => { - const ok = qrAvailable(); - const isTp = selected === "tokenPocket"; - const qrBusy = wc.state !== "idle"; - const qrLabel = - wc.state === "connecting" - ? t("walletConnecting") - : wc.state === "signing" - ? t("walletSigning") - : t("walletQrLogin"); - return ( -
- + {!wc.available ? ( +

+ {t("walletRainbowUnavailable")} +

+ ) : null} - {ok ? ( -

- {t("walletNetworkWarning")} -

- ) : null} - - {wc.qrUri ? ( -
- -

- {mobileDevice - ? t("walletTpWaiting") - : t("walletQrUseAnotherDevice")} -

-
- ) : null} -
- ); - })()} + {selected && wc.qrUri ? ( +
+ +

+ {mobileDevice + ? t("walletTpWaiting") + : t("walletQrUseAnotherDevice")} +

) : null} - {error || wc.error ? ( + {wc.error ? (

- {error || wc.error} + {wc.error} +

+ ) : null} + + {selected ? ( +

+ {t("walletNetworkWarning")}

) : null}
diff --git a/src/wallet/injected.ts b/src/wallet/injected.ts index 036d3a9..ec67e38 100644 --- a/src/wallet/injected.ts +++ b/src/wallet/injected.ts @@ -2,6 +2,15 @@ import { requestWalletNonce, verifyWalletSignature } from "./api"; export type WalletKind = "tokenPocket" | "metaMask" | "imToken"; +const BNB_CHAIN_ID_HEX = "0x38"; +const BNB_CHAIN_PARAMS = { + chainId: BNB_CHAIN_ID_HEX, + chainName: "BNB Smart Chain", + nativeCurrency: { name: "BNB", symbol: "BNB", decimals: 18 }, + rpcUrls: ["https://bsc-dataseed.binance.org"], + blockExplorerUrls: ["https://bscscan.com"], +}; + export type EthereumProvider = { isMetaMask?: boolean; isTokenPocket?: boolean; @@ -13,6 +22,116 @@ export type EthereumProvider = { }) => Promise; }; +function isAddress(value: unknown): value is string { + return typeof value === "string" && /^0x[a-fA-F0-9]{40}$/.test(value); +} + +function utf8ToHex(value: string): string { + return `0x${Array.from(new TextEncoder().encode(value), (byte) => + byte.toString(16).padStart(2, "0"), + ).join("")}`; +} + +function errorText(error: unknown): string { + if (!error || typeof error !== "object") return String(error ?? ""); + const parts: string[] = []; + const record = error as Record; + for (const key of ["shortMessage", "message", "details"]) { + const value = record[key]; + if (typeof value === "string") parts.push(value); + } + if (record.cause) parts.push(errorText(record.cause)); + return parts.join("\n"); +} + +function isNoAccountError(error: unknown): boolean { + return /wallet must has at least one account|wallet must has one account|must have at least one account|no wallet account returned/i.test( + errorText(error), + ); +} + +function normalizeWalletError(error: unknown): Error { + if (isNoAccountError(error)) return new Error("walletNoAccount"); + if (error instanceof Error) return error; + const message = errorText(error); + return new Error(message || "Wallet login failed"); +} + +function shouldRetryPersonalSign(error: unknown): boolean { + const text = errorText(error); + return /wallet must has at least one account|wallet must has one account|must have at least one account|invalid params|invalid account|account not found/i.test( + text, + ); +} + +async function ensureBnbChain(ethereum: EthereumProvider): Promise { + const chainId = await ethereum + .request({ method: "eth_chainId" }) + .catch(() => ""); + if (chainId.toLowerCase() === BNB_CHAIN_ID_HEX) return; + + try { + await ethereum.request({ + method: "wallet_switchEthereumChain", + params: [{ chainId: BNB_CHAIN_ID_HEX }], + }); + } catch (error) { + const code = (error as { code?: number | string } | null)?.code; + if (code !== 4902 && code !== "4902") throw error; + await ethereum.request({ + method: "wallet_addEthereumChain", + params: [BNB_CHAIN_PARAMS], + }); + } +} + +async function requestInjectedAddress( + ethereum: EthereumProvider, +): Promise { + const existingAccounts: unknown[] = await ethereum + .request({ method: "eth_accounts" }) + .catch((): unknown[] => []); + const existingAddress = existingAccounts.find(isAddress); + if (existingAddress) return existingAddress; + + const requestedAccounts = await ethereum + .request({ + method: "eth_requestAccounts", + }) + .catch((error: unknown): never => { + throw normalizeWalletError(error); + }); + const requestedAddress = requestedAccounts.find(isAddress); + if (!requestedAddress) throw new Error("walletNoAccount"); + return requestedAddress; +} + +async function personalSign(params: { + ethereum: EthereumProvider; + message: string; + address: string; +}): Promise { + const { ethereum, message, address } = params; + const hexMessage = utf8ToHex(message); + try { + return await ethereum.request({ + method: "personal_sign", + params: [hexMessage, address], + }); + } catch (error) { + if (!shouldRetryPersonalSign(error)) throw error; + // Some injected wallets incorrectly expect the legacy param order. + return ethereum + .request({ + method: "personal_sign", + params: [address, hexMessage], + }) + .catch((retryError: unknown): never => { + throw normalizeWalletError(retryError); + }); + } +} + export function getInjectedEthereum(): EthereumProvider | null { if (typeof window === "undefined") return null; const maybeWindow = window as typeof window & { ethereum?: EthereumProvider }; @@ -69,23 +188,22 @@ export async function signInWithInjectedWallet(kind?: WalletKind): Promise<{ throw new Error("No injected wallet found"); } - // Login is signature-only (EIP-191 personal_sign). The backend verifies the - // recovered address and never inspects chainId, so we deliberately do NOT - // switch or add any chain — that only adds a failure-prone wallet popup. - console.info("[wallet-login] requesting accounts (eth_requestAccounts)…"); - const accounts = await ethereum.request({ - method: "eth_requestAccounts", - }); - console.info("[wallet-login] accounts", accounts); - const address = accounts[0]; - if (!address) throw new Error("No wallet account returned"); + // BNB Smart Chain is EVM-compatible, so browser wallets still expose the + // standard EIP-1193 method names (`eth_*`) while operating on BNB chain 56. + console.info("[wallet-login] requesting BNB wallet account…"); + const address = await requestInjectedAddress(ethereum); + console.info("[wallet-login] account", address); + + console.info("[wallet-login] ensuring BNB Chain (0x38)…"); + await ensureBnbChain(ethereum); console.info("[wallet-login] requesting nonce for", address); const nonce = await requestWalletNonce(address); console.info("[wallet-login] got nonce, requesting personal_sign…"); - const signature = await ethereum.request({ - method: "personal_sign", - params: [nonce.message, address], + const signature = await personalSign({ + ethereum, + message: nonce.message, + address, }); console.info("[wallet-login] signed, verifying with backend…"); const result = await verifyWalletSignature({ From 4d38c4513d418b5f2c3c202508a7b540f4294f98 Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 21:52:15 +0800 Subject: [PATCH 24/89] fix(wallet): support no-signature wallet connect --- src/wallet/WalletLoginModal.tsx | 82 ++++++++++++++++++-------- src/wallet/WalletProvider.tsx | 19 ++++++ src/wallet/useWalletConnectLogin.ts | 90 ++++++++++++++--------------- 3 files changed, 119 insertions(+), 72 deletions(-) diff --git a/src/wallet/WalletLoginModal.tsx b/src/wallet/WalletLoginModal.tsx index 562e711..38723dc 100644 --- a/src/wallet/WalletLoginModal.tsx +++ b/src/wallet/WalletLoginModal.tsx @@ -51,9 +51,14 @@ export function WalletLoginModal() { wc.reset(); }; - const startWalletLogin = (kind: WalletKind) => { + const selectWallet = (kind: WalletKind) => { setSelected(kind); - void wc.start(kind); + wc.reset(); + }; + + const startWalletLogin = (kind: WalletKind, mode: "deeplink" | "qr") => { + setSelected(kind); + void wc.start(kind, mode); }; return ( @@ -92,34 +97,63 @@ export function WalletLoginModal() { const connecting = active && wc.state === "connecting"; const signing = active && wc.state === "signing"; return ( - + + {mobileDevice && active ? ( +
+ + +
) : null} - +
); })}
diff --git a/src/wallet/WalletProvider.tsx b/src/wallet/WalletProvider.tsx index af43668..eac3f6d 100644 --- a/src/wallet/WalletProvider.tsx +++ b/src/wallet/WalletProvider.tsx @@ -15,6 +15,18 @@ import { clearWalletToken, readWalletToken, writeWalletToken } from "./token"; type WalletStatus = "loading" | "loggedOut" | "loggedIn"; +const localWalletTokenPrefix = "local-wallet:"; + +export function localWalletToken(wallet: string): string { + return `${localWalletTokenPrefix}${wallet}`; +} + +function walletFromLocalToken(token: string): string | null { + return token.startsWith(localWalletTokenPrefix) + ? token.slice(localWalletTokenPrefix.length) + : null; +} + type WalletContextValue = { address: string | null; token: string | null; @@ -52,6 +64,13 @@ export function WalletProvider({ children }: { children: ReactNode }) { return; } + const localWallet = walletFromLocalToken(token); + if (localWallet) { + setAddress(localWallet); + setStatus("loggedIn"); + return; + } + setStatus("loading"); fetchWalletMe(token) .then((me) => { diff --git a/src/wallet/useWalletConnectLogin.ts b/src/wallet/useWalletConnectLogin.ts index 17ee12e..f61d5ea 100644 --- a/src/wallet/useWalletConnectLogin.ts +++ b/src/wallet/useWalletConnectLogin.ts @@ -1,12 +1,12 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { useAccount, useConnect, useDisconnect, useSignMessage } from "wagmi"; +import { useAccount, useConnect, useDisconnect } from "wagmi"; import { bsc } from "wagmi/chains"; -import { requestWalletNonce, verifyWalletSignature } from "./api"; import { hasWalletConnectProjectId } from "./RainbowWalletProvider"; import type { WalletKind } from "./injected"; -import { useWallet } from "./WalletProvider"; +import { localWalletToken, useWallet } from "./WalletProvider"; export type WalletConnectLoginState = "idle" | "connecting" | "signing"; +export type WalletConnectLoginMode = "deeplink" | "qr"; function isMobileDevice(): boolean { if (typeof navigator === "undefined") return false; @@ -15,13 +15,28 @@ function isMobileDevice(): boolean { ); } +function walletConnectDeeplink( + kind: WalletKind | undefined, + uri: string, +): string | null { + if (kind === "tokenPocket") { + return `tpoutside://wc?uri=${encodeURIComponent(uri)}`; + } + if (kind === "metaMask") { + return `https://metamask.app.link/wc?uri=${encodeURIComponent(uri)}`; + } + if (kind === "imToken") { + return `imtokenv2://wc?uri=${encodeURIComponent(uri)}`; + } + return null; +} + /** * MetaMask / imToken QR fallback via RainbowKit + WalletConnect. * - * Flow: open the RainbowKit connect modal (WalletConnect QR) -> once an account - * is connected, request a nonce, sign it with `personal_sign` through wagmi, - * verify against the backend and complete our own JWT login. The wagmi/WC - * session is only needed for the signature, so we disconnect right after. + * Flow: connect through RainbowKit/Wagmi on BNB Chain -> once an account is + * connected, complete a local frontend wallet session. No message signature, + * backend nonce, or verify call is required. * * Entirely gated behind a real `VITE_WALLETCONNECT_PROJECT_ID`: when it is * missing `available` is false and `start` is a no-op, so callers can hide or @@ -31,7 +46,6 @@ export function useWalletConnectLogin() { const available = hasWalletConnectProjectId(); const { completeLogin } = useWallet(); const { address, isConnected } = useAccount(); - const { signMessageAsync } = useSignMessage(); const { connectAsync, connectors } = useConnect(); const { disconnect } = useDisconnect(); const [state, setState] = useState("idle"); @@ -50,17 +64,24 @@ export function useWalletConnectLogin() { }, []); const start = useCallback( - async (preferredWallet?: WalletKind) => { + async ( + preferredWallet?: WalletKind, + mode: WalletConnectLoginMode = "qr", + ) => { if (!available) return; setError(""); setQrUri(""); pendingRef.current = true; setState("connecting"); + // This modal is QR/WalletConnect-only. RainbowKit also exposes wallet- + // specific injected connectors (for example `tokenPocket`) when an + // extension is installed; using those here makes the click try the local + // browser extension and can fail with "wallet must has at least one + // account" before a QR is shown. const connector = - connectors.find((item) => item.id === preferredWallet) ?? - connectors.find((item) => item.id === "walletConnect") ?? - connectors.find((item) => item.type === "walletConnect"); + connectors.find((item) => item.type === "walletConnect") ?? + connectors.find((item) => item.id === "walletConnect"); if (!connector) { pendingRef.current = false; @@ -88,11 +109,10 @@ export function useWalletConnectLogin() { preferredWallet, connectorId: connector.id, }); - setQrUri(message.data); - if (preferredWallet === "tokenPocket" && isMobileDevice()) { - window.location.href = `tpoutside://wc?uri=${encodeURIComponent( - message.data, - )}`; + if (mode === "qr") setQrUri(message.data); + const deeplink = walletConnectDeeplink(preferredWallet, message.data); + if (mode === "deeplink" && deeplink && isMobileDevice()) { + window.location.href = deeplink; } }; @@ -120,37 +140,11 @@ export function useWalletConnectLogin() { useEffect(() => { if (!pendingRef.current || !isConnected || !address) return; pendingRef.current = false; - setState("signing"); - let cancelled = false; - void (async () => { - try { - const nonce = await requestWalletNonce(address); - const signature = await signMessageAsync({ message: nonce.message }); - const verified = await verifyWalletSignature({ - address, - message: nonce.message, - signature, - }); - if (cancelled) return; - completeLogin(verified.token, verified.wallet); - setState("idle"); - } catch (err) { - if (!cancelled) { - setError( - err instanceof Error ? err.message : "WalletConnect login failed", - ); - setQrUri(""); - setState("idle"); - } - } finally { - // We only needed a one-off signature, not a persistent wagmi session. - disconnect(); - } - })(); - return () => { - cancelled = true; - }; - }, [address, completeLogin, disconnect, isConnected, signMessageAsync]); + completeLogin(localWalletToken(address), address); + setQrUri(""); + setState("idle"); + disconnect(); + }, [address, completeLogin, disconnect, isConnected]); return { available, state, error, qrUri, start, reset }; } From 850daf3a2ab0a5698e6688cff6d25f2aa8f26135 Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 22:18:35 +0800 Subject: [PATCH 25/89] fix(wallet): handle desktop walletconnect reconnect --- src/wallet/WalletLoginModal.tsx | 10 +++++ src/wallet/useWalletConnectLogin.ts | 60 +++++++++++++++++++++-------- 2 files changed, 54 insertions(+), 16 deletions(-) diff --git a/src/wallet/WalletLoginModal.tsx b/src/wallet/WalletLoginModal.tsx index 38723dc..ce17c75 100644 --- a/src/wallet/WalletLoginModal.tsx +++ b/src/wallet/WalletLoginModal.tsx @@ -181,6 +181,16 @@ export function WalletLoginModal() {

) : null} + {selected ? ( +
+

Wallet debug

+

state: {wc.state}

+

connected: {wc.isConnected ? "yes" : "no"}

+

address: {wc.address || "-"}

+

qr: {wc.qrUri ? "received" : "-"}

+
+ ) : null} + {selected ? (

{t("walletNetworkWarning")} diff --git a/src/wallet/useWalletConnectLogin.ts b/src/wallet/useWalletConnectLogin.ts index f61d5ea..31be95e 100644 --- a/src/wallet/useWalletConnectLogin.ts +++ b/src/wallet/useWalletConnectLogin.ts @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { useAccount, useConnect, useDisconnect } from "wagmi"; +import { useCallback, useRef, useState } from "react"; +import { useConnect, useDisconnect } from "wagmi"; import { bsc } from "wagmi/chains"; import { hasWalletConnectProjectId } from "./RainbowWalletProvider"; import type { WalletKind } from "./injected"; @@ -45,12 +45,12 @@ function walletConnectDeeplink( export function useWalletConnectLogin() { const available = hasWalletConnectProjectId(); const { completeLogin } = useWallet(); - const { address, isConnected } = useAccount(); const { connectAsync, connectors } = useConnect(); - const { disconnect } = useDisconnect(); + const { disconnect, disconnectAsync } = useDisconnect(); const [state, setState] = useState("idle"); const [error, setError] = useState(""); const [qrUri, setQrUri] = useState(""); + const [connectedAddress, setConnectedAddress] = useState(""); const pendingRef = useRef(false); const cleanupMessageRef = useRef<(() => void) | null>(null); @@ -61,6 +61,7 @@ export function useWalletConnectLogin() { setState("idle"); setError(""); setQrUri(""); + setConnectedAddress(""); }, []); const start = useCallback( @@ -71,6 +72,7 @@ export function useWalletConnectLogin() { if (!available) return; setError(""); setQrUri(""); + setConnectedAddress(""); pendingRef.current = true; setState("connecting"); @@ -122,8 +124,27 @@ export function useWalletConnectLogin() { connector.emitter.off("message", onMessage); try { + await disconnectAsync().catch(() => undefined); await connector.disconnect().catch(() => undefined); - await connectAsync({ chainId: bsc.id, connector }); + const result = await connectAsync({ chainId: bsc.id, connector }); + const connectedAddress = result.accounts[0]; + if (!connectedAddress) + throw new Error("Wallet connected without an account"); + pendingRef.current = false; + setConnectedAddress(connectedAddress); + console.info("[wallet-login] walletconnect connected", { + address: connectedAddress, + chain: "BNB Chain", + chainId: bsc.id, + }); + window.alert(`扫码成功,已拿到钱包地址:\n${connectedAddress}`); + completeLogin(localWalletToken(connectedAddress), connectedAddress); + console.info("[wallet-login] local wallet session completed", { + address: connectedAddress, + }); + setQrUri(""); + setState("idle"); + disconnect(); } catch (err) { pendingRef.current = false; setState("idle"); @@ -134,17 +155,24 @@ export function useWalletConnectLogin() { cleanupMessageRef.current = null; } }, - [available, connectAsync, connectors], + [ + available, + completeLogin, + connectAsync, + connectors, + disconnect, + disconnectAsync, + ], ); - useEffect(() => { - if (!pendingRef.current || !isConnected || !address) return; - pendingRef.current = false; - completeLogin(localWalletToken(address), address); - setQrUri(""); - setState("idle"); - disconnect(); - }, [address, completeLogin, disconnect, isConnected]); - - return { available, state, error, qrUri, start, reset }; + return { + available, + state, + error, + qrUri, + address: connectedAddress, + isConnected: Boolean(connectedAddress), + start, + reset, + }; } From 8a948e41e0044b130418a077a398cf9350a0893e Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 22:19:30 +0800 Subject: [PATCH 26/89] fix(wallet): reconnect wallet session on reload --- src/wallet/RainbowWalletProvider.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/wallet/RainbowWalletProvider.tsx b/src/wallet/RainbowWalletProvider.tsx index 83678ed..f8ecff2 100644 --- a/src/wallet/RainbowWalletProvider.tsx +++ b/src/wallet/RainbowWalletProvider.tsx @@ -12,7 +12,7 @@ import { } from "@rainbow-me/rainbowkit/wallets"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useState, type ReactNode } from "react"; -import { http, createConfig, WagmiProvider } from "wagmi"; +import { http, createConfig, WagmiProvider, useReconnect } from "wagmi"; import { bsc } from "wagmi/chains"; const projectId = @@ -40,12 +40,18 @@ export const wagmiConfig = createConfig({ }, }); +function WalletReconnectOnMount() { + useReconnect(); + return null; +} + export function RainbowWalletProvider({ children }: { children: ReactNode }) { const [queryClient] = useState(() => new QueryClient()); return ( + Date: Tue, 2 Jun 2026 23:32:39 +0800 Subject: [PATCH 27/89] fix(wallet): improve imtoken mobile login fallback --- src/wallet/injected.ts | 22 +++++++++++ src/wallet/useWalletConnectLogin.ts | 57 ++++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/wallet/injected.ts b/src/wallet/injected.ts index ec67e38..a2c0d4a 100644 --- a/src/wallet/injected.ts +++ b/src/wallet/injected.ts @@ -175,6 +175,28 @@ export function logWalletProviders(): void { }); } +export async function connectInjectedWallet( + kind?: WalletKind, +): Promise { + /* eslint-disable no-console */ + console.info("[wallet-login] start injected connect", { kind }); + logWalletProviders(); + const ethereum = getInjectedWallet(kind); + if (!ethereum) { + console.warn("[wallet-login] no injected provider found"); + throw new Error("No injected wallet found"); + } + + console.info("[wallet-login] requesting BNB wallet account…"); + const address = await requestInjectedAddress(ethereum); + console.info("[wallet-login] injected account", address); + + console.info("[wallet-login] ensuring BNB Chain (0x38)…"); + await ensureBnbChain(ethereum); + return address; + /* eslint-enable no-console */ +} + export async function signInWithInjectedWallet(kind?: WalletKind): Promise<{ token: string; wallet: string; diff --git a/src/wallet/useWalletConnectLogin.ts b/src/wallet/useWalletConnectLogin.ts index 31be95e..dddbc91 100644 --- a/src/wallet/useWalletConnectLogin.ts +++ b/src/wallet/useWalletConnectLogin.ts @@ -2,7 +2,11 @@ import { useCallback, useRef, useState } from "react"; import { useConnect, useDisconnect } from "wagmi"; import { bsc } from "wagmi/chains"; import { hasWalletConnectProjectId } from "./RainbowWalletProvider"; -import type { WalletKind } from "./injected"; +import { + connectInjectedWallet, + getInjectedWallet, + type WalletKind, +} from "./injected"; import { localWalletToken, useWallet } from "./WalletProvider"; export type WalletConnectLoginState = "idle" | "connecting" | "signing"; @@ -15,6 +19,11 @@ function isMobileDevice(): boolean { ); } +function currentUrl(): string { + if (typeof window === "undefined") return "https://ark-library.com"; + return window.location.href; +} + function walletConnectDeeplink( kind: WalletKind | undefined, uri: string, @@ -31,6 +40,27 @@ function walletConnectDeeplink( return null; } +function inAppBrowserFallback(kind: WalletKind | undefined): string | null { + if (kind === "imToken") { + return `imtokenv2://navigate/DappView?url=${encodeURIComponent(currentUrl())}`; + } + return null; +} + +function openWalletDeeplink( + kind: WalletKind | undefined, + deeplink: string, +): void { + window.location.href = deeplink; + const fallback = inAppBrowserFallback(kind); + if (!fallback) return; + window.setTimeout(() => { + if (document.visibilityState === "visible") { + window.location.href = fallback; + } + }, 1500); +} + /** * MetaMask / imToken QR fallback via RainbowKit + WalletConnect. * @@ -76,11 +106,28 @@ export function useWalletConnectLogin() { pendingRef.current = true; setState("connecting"); + if (preferredWallet && getInjectedWallet(preferredWallet)) { + try { + const injectedAddress = await connectInjectedWallet(preferredWallet); + console.info("[wallet-login] injected connected", { + preferredWallet, + address: injectedAddress, + chain: "BNB Chain", + chainId: bsc.id, + }); + completeLogin(localWalletToken(injectedAddress), injectedAddress); + setState("idle"); + return; + } catch (err) { + console.info("[wallet-login] injected connect fallback to wc", { + preferredWallet, + message: err instanceof Error ? err.message : String(err), + }); + } + } + // This modal is QR/WalletConnect-only. RainbowKit also exposes wallet- // specific injected connectors (for example `tokenPocket`) when an - // extension is installed; using those here makes the click try the local - // browser extension and can fail with "wallet must has at least one - // account" before a QR is shown. const connector = connectors.find((item) => item.type === "walletConnect") ?? connectors.find((item) => item.id === "walletConnect"); @@ -114,7 +161,7 @@ export function useWalletConnectLogin() { if (mode === "qr") setQrUri(message.data); const deeplink = walletConnectDeeplink(preferredWallet, message.data); if (mode === "deeplink" && deeplink && isMobileDevice()) { - window.location.href = deeplink; + openWalletDeeplink(preferredWallet, deeplink); } }; From 6552b92c50b8a5fdbf652ee05d51d7f83dc88349 Mon Sep 17 00:00:00 2001 From: TerryM Date: Wed, 3 Jun 2026 00:12:50 +0800 Subject: [PATCH 28/89] fix(wallet): restore metamask mobile login --- .../2026-06-02-metamask-wallet-login-debug.md | 103 ++++++++++++++++++ src/wallet/WalletLoginModal.tsx | 7 +- src/wallet/useWalletConnectLogin.ts | 97 +++++++++++++++-- 3 files changed, 192 insertions(+), 15 deletions(-) create mode 100644 .unipi/docs/debug/2026-06-02-metamask-wallet-login-debug.md diff --git a/.unipi/docs/debug/2026-06-02-metamask-wallet-login-debug.md b/.unipi/docs/debug/2026-06-02-metamask-wallet-login-debug.md new file mode 100644 index 0000000..a9ef5cd --- /dev/null +++ b/.unipi/docs/debug/2026-06-02-metamask-wallet-login-debug.md @@ -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:

`. +- QR login copy should be generic or absent; it should not say “waiting in TokenPocket” when the selected wallet is MetaMask or imToken. + +## Actual Behavior +- Terry can approve MetaMask QR/deeplink login, but the web page does not show the authorized address. +- The QR panel uses TokenPocket-specific copy for any wallet on mobile, e.g. imToken QR login still shows a TokenPocket waiting message. + +## Reproduction Steps +1. Open the public frontend and open the wallet login modal. +2. Select MetaMask. +3. Use either: + - QR login: scan the QR using MetaMask and approve, or + - Mobile app login: tap “Open wallet app”, approve in MetaMask, then return to the web page. +4. Observe that the page does not show the wallet address. +5. Select imToken QR login on mobile and observe that the QR panel displays TokenPocket-specific waiting text. + +## Environment +- Project: Arkie Library Frontend (`ark-database-web`) +- Branch context: `terry-wallet-login` +- Stack: React 18, Vite, TypeScript, RainbowKit, Wagmi, WalletConnect/Reown +- Wallets involved: MetaMask Mobile, TokenPocket, imToken +- Backend auth endpoints intentionally not required for this flow; login is local no-signature wallet session. + +## Root Cause Analysis + +### Failure Chain +1. `WalletLoginModal` calls `wc.start(kind, mode)` for all wallet app/QR flows. +2. `useWalletConnectLogin.start()` currently chooses the first generic WalletConnect connector for non-injected flows: + - `connectors.find((item) => item.type === "walletConnect") ?? connectors.find((item) => item.id === "walletConnect")` +3. On MetaMask mobile, RainbowKit’s own MetaMask wallet definition intentionally uses Wagmi’s `metaMask()` / MetaMask SDK connector, not the generic WalletConnect connector. +4. The custom hook bypasses that MetaMask-specific connector on mobile, so MetaMask SDK deeplink/reconnect handling is not used. +5. The hook only calls `completeLogin(localWalletToken(address), address)` inside the awaited `connectAsync(...)` result path. +6. If MetaMask approval completes while the browser is backgrounded, after a page reload, or through a restored Wagmi connection, there is no `useAccount`/reconnect bridge that converts the Wagmi connected address into the app’s local wallet session. +7. `RainbowWalletProvider` calls `useReconnect()`, but this only restores Wagmi connection state; it does not update `WalletProvider` unless `useWalletConnectLogin` observes the restored account and calls `completeLogin`. +8. Therefore MetaMask may be approved/connected at the wallet/Wagmi layer but the ARK UI still has no `local-wallet:
` 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=` instead of always rendering raw `wc:`. +4. Remove the QR panel paragraph entirely, or replace it with generic copy such as “Please approve the connection in your wallet app.” +5. Re-test MetaMask separately for desktop QR scan, mobile Chrome deeplink return, and MetaMask in-app browser injected login. + +### Risk Assessment +- Risk: Selecting MetaMask’s SDK connector may behave differently from WalletConnect for QR mode. Mitigate by falling back to WalletConnect if the MetaMask connector does not emit a `display_uri` in QR mode. +- Risk: Auto-completing local login from any Wagmi connected account may log in a stale account after reconnect. Mitigate by only completing when modal/pending/login-in-progress context exists or by clearing stale flags. +- Risk: Changing QR value for MetaMask may affect wallets that scan raw WalletConnect URIs. Mitigate by only applying MetaMask app-link QR transformation for MetaMask. + +## Verification Plan +1. Run `npx tsc --noEmit`. +2. Run `npm run format:check`. +3. Run `npm test`. +4. Desktop Chrome + MetaMask Mobile QR: Select MetaMask → QR login → scan/approve → confirm address and `local-wallet:
`. +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. diff --git a/src/wallet/WalletLoginModal.tsx b/src/wallet/WalletLoginModal.tsx index ce17c75..34d7645 100644 --- a/src/wallet/WalletLoginModal.tsx +++ b/src/wallet/WalletLoginModal.tsx @@ -165,13 +165,8 @@ export function WalletLoginModal() { ) : null} {selected && wc.qrUri ? ( -
+
-

- {mobileDevice - ? t("walletTpWaiting") - : t("walletQrUseAnotherDevice")} -

) : null} diff --git a/src/wallet/useWalletConnectLogin.ts b/src/wallet/useWalletConnectLogin.ts index dddbc91..c74c609 100644 --- a/src/wallet/useWalletConnectLogin.ts +++ b/src/wallet/useWalletConnectLogin.ts @@ -1,5 +1,5 @@ -import { useCallback, useRef, useState } from "react"; -import { useConnect, useDisconnect } from "wagmi"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useAccount, useConnect, useDisconnect } from "wagmi"; import { bsc } from "wagmi/chains"; import { hasWalletConnectProjectId } from "./RainbowWalletProvider"; import { @@ -24,18 +24,39 @@ function currentUrl(): string { return window.location.href; } +function isWalletConnectUri(uri: string): boolean { + return uri.startsWith("wc:"); +} + +function metaMaskWalletConnectLink(uri: string): string { + if (!isWalletConnectUri(uri)) return uri; + return `https://metamask.app.link/wc?uri=${encodeURIComponent(uri)}`; +} + +function walletConnectQrValue( + kind: WalletKind | undefined, + uri: string, +): string { + if (kind === "metaMask") return metaMaskWalletConnectLink(uri); + return uri; +} + function walletConnectDeeplink( kind: WalletKind | undefined, uri: string, ): string | null { if (kind === "tokenPocket") { - return `tpoutside://wc?uri=${encodeURIComponent(uri)}`; + return isWalletConnectUri(uri) + ? `tpoutside://wc?uri=${encodeURIComponent(uri)}` + : uri; } if (kind === "metaMask") { - return `https://metamask.app.link/wc?uri=${encodeURIComponent(uri)}`; + return metaMaskWalletConnectLink(uri); } if (kind === "imToken") { - return `imtokenv2://wc?uri=${encodeURIComponent(uri)}`; + return isWalletConnectUri(uri) + ? `imtokenv2://wc?uri=${encodeURIComponent(uri)}` + : uri; } return null; } @@ -61,6 +82,26 @@ function openWalletDeeplink( }, 1500); } +function connectorMatchesWallet( + connector: { id: string; name?: string; type?: string }, + kind: WalletKind | undefined, +): boolean { + if (!kind) return false; + const id = connector.id.toLowerCase(); + const name = connector.name?.toLowerCase() ?? ""; + const type = connector.type?.toLowerCase() ?? ""; + if (kind === "metaMask") { + return id === "metamask" || type === "metamask" || name === "metamask"; + } + if (kind === "tokenPocket") { + return id === "tokenpocket" || name === "tokenpocket"; + } + if (kind === "imToken") { + return id === "imtoken" || name === "imtoken"; + } + return false; +} + /** * MetaMask / imToken QR fallback via RainbowKit + WalletConnect. * @@ -74,7 +115,8 @@ function openWalletDeeplink( */ export function useWalletConnectLogin() { const available = hasWalletConnectProjectId(); - const { completeLogin } = useWallet(); + const { address: localAddress, completeLogin } = useWallet(); + const { address: wagmiAddress, isConnected: wagmiConnected } = useAccount(); const { connectAsync, connectors } = useConnect(); const { disconnect, disconnectAsync } = useDisconnect(); const [state, setState] = useState("idle"); @@ -82,10 +124,12 @@ export function useWalletConnectLogin() { const [qrUri, setQrUri] = useState(""); const [connectedAddress, setConnectedAddress] = useState(""); const pendingRef = useRef(false); + const completedAddressRef = useRef(null); const cleanupMessageRef = useRef<(() => void) | null>(null); const reset = useCallback(() => { pendingRef.current = false; + completedAddressRef.current = null; cleanupMessageRef.current?.(); cleanupMessageRef.current = null; setState("idle"); @@ -94,6 +138,33 @@ export function useWalletConnectLogin() { setConnectedAddress(""); }, []); + useEffect(() => { + if (!wagmiConnected || !wagmiAddress) return; + const alreadyCompleted = + completedAddressRef.current?.toLowerCase() === wagmiAddress.toLowerCase(); + if (alreadyCompleted) return; + + completedAddressRef.current = wagmiAddress; + pendingRef.current = false; + setConnectedAddress(wagmiAddress); + setQrUri(""); + setState("idle"); + + if (localAddress?.toLowerCase() !== wagmiAddress.toLowerCase()) { + console.info("[wallet-login] wagmi account connected", { + address: wagmiAddress, + chain: "BNB Chain", + chainId: bsc.id, + }); + completeLogin(localWalletToken(wagmiAddress), wagmiAddress); + console.info("[wallet-login] local wallet session completed", { + address: wagmiAddress, + }); + } + + void disconnect(); + }, [completeLogin, disconnect, localAddress, wagmiAddress, wagmiConnected]); + const start = useCallback( async ( preferredWallet?: WalletKind, @@ -103,6 +174,7 @@ export function useWalletConnectLogin() { setError(""); setQrUri(""); setConnectedAddress(""); + completedAddressRef.current = null; pendingRef.current = true; setState("connecting"); @@ -126,9 +198,13 @@ export function useWalletConnectLogin() { } } - // This modal is QR/WalletConnect-only. RainbowKit also exposes wallet- - // specific injected connectors (for example `tokenPocket`) when an + // Prefer the connector RainbowKit created for the selected wallet. This + // is especially important for MetaMask Mobile: RainbowKit/Wagmi use the + // MetaMask SDK connector there instead of the generic WalletConnect one. const connector = + connectors.find((item) => + connectorMatchesWallet(item, preferredWallet), + ) ?? connectors.find((item) => item.type === "walletConnect") ?? connectors.find((item) => item.id === "walletConnect"); @@ -158,7 +234,9 @@ export function useWalletConnectLogin() { preferredWallet, connectorId: connector.id, }); - if (mode === "qr") setQrUri(message.data); + if (mode === "qr") { + setQrUri(walletConnectQrValue(preferredWallet, message.data)); + } const deeplink = walletConnectDeeplink(preferredWallet, message.data); if (mode === "deeplink" && deeplink && isMobileDevice()) { openWalletDeeplink(preferredWallet, deeplink); @@ -178,6 +256,7 @@ export function useWalletConnectLogin() { if (!connectedAddress) throw new Error("Wallet connected without an account"); pendingRef.current = false; + completedAddressRef.current = connectedAddress; setConnectedAddress(connectedAddress); console.info("[wallet-login] walletconnect connected", { address: connectedAddress, From a68dd8f616fb6d5466deb116e3dde0559716166d Mon Sep 17 00:00:00 2001 From: TerryM Date: Wed, 3 Jun 2026 00:25:46 +0800 Subject: [PATCH 29/89] fix(wallet): complete desktop qr login after approval --- src/wallet/useWalletConnectLogin.ts | 110 ++++++++++++++++------------ 1 file changed, 65 insertions(+), 45 deletions(-) diff --git a/src/wallet/useWalletConnectLogin.ts b/src/wallet/useWalletConnectLogin.ts index c74c609..5e6c3be 100644 --- a/src/wallet/useWalletConnectLogin.ts +++ b/src/wallet/useWalletConnectLogin.ts @@ -33,14 +33,6 @@ function metaMaskWalletConnectLink(uri: string): string { return `https://metamask.app.link/wc?uri=${encodeURIComponent(uri)}`; } -function walletConnectQrValue( - kind: WalletKind | undefined, - uri: string, -): string { - if (kind === "metaMask") return metaMaskWalletConnectLink(uri); - return uri; -} - function walletConnectDeeplink( kind: WalletKind | undefined, uri: string, @@ -118,7 +110,7 @@ export function useWalletConnectLogin() { const { address: localAddress, completeLogin } = useWallet(); const { address: wagmiAddress, isConnected: wagmiConnected } = useAccount(); const { connectAsync, connectors } = useConnect(); - const { disconnect, disconnectAsync } = useDisconnect(); + const { disconnectAsync } = useDisconnect(); const [state, setState] = useState("idle"); const [error, setError] = useState(""); const [qrUri, setQrUri] = useState(""); @@ -126,12 +118,15 @@ export function useWalletConnectLogin() { const pendingRef = useRef(false); const completedAddressRef = useRef(null); const cleanupMessageRef = useRef<(() => void) | null>(null); + const cleanupPollingRef = useRef<(() => void) | null>(null); const reset = useCallback(() => { pendingRef.current = false; completedAddressRef.current = null; cleanupMessageRef.current?.(); cleanupMessageRef.current = null; + cleanupPollingRef.current?.(); + cleanupPollingRef.current = null; setState("idle"); setError(""); setQrUri(""); @@ -161,9 +156,7 @@ export function useWalletConnectLogin() { address: wagmiAddress, }); } - - void disconnect(); - }, [completeLogin, disconnect, localAddress, wagmiAddress, wagmiConnected]); + }, [completeLogin, localAddress, wagmiAddress, wagmiConnected]); const start = useCallback( async ( @@ -178,7 +171,11 @@ export function useWalletConnectLogin() { pendingRef.current = true; setState("connecting"); - if (preferredWallet && getInjectedWallet(preferredWallet)) { + if ( + mode === "deeplink" && + preferredWallet && + getInjectedWallet(preferredWallet) + ) { try { const injectedAddress = await connectInjectedWallet(preferredWallet); console.info("[wallet-login] injected connected", { @@ -198,15 +195,20 @@ export function useWalletConnectLogin() { } } - // Prefer the connector RainbowKit created for the selected wallet. This - // is especially important for MetaMask Mobile: RainbowKit/Wagmi use the - // MetaMask SDK connector there instead of the generic WalletConnect one. - const connector = - connectors.find((item) => - connectorMatchesWallet(item, preferredWallet), - ) ?? + // QR mode must always use a WalletConnect-compatible connector so the + // desktop page can render a scannable `wc:` URI instead of opening a + // local browser extension. Deeplink mode can prefer wallet-specific + // connectors (notably MetaMask SDK on mobile). + const walletConnectConnector = connectors.find((item) => item.type === "walletConnect") ?? connectors.find((item) => item.id === "walletConnect"); + const walletSpecificConnector = connectors.find((item) => + connectorMatchesWallet(item, preferredWallet), + ); + const connector = + mode === "qr" + ? walletConnectConnector + : (walletSpecificConnector ?? walletConnectConnector); if (!connector) { pendingRef.current = false; @@ -235,7 +237,7 @@ export function useWalletConnectLogin() { connectorId: connector.id, }); if (mode === "qr") { - setQrUri(walletConnectQrValue(preferredWallet, message.data)); + setQrUri(message.data); } const deeplink = walletConnectDeeplink(preferredWallet, message.data); if (mode === "deeplink" && deeplink && isMobileDevice()) { @@ -248,6 +250,43 @@ export function useWalletConnectLogin() { cleanupMessageRef.current = () => connector.emitter.off("message", onMessage); + cleanupPollingRef.current?.(); + const finishFromAddress = (address: string, source: string) => { + const alreadyCompleted = + completedAddressRef.current?.toLowerCase() === address.toLowerCase(); + if (alreadyCompleted) return; + pendingRef.current = false; + completedAddressRef.current = address; + setConnectedAddress(address); + setQrUri(""); + setState("idle"); + cleanupMessageRef.current?.(); + cleanupMessageRef.current = null; + cleanupPollingRef.current?.(); + cleanupPollingRef.current = null; + console.info("[wallet-login] wallet account connected", { + source, + preferredWallet, + address, + chain: "BNB Chain", + chainId: bsc.id, + }); + completeLogin(localWalletToken(address), address); + console.info("[wallet-login] local wallet session completed", { + address, + }); + }; + const pollId = window.setInterval(() => { + void connector + .getAccounts() + .then((accounts) => { + const account = accounts[0]; + if (account) finishFromAddress(account, "connector-poll"); + }) + .catch(() => undefined); + }, 1000); + cleanupPollingRef.current = () => window.clearInterval(pollId); + try { await disconnectAsync().catch(() => undefined); await connector.disconnect().catch(() => undefined); @@ -255,23 +294,9 @@ export function useWalletConnectLogin() { const connectedAddress = result.accounts[0]; if (!connectedAddress) throw new Error("Wallet connected without an account"); - pendingRef.current = false; - completedAddressRef.current = connectedAddress; - setConnectedAddress(connectedAddress); - console.info("[wallet-login] walletconnect connected", { - address: connectedAddress, - chain: "BNB Chain", - chainId: bsc.id, - }); - window.alert(`扫码成功,已拿到钱包地址:\n${connectedAddress}`); - completeLogin(localWalletToken(connectedAddress), connectedAddress); - console.info("[wallet-login] local wallet session completed", { - address: connectedAddress, - }); - setQrUri(""); - setState("idle"); - disconnect(); + finishFromAddress(connectedAddress, "connectAsync"); } catch (err) { + if (completedAddressRef.current) return; pendingRef.current = false; setState("idle"); setError( @@ -279,16 +304,11 @@ export function useWalletConnectLogin() { ); cleanupMessageRef.current?.(); cleanupMessageRef.current = null; + cleanupPollingRef.current?.(); + cleanupPollingRef.current = null; } }, - [ - available, - completeLogin, - connectAsync, - connectors, - disconnect, - disconnectAsync, - ], + [available, completeLogin, connectAsync, connectors, disconnectAsync], ); return { From 966663f3d7b4867e756ceda0f9fa32f8244aa792 Mon Sep 17 00:00:00 2001 From: TerryM Date: Wed, 3 Jun 2026 01:57:56 +0800 Subject: [PATCH 30/89] ci: add staging deploy workflow for terry-wallet-login - trigger on push to terry-wallet-login - deploys to staging server via STAGING_* secrets - rsync to /var/www/ark-library-staging/, sha256 verify --- .gitea/workflows/deploy-staging.yml | 103 ++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 .gitea/workflows/deploy-staging.yml diff --git a/.gitea/workflows/deploy-staging.yml b/.gitea/workflows/deploy-staging.yml new file mode 100644 index 0000000..87bbec5 --- /dev/null +++ b/.gitea/workflows/deploy-staging.yml @@ -0,0 +1,103 @@ +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 + if [ -d "$HOME/.cache/act" ]; then + du -sh "$HOME/.cache/act" 2>/dev/null + find "$HOME/.cache/act" -mindepth 1 -maxdepth 1 -type d -mmin +60 -exec rm -rf {} + 2>/dev/null + fi + for dir in "$HOME/actions-runner/_work" "$HOME/.cache/setup-node" "$HOME/.npm/_cacache"; do + if [ -d "$dir" ]; then + find "$dir" -mindepth 1 -maxdepth 2 -mmin +1440 -exec rm -rf {} + 2>/dev/null + fi + done + if command -v docker >/dev/null 2>&1; then + docker image prune -af --filter "until=24h" 2>/dev/null + docker container prune -f --filter "until=24h" 2>/dev/null + docker builder prune -af --filter "until=24h" 2>/dev/null + fi + find /tmp -mindepth 1 -maxdepth 1 -mmin +120 \ + -not -name 'runner*' -not -name 'act*' \ + -exec rm -rf {} + 2>/dev/null + echo "=== Disk after cleanup ===" + df -h + exit 0 + + - name: Checkout code + uses: actions/checkout@v4 + + - 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 From 42b25b9e090c8a5d862b1e1887e37c37efabcc06 Mon Sep 17 00:00:00 2001 From: TerryM Date: Wed, 3 Jun 2026 02:11:26 +0800 Subject: [PATCH 31/89] ci(staging): aggressive disk cleanup to prevent ENOSPC during npm ci - 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 --- .gitea/workflows/deploy-staging.yml | 54 ++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/.gitea/workflows/deploy-staging.yml b/.gitea/workflows/deploy-staging.yml index 87bbec5..986ab62 100644 --- a/.gitea/workflows/deploy-staging.yml +++ b/.gitea/workflows/deploy-staging.yml @@ -15,23 +15,53 @@ jobs: set +e echo "=== Disk before cleanup ===" df -h + du -sh "$HOME/.cache/act" "$HOME/.npm" "$HOME/actions-runner/_work" 2>/dev/null + + # Identify current act workspace (most recently modified dir) and keep it; wipe the rest. if [ -d "$HOME/.cache/act" ]; then - du -sh "$HOME/.cache/act" 2>/dev/null - find "$HOME/.cache/act" -mindepth 1 -maxdepth 1 -type d -mmin +60 -exec rm -rf {} + 2>/dev/null + CURRENT_ACT=$(ls -1dt "$HOME/.cache/act"/*/ 2>/dev/null | head -1 || true) + echo "Preserving current act dir: ${CURRENT_ACT:-}" + for d in "$HOME/.cache/act"/*/; do + [ -d "$d" ] || continue + if [ "$d" != "$CURRENT_ACT" ]; then + echo "Removing stale act workspace: $d" + rm -rf "$d" + fi + done fi - for dir in "$HOME/actions-runner/_work" "$HOME/.cache/setup-node" "$HOME/.npm/_cacache"; do - if [ -d "$dir" ]; then - find "$dir" -mindepth 1 -maxdepth 2 -mmin +1440 -exec rm -rf {} + 2>/dev/null - fi - done + + # Wipe npm and setup-node caches (will repopulate from registry via cache: npm) + 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 image prune -af --filter "until=24h" 2>/dev/null - docker container prune -f --filter "until=24h" 2>/dev/null - docker builder prune -af --filter "until=24h" 2>/dev/null + docker system prune -af --volumes 2>/dev/null fi - find /tmp -mindepth 1 -maxdepth 1 -mmin +120 \ - -not -name 'runner*' -not -name 'act*' \ + + # apt cache (if runner uses apt) + 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 From 49380dc5ed98d96925ba1e203decea265f3ce317 Mon Sep 17 00:00:00 2001 From: TerryM Date: Wed, 3 Jun 2026 02:18:29 +0800 Subject: [PATCH 32/89] ci(staging): revert act cache wipe, keep other aggressive cleanup 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. --- .gitea/workflows/deploy-staging.yml | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/.gitea/workflows/deploy-staging.yml b/.gitea/workflows/deploy-staging.yml index 986ab62..9362900 100644 --- a/.gitea/workflows/deploy-staging.yml +++ b/.gitea/workflows/deploy-staging.yml @@ -17,20 +17,12 @@ jobs: df -h du -sh "$HOME/.cache/act" "$HOME/.npm" "$HOME/actions-runner/_work" 2>/dev/null - # Identify current act workspace (most recently modified dir) and keep it; wipe the rest. + # DO NOT touch ~/.cache/act for the current job — only sweep dirs older than 60 min. if [ -d "$HOME/.cache/act" ]; then - CURRENT_ACT=$(ls -1dt "$HOME/.cache/act"/*/ 2>/dev/null | head -1 || true) - echo "Preserving current act dir: ${CURRENT_ACT:-}" - for d in "$HOME/.cache/act"/*/; do - [ -d "$d" ] || continue - if [ "$d" != "$CURRENT_ACT" ]; then - echo "Removing stale act workspace: $d" - rm -rf "$d" - fi - done + 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 (will repopulate from registry via cache: npm) + # 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 @@ -44,7 +36,7 @@ jobs: docker system prune -af --volumes 2>/dev/null fi - # apt cache (if runner uses apt) + # apt/yum cache if command -v apt-get >/dev/null 2>&1; then sudo apt-get clean 2>/dev/null fi From a2f6c4fc35e06a564c8d5c2917a87612a29108e1 Mon Sep 17 00:00:00 2001 From: TerryM Date: Wed, 3 Jun 2026 08:08:28 +0800 Subject: [PATCH 33/89] 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 --- src/components/PopularRankList.tsx | 22 ++++---- src/components/RecommendedCard.tsx | 88 +++++++++++++++++------------- 2 files changed, 60 insertions(+), 50 deletions(-) diff --git a/src/components/PopularRankList.tsx b/src/components/PopularRankList.tsx index b71ce1a..1867eaf 100644 --- a/src/components/PopularRankList.tsx +++ b/src/components/PopularRankList.tsx @@ -66,7 +66,7 @@ function RankBadge({ index }: { index: number }) { if (index < MEDALS.length) { return ( {MEDALS[index]} @@ -75,7 +75,7 @@ function RankBadge({ index }: { index: number }) { } return ( {index + 1} @@ -123,7 +123,7 @@ function PopularRankRow({ }; return ( -
+
- ) : null} +
+ + {dl ? ( + + ) : null} +
From 7e4be0a5908ecd81250edb8ef76f07d3ba91d174 Mon Sep 17 00:00:00 2001 From: TerryM Date: Wed, 3 Jun 2026 08:11:48 +0800 Subject: [PATCH 34/89] style(home): set recommended card background to #1D1E23 per figma spec --- src/components/RecommendedCard.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/RecommendedCard.tsx b/src/components/RecommendedCard.tsx index 1f6d082..486bdcb 100644 --- a/src/components/RecommendedCard.tsx +++ b/src/components/RecommendedCard.tsx @@ -22,7 +22,7 @@ function isPlaceholderAsset(path: string | undefined | null) { } const CARD_BASE_CLASS = - "group flex shrink-0 flex-col overflow-hidden rounded-xl border bg-[#272632] transition hover:border-ark-gold/55 hover:shadow-lg hover:shadow-black/30"; + "group flex shrink-0 flex-col overflow-hidden rounded-xl border bg-[#1D1E23] transition hover:border-ark-gold/55 hover:shadow-lg hover:shadow-black/30"; const CARD_HOVER_SPRING = { type: "spring", @@ -107,7 +107,7 @@ export function RecommendedCard({ layout === "grid" ? CARD_GRID_SIZE_CLASS : CARD_CAROUSEL_SIZE_CLASS } ${ useFigmaDesign - ? "border-[#27292E] bg-[#272632]" + ? "border-[#27292E] bg-[#1D1E23]" : "border-transparent md:border-ark-line md:bg-ark-panel" }`} > @@ -119,7 +119,7 @@ export function RecommendedCard({
From 4f0d8925a48a58de4e6aa2e2732b89c1acdea08f Mon Sep 17 00:00:00 2001 From: TerryM Date: Wed, 3 Jun 2026 08:13:55 +0800 Subject: [PATCH 35/89] style(popular): drop static border on rank row to match figma spec --- src/components/PopularRankList.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/PopularRankList.tsx b/src/components/PopularRankList.tsx index 1867eaf..64c4a13 100644 --- a/src/components/PopularRankList.tsx +++ b/src/components/PopularRankList.tsx @@ -123,7 +123,7 @@ function PopularRankRow({ }; return ( -
+
+ ) : null} +
+ ); +} + +function Footer({ post, attachment }: { post: Post; attachment?: Attachment }) { + return ( +
+ + +
+ ); +} + +function attachmentPreview(att: Attachment | undefined): string { + if (!att) return ""; + return att.thumbnailUrl ?? att.posterUrl ?? att.thumbUrl ?? att.url ?? ""; +} + +function FileCard({ post, att }: { post: Post; att: Attachment }) { + const { lang } = useI18n(); + const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename }); + const displayFilename = filenameWithExtension(att.filename, att.mime); + const text = postDisplayText(post, lang); + + return ( +
+ +
+
+ +
+
+
+ {(() => { + const { base, ext } = splitFilename(displayFilename); + const tailChars = Math.min(8, base.length); + const head = base.slice(0, base.length - tailChars); + const tail = base.slice(base.length - tailChars) + ext; + return ( + <> + {head} + {tail} + + ); + })()} +
+
+ {formatBytes(att.sizeBytes)} +
+
+
+
+ {text || post.title || displayFilename} +
+
+
+ ); +} + +function MediaGrid({ attachments }: { attachments: Attachment[] }) { + const visible = attachments.slice(0, 4); + const extra = Math.max(0, attachments.length - visible.length); + if (visible.length <= 1) { + const src = attachmentPreview(visible[0]); + return src ? ( + + ) : ( +
+ ); + } + return ( +
+ {visible.map((att, index) => ( +
+ + {extra > 0 && index === visible.length - 1 ? ( +
+ +{extra} +
+ ) : null} +
+ ))} +
+ ); +} + +function MediaBadge({ att }: { att?: Attachment }) { + if (!att) return null; + return ( +
+ + + + {formatBytes(att.sizeBytes)} +
+ ); +} + +function VisualCard({ post }: { post: Post }) { + const { lang } = useI18n(); + const att = post.attachments[0]; + const isVideo = att?.kind === "video" || att?.mime.startsWith("video/"); + const text = postDisplayText(post, lang); + + return ( +
+ +
+ + + {isVideo ? ( +
+ + + +
+ ) : null} +
+ {text || post.title ? ( +
+ {autolink(text || post.title || "")} +
+ ) : null} +
+
+ ); +} + +function CardLink({ postId }: { postId: string }) { + const lp = useLocalizedPath(); + return ( + + ); +} + +export function LatestUpdateCard({ post }: { post: Post }) { + const first = post.attachments[0]; + const isFile = + !!first && + !( + first.kind === "image" || + first.kind === "video" || + first.mime.startsWith("image/") || + first.mime.startsWith("video/") + ); + if (isFile) return ; + return ; +} diff --git a/src/components/icons/DownloadCloudIcon.tsx b/src/components/icons/DownloadCloudIcon.tsx index a0fa835..7d4dca1 100644 --- a/src/components/icons/DownloadCloudIcon.tsx +++ b/src/components/icons/DownloadCloudIcon.tsx @@ -3,13 +3,18 @@ import type { SVGProps } from "react"; export function DownloadCloudIcon(props: SVGProps) { return ( - + ); } diff --git a/src/components/messageStream/MessageBubble.tsx b/src/components/messageStream/MessageBubble.tsx index 87268a0..76f9afc 100644 --- a/src/components/messageStream/MessageBubble.tsx +++ b/src/components/messageStream/MessageBubble.tsx @@ -10,7 +10,12 @@ import { LinkPreviewCard } from "./LinkPreviewCard"; import { formatDateTime } from "./utils/formatTime"; import { FavoriteButton } from "../../favorites/FavoriteButton"; -type BubbleComponent = ComponentType<{ post: Post }>; +export type MessageBubbleVariant = "default" | "latest"; + +type BubbleComponent = ComponentType<{ + post: Post; + variant?: MessageBubbleVariant; +}>; export function pickBubble(post: Post): BubbleComponent { const a = post.attachments; @@ -27,11 +32,14 @@ export function pickBubble(post: Post): BubbleComponent { export function MessageBubble({ post, fluid = false, + variant = "default", }: { post: Post; /** When true, fill the parent container instead of applying the standalone * feed max-widths. Used by the desktop 3-column masonry on the home page. */ fluid?: boolean; + /** Desktop latest-updates cards follow the dedicated Figma masonry design. */ + variant?: MessageBubbleVariant; }) { const Bubble = pickBubble(post); const isVisual = @@ -39,6 +47,7 @@ export function MessageBubble({ Bubble === VideoBubble || Bubble === ImageBubble || Bubble === ImageWithTextBubble; + const isLatestFileCard = variant === "latest" && Bubble === FileDocBubble; return (
- - + {!isLatestFileCard ? ( + + ) : null} + {post.linkPreview ? (
) : null} - + {!isLatestFileCard ? ( + + ) : null}
); diff --git a/src/components/messageStream/bubbles/FileDocBubble.tsx b/src/components/messageStream/bubbles/FileDocBubble.tsx index a9a3211..2d0ea7c 100644 --- a/src/components/messageStream/bubbles/FileDocBubble.tsx +++ b/src/components/messageStream/bubbles/FileDocBubble.tsx @@ -7,6 +7,7 @@ import { downloadAttachment } from "../utils/downloadFile"; import { fileIcon } from "../utils/fileIcon"; import { filenameWithExtension, splitFilename } from "../utils/filenameDisplay"; import { formatBytes } from "../utils/formatBytes"; +import { formatDateTime } from "../utils/formatTime"; import { postDisplayText } from "../utils/postText"; import { CollapsibleText } from "../CollapsibleText"; import { @@ -14,6 +15,8 @@ import { useSaveToAlbumGuide, } from "../../SaveToAlbumGuide"; import { useToast } from "../../Toast"; +import { FavoriteButton } from "../../../favorites/FavoriteButton"; +import type { MessageBubbleVariant } from "../MessageBubble"; function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) { const { t } = useI18n(); @@ -104,9 +107,123 @@ function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) { ); } -export function FileDocBubble({ post }: { post: Post }) { +function LatestFileCard({ post }: { post: Post }) { + const { t, lang } = useI18n(); + const { showToast } = useToast(); + const { showSaveToAlbumGuide } = useSaveToAlbumGuide(); + const [isDownloading, setIsDownloading] = useState(false); + const att = post.attachments[0]; + const text = postDisplayText(post, lang); + + if (!att) return null; + + const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename }); + const displayFilename = filenameWithExtension(att.filename, att.mime); + + const handleDownload = async () => { + if (isDownloading) return; + setIsDownloading(true); + try { + await downloadAttachment(post.id, att.id, displayFilename); + const mediaKind = mediaSaveKindFromAttachment(att); + if (mediaKind) showSaveToAlbumGuide(mediaKind); + } catch { + showToast(t("downloadFail"), "error"); + } finally { + setIsDownloading(false); + } + }; + + return ( +
+
+ +
+
+ {(() => { + const { base, ext } = splitFilename(displayFilename); + const tailChars = Math.min(8, base.length); + const head = base.slice(0, base.length - tailChars); + const tail = base.slice(base.length - tailChars) + ext; + return ( + <> + {head} + {tail} + + ); + })()} +
+
+ {isDownloading ? t("downloading") : formatBytes(att.sizeBytes)} +
+
+
+ + {text ? ( +
+ {text} +
+ ) : ( +
+ )} + +
+ +
+ + +
+
+
+ ); +} + +export function FileDocBubble({ + post, + variant = "default", +}: { + post: Post; + variant?: MessageBubbleVariant; +}) { const { lang } = useI18n(); const text = postDisplayText(post, lang); + + if (variant === "latest") { + return ; + } + return (
{post.attachments.map((att) => ( diff --git a/src/favorites/FavoriteButton.tsx b/src/favorites/FavoriteButton.tsx index 260cd9c..d2824f1 100644 --- a/src/favorites/FavoriteButton.tsx +++ b/src/favorites/FavoriteButton.tsx @@ -1,4 +1,4 @@ -import { Heart, LoaderCircle } from "lucide-react"; +import { LoaderCircle } from "lucide-react"; import { useEffect } from "react"; import { useI18n } from "../i18n"; import { useFavorites } from "./FavoritesProvider"; @@ -9,6 +9,24 @@ type FavoriteButtonProps = { size?: "sm" | "md"; }; +function FigmaBookmarkIcon() { + return ( + + ); +} + export function FavoriteButton({ resourceId, className = "", @@ -43,18 +61,14 @@ export function FavoriteButton({ dimension, isFavorite ? "border-ark-gold/60 bg-ark-gold text-black hover:bg-ark-gold2" - : "border-white/10 bg-[#191921]/90 text-white hover:border-ark-gold/50 hover:bg-ark-gold/10 hover:text-ark-gold", + : "border-white/10 bg-[#191921]/90 text-[#A8A9AE] hover:border-ark-gold hover:bg-[#191921] hover:text-ark-gold", className, ].join(" ")} > {pending ? ( ) : ( - + )} ); diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index 39cb841..7e53f8b 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -645,19 +645,6 @@ export function PublicLayout() {
- - -
); } From 724bfb8f2422da18e6ce30acb56f76f5166cd753 Mon Sep 17 00:00:00 2001 From: TerryM Date: Wed, 3 Jun 2026 08:33:29 +0800 Subject: [PATCH 37/89] fix(home): match Figma media pills for latest cards - 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. --- src/components/LatestUpdateCard.tsx | 141 ++++++++++++++++++---------- 1 file changed, 94 insertions(+), 47 deletions(-) diff --git a/src/components/LatestUpdateCard.tsx b/src/components/LatestUpdateCard.tsx index 7043f89..e901272 100644 --- a/src/components/LatestUpdateCard.tsx +++ b/src/components/LatestUpdateCard.tsx @@ -147,57 +147,112 @@ function FileCard({ post, att }: { post: Post; att: Attachment }) { ); } +function PillDownloadIcon() { + return ( + + ); +} + +function MediaSizeChip({ att }: { att: Attachment }) { + return ( +
+ + + + + {formatBytes(att.sizeBytes)} + +
+ ); +} + +function MediaTile({ + att, + showExtra, +}: { + att: Attachment; + showExtra?: number; +}) { + const src = attachmentPreview(att); + const isVideo = att.kind === "video" || att.mime.startsWith("video/"); + return ( +
+ {src ? ( + + ) : null} + + {isVideo ? ( +
+ + + +
+ ) : null} + {showExtra ? ( +
+ +{showExtra} +
+ ) : null} +
+ ); +} + function MediaGrid({ attachments }: { attachments: Attachment[] }) { const visible = attachments.slice(0, 4); const extra = Math.max(0, attachments.length - visible.length); - if (visible.length <= 1) { - const src = attachmentPreview(visible[0]); - return src ? ( - - ) : ( -
+ if (visible.length === 0) return
; + if (visible.length === 1) return ; + if (visible.length === 2) { + return ( +
+ {visible.map((att) => ( + + ))} +
+ ); + } + if (visible.length === 3) { + return ( +
+ +
+ + +
+
); } return (
{visible.map((att, index) => ( -
- - {extra > 0 && index === visible.length - 1 ? ( -
- +{extra} -
- ) : null} -
+ 0 && index === visible.length - 1 ? extra : 0} + /> ))}
); } -function MediaBadge({ att }: { att?: Attachment }) { - if (!att) return null; - return ( -
- - - - {formatBytes(att.sizeBytes)} -
- ); -} - function VisualCard({ post }: { post: Post }) { const { lang } = useI18n(); const att = post.attachments[0]; @@ -213,21 +268,13 @@ function VisualCard({ post }: { post: Post }) { }`} > - - {isVideo ? ( -
- - - -
- ) : null}
{text || post.title ? (
{autolink(text || post.title || "")}
) : null} -
+
); } From 6800a8e9b68ff17bca32d0ba0d3d5816a5e1ac22 Mon Sep 17 00:00:00 2001 From: TerryM Date: Wed, 3 Jun 2026 20:07:23 +0800 Subject: [PATCH 38/89] feat(wallet): bypass WalletConnect for TP/imToken on mobile to fix China users 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 + modal so RainbowKit init failures no longer blank the entire app. - Hoist 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= 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=, 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). --- src/App.tsx | 212 ++++++++++++------------ src/wallet/AutoInjectedLogin.tsx | 83 ++++++++++ src/wallet/WalletLoginModal.tsx | 34 +++- src/wallet/WalletStackErrorBoundary.tsx | 22 +++ 4 files changed, 242 insertions(+), 109 deletions(-) create mode 100644 src/wallet/AutoInjectedLogin.tsx create mode 100644 src/wallet/WalletStackErrorBoundary.tsx diff --git a/src/App.tsx b/src/App.tsx index f851274..2cae08b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,9 +4,11 @@ import { MotionProvider } from "./motion"; import { ToastProvider } from "./components/Toast"; import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide"; import { FavoritesProvider } from "./favorites/FavoritesProvider"; +import { AutoInjectedLogin } from "./wallet/AutoInjectedLogin"; import { RainbowWalletProvider } from "./wallet/RainbowWalletProvider"; import { WalletLoginModal } from "./wallet/WalletLoginModal"; import { WalletProvider } from "./wallet/WalletProvider"; +import { WalletStackErrorBoundary } from "./wallet/WalletStackErrorBoundary"; import { PublicLayout } from "./layouts/PublicLayout"; import { LocalizedHomePage } from "./pages/LocalizedHome"; import { Browse } from "./pages/Browse"; @@ -32,115 +34,113 @@ export default function App() { - - - - - - - - - - - - - }> - {/* English (root, no prefix) */} - - } - /> - } /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - {/* Each non-English language gets its own nested tree. */} - {localizedHomeRoutes.map((route) => ( - - - } - /> - } /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - ))} - - - {adminEnabled ? ( - AdminRouteTree() - ) : ( - } - /> - )} - + + + + + + + + + + + + + + + + + }> + {/* English (root, no prefix) */} } + /> + } /> + } + /> + } + /> + } + /> + } /> + } + /> + } + /> + + {/* Each non-English language gets its own nested tree. */} + {localizedHomeRoutes.map((route) => ( + + + } + /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + ))} + + + {adminEnabled ? ( + AdminRouteTree() + ) : ( + } /> - - - - - - - - - - + )} + + } + /> + + + + + + + + + diff --git a/src/wallet/AutoInjectedLogin.tsx b/src/wallet/AutoInjectedLogin.tsx new file mode 100644 index 0000000..dfc5178 --- /dev/null +++ b/src/wallet/AutoInjectedLogin.tsx @@ -0,0 +1,83 @@ +import { useEffect } from "react"; +import { + connectInjectedWallet, + getInjectedWallet, + type WalletKind, +} from "./injected"; +import { localWalletToken, useWallet } from "./WalletProvider"; + +const AUTO_LOGIN_PARAM = "autoLogin"; +const ETHEREUM_WAIT_MS = 8000; +const ETHEREUM_POLL_MS = 200; + +function parseKind(value: string | null): WalletKind | null { + if (value === "tokenPocket" || value === "metaMask" || value === "imToken") { + return value; + } + return null; +} + +function stripAutoLoginParam(): void { + const url = new URL(window.location.href); + url.searchParams.delete(AUTO_LOGIN_PARAM); + const qs = url.searchParams.toString(); + const next = url.pathname + (qs ? `?${qs}` : "") + url.hash; + window.history.replaceState({}, "", next); +} + +function waitForInjected(kind: WalletKind): Promise { + return new Promise((resolve) => { + const start = Date.now(); + const tick = () => { + if (getInjectedWallet(kind)) { + resolve(true); + return; + } + if (Date.now() - start >= ETHEREUM_WAIT_MS) { + resolve(false); + return; + } + window.setTimeout(tick, ETHEREUM_POLL_MS); + }; + tick(); + }); +} + +/** + * When the page is opened via a `?autoLogin=` deeplink (typically from + * inside TokenPocket / imToken in-app browsers), wait for the wallet to inject + * `window.ethereum`, then complete a local wallet session automatically. Bypasses + * WalletConnect entirely so it works on networks where the WC relay is blocked. + */ +export function AutoInjectedLogin() { + const { completeLogin, status } = useWallet(); + + useEffect(() => { + if (typeof window === "undefined") return; + const params = new URLSearchParams(window.location.search); + const kind = parseKind(params.get(AUTO_LOGIN_PARAM)); + if (!kind) return; + + stripAutoLoginParam(); + if (status === "loggedIn") return; + + let cancelled = false; + void waitForInjected(kind).then(async (ready) => { + if (cancelled || !ready) return; + try { + const address = await connectInjectedWallet(kind); + if (cancelled) return; + completeLogin(localWalletToken(address), address); + } catch (err) { + // eslint-disable-next-line no-console + console.warn("[wallet-autologin] failed", err); + } + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return null; +} diff --git a/src/wallet/WalletLoginModal.tsx b/src/wallet/WalletLoginModal.tsx index 34d7645..1ed82ca 100644 --- a/src/wallet/WalletLoginModal.tsx +++ b/src/wallet/WalletLoginModal.tsx @@ -2,11 +2,24 @@ import { QRCodeSVG } from "qrcode.react"; import { LoaderCircle, X } from "lucide-react"; import { useEffect, useState } from "react"; import { useI18n } from "../i18n"; -import type { WalletKind } from "./injected"; +import { walletDeepLink } from "./deepLinks"; +import { getInjectedWallet, type WalletKind } from "./injected"; import { useWallet } from "./WalletProvider"; import { useWalletConnectLogin } from "./useWalletConnectLogin"; import { WalletBrandIcon } from "./WalletBrandIcon"; +const AUTO_LOGIN_PARAM = "autoLogin"; + +function supportsDirectPull(kind: WalletKind): boolean { + return kind === "tokenPocket" || kind === "imToken"; +} + +function buildAutoLoginDappUrl(kind: WalletKind): string { + const url = new URL(window.location.href); + url.searchParams.set(AUTO_LOGIN_PARAM, kind); + return url.toString(); +} + const wallets: WalletKind[] = ["tokenPocket", "metaMask", "imToken"]; function isMobileDevice(): boolean { @@ -61,6 +74,19 @@ export function WalletLoginModal() { void wc.start(kind, mode); }; + const openWalletAppDirect = (kind: WalletKind) => { + if (getInjectedWallet(kind)) { + startWalletLogin(kind, "deeplink"); + return; + } + if (mobileDevice && supportsDirectPull(kind)) { + const deeplink = walletDeepLink(kind, buildAutoLoginDappUrl(kind)); + window.location.href = deeplink; + return; + } + startWalletLogin(kind, "deeplink"); + }; + return (
+ ); +} diff --git a/src/components/messageStream/MessageBubble.tsx b/src/components/messageStream/MessageBubble.tsx index 76f9afc..b8e992c 100644 --- a/src/components/messageStream/MessageBubble.tsx +++ b/src/components/messageStream/MessageBubble.tsx @@ -9,6 +9,7 @@ import { VideoBubble } from "./bubbles/VideoBubble"; import { LinkPreviewCard } from "./LinkPreviewCard"; import { formatDateTime } from "./utils/formatTime"; import { FavoriteButton } from "../../favorites/FavoriteButton"; +import { BubbleAttachmentDownloadButton } from "./BubbleAttachmentDownloadButton"; export type MessageBubbleVariant = "default" | "latest"; @@ -47,7 +48,9 @@ export function MessageBubble({ Bubble === VideoBubble || Bubble === ImageBubble || Bubble === ImageWithTextBubble; - const isLatestFileCard = variant === "latest" && Bubble === FileDocBubble; + const isFileBubble = Bubble === FileDocBubble; + const isLatestVariant = variant === "latest"; + const isLatestFileCard = isLatestVariant && isFileBubble; return (
- {!isLatestFileCard ? ( + {isLatestVariant && !isFileBubble ? ( ) : null} + + {post.linkPreview ? (
) : null} - {!isLatestFileCard ? ( + + {!isLatestVariant ? ( +
+ +
+ + {isFileBubble && post.attachments[0] ? ( + + ) : null} +
+
+ ) : null} + + {isLatestVariant && !isFileBubble ? (
- {isDownloading ? t("downloading") : formatBytes(att.sizeBytes)} + {formatBytes(att.sizeBytes)}
-
); } From 39f9cba8c7ac28308f03acaafd62aeaaa1ade261 Mon Sep 17 00:00:00 2001 From: TerryM Date: Wed, 3 Jun 2026 21:59:31 +0800 Subject: [PATCH 40/89] feat(layout): full-screen mobile menu drawer matching Figma 4164-5733 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
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. --- ...6-06-03-mobile-menu-drawer-stacking-fix.md | 29 ++++ src/layouts/PublicLayout.tsx | 155 ++++++++++-------- 2 files changed, 113 insertions(+), 71 deletions(-) create mode 100644 .unipi/docs/fix/2026-06-03-mobile-menu-drawer-stacking-fix.md diff --git a/.unipi/docs/fix/2026-06-03-mobile-menu-drawer-stacking-fix.md b/.unipi/docs/fix/2026-06-03-mobile-menu-drawer-stacking-fix.md new file mode 100644 index 0000000..1d852ec --- /dev/null +++ b/.unipi/docs/fix/2026-06-03-mobile-menu-drawer-stacking-fix.md @@ -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 `
`. 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 (`
- - {open ? ( -
- setOpen(false)} - > - {t("all")} - - setOpen(false)} - > - {t("categories")} - - setOpen(false)} - > - {t("official")} - - setOpen(false)} - > - {t("latest")} - - setOpen(false)} - > - {t("popular")} - - setOpen(false)} - > - - {t("favorites")} - -
- setOpen(false)} /> -
-
- ) : null} + {open ? ( +
+ +
+ setOpen(false)} /> +
+
+ ) : null} + {mobileSearchOpen ? ( Date: Wed, 3 Jun 2026 21:59:38 +0800 Subject: [PATCH 41/89] feat(wallet): swap CTA glyph to Figma 4414-12829 filled wallet icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../fix/2026-06-03-wallet-icon-figma-fix.md | 30 +++++++++++++++++++ src/components/icons/WalletIcon.tsx | 27 +++++++++++++++++ src/wallet/WalletButton.tsx | 5 ++-- 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 .unipi/docs/fix/2026-06-03-wallet-icon-figma-fix.md create mode 100644 src/components/icons/WalletIcon.tsx diff --git a/.unipi/docs/fix/2026-06-03-wallet-icon-figma-fix.md b/.unipi/docs/fix/2026-06-03-wallet-icon-figma-fix.md new file mode 100644 index 0000000..649e4e7 --- /dev/null +++ b/.unipi/docs/fix/2026-06-03-wallet-icon-figma-fix.md @@ -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. diff --git a/src/components/icons/WalletIcon.tsx b/src/components/icons/WalletIcon.tsx new file mode 100644 index 0000000..7fa00db --- /dev/null +++ b/src/components/icons/WalletIcon.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from "react"; + +/** + * Figma wallet glyph (`4414:12829`). Filled body so it reads as a solid mark + * on the yellow CTA button. Uses `currentColor` so callers control the + * paint via `text-…` utilities. + */ +export function WalletIcon({ + className = "h-4 w-4", + ...rest +}: SVGProps) { + return ( + + ); +} diff --git a/src/wallet/WalletButton.tsx b/src/wallet/WalletButton.tsx index 83c18b1..2beb350 100644 --- a/src/wallet/WalletButton.tsx +++ b/src/wallet/WalletButton.tsx @@ -1,6 +1,7 @@ -import { Heart, Wallet } from "lucide-react"; +import { Heart } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { Link } from "react-router-dom"; +import { WalletIcon } from "../components/icons/WalletIcon"; import { useI18n } from "../i18n"; import { useLocalizedPath } from "../useLocalizedPath"; import { shortenAddress, useWallet } from "./WalletProvider"; @@ -111,7 +112,7 @@ export function WalletButton({ compact ? "w-full" : "min-w-[124px] shrink-0 whitespace-nowrap", ].join(" ")} > - + {wallet.status === "loading" ? t("loading") : t("walletConnect")} From 57dc25e5eb60db506ffdd964625685e12bef6bf2 Mon Sep 17 00:00:00 2001 From: TerryM Date: Thu, 4 Jun 2026 07:21:33 +0800 Subject: [PATCH 42/89] 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. --- src/layouts/PublicLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index e57a3eb..380e7ea 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -713,7 +713,7 @@ export function PublicLayout() { {open ? (
) : null} + + {!mobileDevice && active ? ( +
+

+ {walletText("walletDesktopHelpTitle", kind)} +

+
    +
  1. {t("walletDesktopHelpUnlock")}
  2. +
  3. {t("walletDesktopHelpSelect")}
  4. +
  5. {walletText("walletDesktopHelpRetry", kind)}
  6. +
+
+ + +
+
+ ) : null}
); })} From 90f27b050ce54607700dc058e1cfccadb1189ce2 Mon Sep 17 00:00:00 2001 From: TerryM Date: Thu, 4 Jun 2026 11:36:28 +0800 Subject: [PATCH 55/89] i18n: translate wallet desktop guidance --- src/locales/id.ts | 11 +++++++++++ src/locales/ja.ts | 11 +++++++++++ src/locales/ko.ts | 11 +++++++++++ src/locales/ms.ts | 11 +++++++++++ src/locales/vi.ts | 9 +++++++++ 5 files changed, 53 insertions(+) diff --git a/src/locales/id.ts b/src/locales/id.ts index cf67642..1c3a3b1 100644 --- a/src/locales/id.ts +++ b/src/locales/id.ts @@ -208,6 +208,15 @@ export const idDict: Dict = { "Jika tidak ada dompet terbuka setelah diklik, pastikan ekstensi browser yang sesuai sudah terpasang dan diaktifkan.", walletInstallSelected: "Ekstensi browser {wallet} tidak terdeteksi. Pasang atau aktifkan, lalu coba lagi.", + walletDesktopHelpTitle: "Kami mencoba membuka {wallet}. Langkah berikutnya:", + walletDesktopHelpUnlock: + "Buka ekstensi dompet di toolbar browser dan buka kuncinya.", + walletDesktopHelpSelect: + "Pastikan dompet memiliki akun, lalu pilih akun tersebut.", + walletDesktopHelpRetry: + "Kembali ke sini dan klik “Hubungkan ulang {wallet}”.", + walletReconnectWallet: "Hubungkan ulang {wallet}", + walletInstallWallet: "Pasang ekstensi {wallet}", walletOpen: "Buka", walletQrLogin: "Login QR", walletMobileQrDesc: @@ -232,6 +241,8 @@ export const idDict: Dict = { walletRainbowUnavailable: "Login QR belum tersedia.", walletLoginSuccess: "Dompet terhubung", walletLoginFailed: "Login dompet gagal", + walletNoAccount: + "Dompet tidak mengembalikan akun apa pun. Buka kunci dompet, pilih akun, lalu coba lagi.", walletDisconnected: "Dompet terputus", walletOtherMethods: "Metode login lainnya", walletUseCurrent: "Gunakan dompet saat ini", diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 62987d1..1a3c01d 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -200,6 +200,8 @@ export const jaDict: Dict = { walletRainbowUnavailable: "QR ログインは現在利用できません。", walletLoginSuccess: "ウォレットを接続しました", walletLoginFailed: "ウォレットログインに失敗しました", + walletNoAccount: + "ウォレットからアカウントが返されませんでした。ウォレットのロックを解除してアカウントを選択し、もう一度お試しください。", walletDisconnected: "ウォレットを切断しました", walletNoBrowserWallet: "ブラウザウォレットが見つかりません", walletNoBrowserWalletDesc: @@ -229,6 +231,15 @@ export const jaDict: Dict = { "クリックしてもウォレットが開かない場合は、対応するブラウザ拡張機能がインストールされ有効になっているか確認してください。", walletInstallSelected: "{wallet} のブラウザ拡張機能が検出されません。インストールまたは有効にしてから再試行してください。", + walletDesktopHelpTitle: + "{wallet} を開こうとしました。次の手順を行ってください:", + walletDesktopHelpUnlock: + "ブラウザのツールバーからウォレット拡張機能を開き、ロックを解除します。", + walletDesktopHelpSelect: + "ウォレットにアカウントがあることを確認し、そのアカウントを選択します。", + walletDesktopHelpRetry: "ここに戻って「{wallet} に再接続」をクリックします。", + walletReconnectWallet: "{wallet} に再接続", + walletInstallWallet: "{wallet} 拡張機能をインストール", walletOpen: "開く", walletQrLogin: "QR ログイン", walletMobileQrDesc: diff --git a/src/locales/ko.ts b/src/locales/ko.ts index b59432c..39625e0 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -205,6 +205,15 @@ export const koDict: Dict = { "클릭 후 지갑이 열리지 않으면 해당 브라우저 확장 프로그램이 설치되고 활성화되어 있는지 확인하세요.", walletInstallSelected: "{wallet} 브라우저 확장 프로그램을 찾을 수 없습니다. 설치하거나 활성화한 후 다시 시도하세요.", + walletDesktopHelpTitle: + "{wallet} 열기를 시도했습니다. 다음 단계를 진행하세요:", + walletDesktopHelpUnlock: + "브라우저 툴바에서 지갑 확장 프로그램을 열고 잠금을 해제하세요.", + walletDesktopHelpSelect: + "지갑에 계정이 있는지 확인한 뒤 해당 계정을 선택하세요.", + walletDesktopHelpRetry: "여기로 돌아와 ‘{wallet} 다시 연결’을 클릭하세요.", + walletReconnectWallet: "{wallet} 다시 연결", + walletInstallWallet: "{wallet} 확장 프로그램 설치", walletOpen: "열기", walletQrLogin: "QR 로그인", walletMobileQrDesc: @@ -229,6 +238,8 @@ export const koDict: Dict = { walletRainbowUnavailable: "QR 로그인을 사용할 수 없습니다.", walletLoginSuccess: "지갑이 연결되었습니다", walletLoginFailed: "지갑 로그인에 실패했습니다", + walletNoAccount: + "지갑에서 계정이 반환되지 않았습니다. 지갑 잠금을 해제하고 계정을 선택한 후 다시 시도하세요.", walletDisconnected: "지갑 연결이 해제되었습니다", walletOtherMethods: "다른 로그인 방법", walletUseCurrent: "현재 지갑 사용", diff --git a/src/locales/ms.ts b/src/locales/ms.ts index f7156e1..df64b8f 100644 --- a/src/locales/ms.ts +++ b/src/locales/ms.ts @@ -206,6 +206,15 @@ export const msDict: Dict = { "Jika tiada dompet terbuka selepas klik, pastikan sambungan pelayar yang sepadan telah dipasang dan diaktifkan.", walletInstallSelected: "Tiada sambungan pelayar {wallet} dikesan. Pasang atau aktifkannya, kemudian cuba lagi.", + walletDesktopHelpTitle: + "Kami telah cuba membuka {wallet}. Langkah seterusnya:", + walletDesktopHelpUnlock: + "Buka sambungan dompet pada bar alat pelayar dan nyahkuncinya.", + walletDesktopHelpSelect: + "Pastikan dompet mempunyai akaun, kemudian pilih akaun tersebut.", + walletDesktopHelpRetry: "Kembali ke sini dan klik “Sambung semula {wallet}”.", + walletReconnectWallet: "Sambung semula {wallet}", + walletInstallWallet: "Pasang sambungan {wallet}", walletOpen: "Buka", walletQrLogin: "Log masuk QR", walletMobileQrDesc: @@ -230,6 +239,8 @@ export const msDict: Dict = { walletRainbowUnavailable: "Log masuk QR belum tersedia.", walletLoginSuccess: "Dompet disambungkan", walletLoginFailed: "Log masuk dompet gagal", + walletNoAccount: + "Dompet tidak mengembalikan sebarang akaun. Nyahkunci dompet, pilih akaun, kemudian cuba lagi.", walletDisconnected: "Dompet diputuskan", walletOtherMethods: "Kaedah log masuk lain", walletUseCurrent: "Guna dompet semasa", diff --git a/src/locales/vi.ts b/src/locales/vi.ts index db38863..d22a954 100644 --- a/src/locales/vi.ts +++ b/src/locales/vi.ts @@ -204,6 +204,13 @@ export const viDict: Dict = { "Nếu không có ví nào mở sau khi nhấn, hãy đảm bảo tiện ích mở rộng tương ứng đã được cài đặt và bật.", walletInstallSelected: "Không tìm thấy tiện ích mở rộng {wallet}. Hãy cài đặt hoặc bật nó, rồi thử lại.", + walletDesktopHelpTitle: "Chúng tôi đã thử mở {wallet}. Các bước tiếp theo:", + walletDesktopHelpUnlock: + "Mở tiện ích ví trên thanh công cụ trình duyệt và mở khóa ví.", + walletDesktopHelpSelect: "Đảm bảo ví có tài khoản, sau đó chọn tài khoản đó.", + walletDesktopHelpRetry: "Quay lại đây và nhấn “Kết nối lại {wallet}”.", + walletReconnectWallet: "Kết nối lại {wallet}", + walletInstallWallet: "Cài tiện ích {wallet}", walletOpen: "Mở", walletQrLogin: "Đăng nhập QR", walletMobileQrDesc: @@ -228,6 +235,8 @@ export const viDict: Dict = { walletRainbowUnavailable: "Đăng nhập QR chưa khả dụng.", walletLoginSuccess: "Đã kết nối ví", walletLoginFailed: "Đăng nhập ví thất bại", + walletNoAccount: + "Ví không trả về tài khoản nào. Hãy mở khóa ví, chọn một tài khoản rồi thử lại.", walletDisconnected: "Đã ngắt kết nối ví", walletOtherMethods: "Phương thức đăng nhập khác", walletUseCurrent: "Dùng ví hiện tại", From 4059ec3f207e9dca87a701347736cf8ff0cf9157 Mon Sep 17 00:00:00 2001 From: TerryM Date: Thu, 4 Jun 2026 11:55:13 +0800 Subject: [PATCH 56/89] feat: confirm desktop wallet address --- src/locales/en.ts | 8 ++ src/locales/id.ts | 8 ++ src/locales/ja.ts | 9 +++ src/locales/ko.ts | 8 ++ src/locales/ms.ts | 9 +++ src/locales/vi.ts | 9 +++ src/locales/zh-CN.ts | 7 ++ src/wallet/WalletLoginModal.tsx | 139 +++++++++++++++++++++++++------- src/wallet/deepLinks.ts | 2 +- 9 files changed, 171 insertions(+), 28 deletions(-) diff --git a/src/locales/en.ts b/src/locales/en.ts index 7d5c2db..c1eaf7d 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -215,6 +215,14 @@ export const enDict: Dict = { walletDesktopHelpRetry: "Come back here and click “Reconnect {wallet}”.", walletReconnectWallet: "Reconnect {wallet}", walletInstallWallet: "Install {wallet} extension", + walletConfirmAddressTitle: "Log in with this {wallet} address?", + walletConfirmAddressDesc: + "Confirm this is the {wallet} address you want to use.", + walletConfirmLogin: "Confirm login", + walletCancelLogin: "Cancel", + walletDesktopImTokenTitle: "Open this site in the mobile {wallet} app", + walletDesktopImTokenDesc: + "{wallet} does not provide a desktop browser extension. Open https://arkie-library-stag.com inside the mobile {wallet} app to log in.", walletOpen: "Open", walletQrLogin: "QR login", walletMobileQrDesc: diff --git a/src/locales/id.ts b/src/locales/id.ts index 1c3a3b1..de412d9 100644 --- a/src/locales/id.ts +++ b/src/locales/id.ts @@ -217,6 +217,14 @@ export const idDict: Dict = { "Kembali ke sini dan klik “Hubungkan ulang {wallet}”.", walletReconnectWallet: "Hubungkan ulang {wallet}", walletInstallWallet: "Pasang ekstensi {wallet}", + walletConfirmAddressTitle: "Masuk dengan alamat {wallet} ini?", + walletConfirmAddressDesc: + "Pastikan ini adalah alamat dompet {wallet} yang ingin Anda gunakan.", + walletConfirmLogin: "Konfirmasi masuk", + walletCancelLogin: "Batal", + walletDesktopImTokenTitle: "Buka situs ini di aplikasi {wallet} seluler", + walletDesktopImTokenDesc: + "{wallet} tidak menyediakan ekstensi browser desktop. Buka https://arkie-library-stag.com di dalam aplikasi {wallet} seluler untuk masuk.", walletOpen: "Buka", walletQrLogin: "Login QR", walletMobileQrDesc: diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 1a3c01d..c2eea0b 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -240,6 +240,15 @@ export const jaDict: Dict = { walletDesktopHelpRetry: "ここに戻って「{wallet} に再接続」をクリックします。", walletReconnectWallet: "{wallet} に再接続", walletInstallWallet: "{wallet} 拡張機能をインストール", + walletConfirmAddressTitle: "この {wallet} アドレスでログインしますか?", + walletConfirmAddressDesc: + "使用する {wallet} ウォレットアドレスであることを確認してください。", + walletConfirmLogin: "ログインを確認", + walletCancelLogin: "キャンセル", + walletDesktopImTokenTitle: + "モバイル {wallet} アプリでこのサイトを開いてください", + walletDesktopImTokenDesc: + "{wallet} にはデスクトップ用ブラウザ拡張機能がありません。モバイル {wallet} アプリ内で https://arkie-library-stag.com を開いてログインしてください。", walletOpen: "開く", walletQrLogin: "QR ログイン", walletMobileQrDesc: diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 39625e0..1c97aa5 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -214,6 +214,14 @@ export const koDict: Dict = { walletDesktopHelpRetry: "여기로 돌아와 ‘{wallet} 다시 연결’을 클릭하세요.", walletReconnectWallet: "{wallet} 다시 연결", walletInstallWallet: "{wallet} 확장 프로그램 설치", + walletConfirmAddressTitle: "이 {wallet} 주소로 로그인할까요?", + walletConfirmAddressDesc: + "사용하려는 {wallet} 지갑 주소가 맞는지 확인하세요.", + walletConfirmLogin: "로그인 확인", + walletCancelLogin: "취소", + walletDesktopImTokenTitle: "모바일 {wallet} 앱에서 이 사이트를 여세요", + walletDesktopImTokenDesc: + "{wallet}은 데스크톱 브라우저 확장 프로그램을 제공하지 않습니다. 모바일 {wallet} 앱 안에서 https://arkie-library-stag.com 을 열어 로그인하세요.", walletOpen: "열기", walletQrLogin: "QR 로그인", walletMobileQrDesc: diff --git a/src/locales/ms.ts b/src/locales/ms.ts index df64b8f..4aad10b 100644 --- a/src/locales/ms.ts +++ b/src/locales/ms.ts @@ -215,6 +215,15 @@ export const msDict: Dict = { walletDesktopHelpRetry: "Kembali ke sini dan klik “Sambung semula {wallet}”.", walletReconnectWallet: "Sambung semula {wallet}", walletInstallWallet: "Pasang sambungan {wallet}", + walletConfirmAddressTitle: "Log masuk dengan alamat {wallet} ini?", + walletConfirmAddressDesc: + "Sahkan ini ialah alamat dompet {wallet} yang ingin anda gunakan.", + walletConfirmLogin: "Sahkan log masuk", + walletCancelLogin: "Batal", + walletDesktopImTokenTitle: + "Buka laman ini dalam aplikasi mudah alih {wallet}", + walletDesktopImTokenDesc: + "{wallet} tidak menyediakan sambungan pelayar desktop. Buka https://arkie-library-stag.com dalam aplikasi mudah alih {wallet} untuk log masuk.", walletOpen: "Buka", walletQrLogin: "Log masuk QR", walletMobileQrDesc: diff --git a/src/locales/vi.ts b/src/locales/vi.ts index d22a954..bdf6bf8 100644 --- a/src/locales/vi.ts +++ b/src/locales/vi.ts @@ -211,6 +211,15 @@ export const viDict: Dict = { walletDesktopHelpRetry: "Quay lại đây và nhấn “Kết nối lại {wallet}”.", walletReconnectWallet: "Kết nối lại {wallet}", walletInstallWallet: "Cài tiện ích {wallet}", + walletConfirmAddressTitle: "Đăng nhập bằng địa chỉ {wallet} này?", + walletConfirmAddressDesc: + "Vui lòng xác nhận đây là địa chỉ ví {wallet} bạn muốn dùng.", + walletConfirmLogin: "Xác nhận đăng nhập", + walletCancelLogin: "Hủy", + walletDesktopImTokenTitle: + "Mở trang này trong ứng dụng {wallet} trên điện thoại", + walletDesktopImTokenDesc: + "{wallet} không có tiện ích mở rộng trình duyệt cho máy tính. Hãy mở https://arkie-library-stag.com trong ứng dụng {wallet} trên điện thoại để đăng nhập.", walletOpen: "Mở", walletQrLogin: "Đăng nhập QR", walletMobileQrDesc: diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 5a235e0..c54ba02 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -200,6 +200,13 @@ export const zhDict: Dict = { walletDesktopHelpRetry: "回到这里点击“重新连接 {wallet}”。", walletReconnectWallet: "重新连接 {wallet}", walletInstallWallet: "安装 {wallet} 插件", + walletConfirmAddressTitle: "确认使用这个 {wallet} 地址登录?", + walletConfirmAddressDesc: "请确认这是你要使用的 {wallet} 钱包地址。", + walletConfirmLogin: "确认登录", + walletCancelLogin: "取消", + walletDesktopImTokenTitle: "请用手机 {wallet} App 打开本站", + walletDesktopImTokenDesc: + "{wallet} 没有电脑浏览器插件。请在手机 {wallet} App 内打开 https://arkie-library-stag.com 后登录。", walletOpen: "打开", walletQrLogin: "扫码登录", walletMobileQrDesc: "适合用另一台设备扫描二维码登录当前浏览器。", diff --git a/src/wallet/WalletLoginModal.tsx b/src/wallet/WalletLoginModal.tsx index 6601775..bc269e2 100644 --- a/src/wallet/WalletLoginModal.tsx +++ b/src/wallet/WalletLoginModal.tsx @@ -16,6 +16,10 @@ function supportsDirectPull(kind: WalletKind): boolean { return kind === "tokenPocket" || kind === "imToken"; } +function supportsDesktopExtension(kind: WalletKind): boolean { + return kind === "tokenPocket"; +} + function buildAutoLoginDappUrl(kind: WalletKind): string { const url = new URL(window.location.href); url.searchParams.set(AUTO_LOGIN_PARAM, kind); @@ -26,6 +30,11 @@ const wallets: WalletKind[] = ["tokenPocket", "imToken"]; type LoginState = "idle" | "connecting"; +type PendingLogin = { + kind: WalletKind; + address: string; +}; + type Translate = (key: string) => string; function walletErrorMessage(error: unknown, t: Translate): string { @@ -47,6 +56,7 @@ export function WalletLoginModal() { const [mobileDevice, setMobileDevice] = useState(false); const [state, setState] = useState("idle"); const [error, setError] = useState(""); + const [pendingLogin, setPendingLogin] = useState(null); useEffect(() => { if (!loginModalOpen) return; @@ -54,13 +64,14 @@ export function WalletLoginModal() { setSelected(null); setState("idle"); setError(""); + setPendingLogin(null); }, [loginModalOpen]); if (!loginModalOpen) return null; const walletName = (kind: WalletKind) => t(walletNameKey(kind)); const walletText = (key: string, kind: WalletKind) => - t(key).replace("{wallet}", walletName(kind)); + t(key).replaceAll("{wallet}", walletName(kind)); const walletHint = () => mobileDevice ? t("walletChooseMobile") : t("walletDesktopHint"); const busy = state !== "idle"; @@ -70,27 +81,49 @@ export function WalletLoginModal() { setSelected(null); setState("idle"); setError(""); + setPendingLogin(null); }; const selectWallet = (kind: WalletKind) => { setSelected(kind); setError(""); + setPendingLogin(null); }; const loginInjected = async (kind: WalletKind) => { setSelected(kind); setState("connecting"); setError(""); + setPendingLogin(null); try { const address = await connectInjectedWallet(kind); - completeLogin(localWalletToken(address), address); + setPendingLogin({ kind, address }); + setState("idle"); } catch (err) { setState("idle"); setError(walletErrorMessage(err, t)); } }; + const confirmPendingLogin = () => { + if (!pendingLogin) return; + completeLogin(localWalletToken(pendingLogin.address), pendingLogin.address); + }; + + const cancelPendingLogin = () => { + setPendingLogin(null); + setSelected(null); + setState("idle"); + setError(""); + }; + const openWalletAppDirect = (kind: WalletKind) => { + if (!mobileDevice && !supportsDesktopExtension(kind)) { + setSelected(kind); + setPendingLogin(null); + setError(""); + return; + } if (getInjectedWallet(kind)) { void loginInjected(kind); return; @@ -103,6 +136,7 @@ export function WalletLoginModal() { return; } setSelected(kind); + setPendingLogin(null); setError(walletText("walletInstallSelected", kind)); }; @@ -192,31 +226,82 @@ export function WalletLoginModal() { {!mobileDevice && active ? (
-

- {walletText("walletDesktopHelpTitle", kind)} -

-
    -
  1. {t("walletDesktopHelpUnlock")}
  2. -
  3. {t("walletDesktopHelpSelect")}
  4. -
  5. {walletText("walletDesktopHelpRetry", kind)}
  6. -
-
- - -
+ {pendingLogin?.kind === kind ? ( + <> +

+ {walletText("walletConfirmAddressTitle", kind)} +

+

+ {walletText("walletConfirmAddressDesc", kind)} +

+

+ {pendingLogin.address} +

+
+ + +
+ + ) : supportsDesktopExtension(kind) ? ( + <> +

+ {walletText("walletDesktopHelpTitle", kind)} +

+
    +
  1. {t("walletDesktopHelpUnlock")}
  2. +
  3. {t("walletDesktopHelpSelect")}
  4. +
  5. {walletText("walletDesktopHelpRetry", kind)}
  6. +
+
+ + +
+ + ) : ( + <> +

+ {walletText("walletDesktopImTokenTitle", kind)} +

+

+ {walletText("walletDesktopImTokenDesc", kind)} +

+ + + )}
) : null}
diff --git a/src/wallet/deepLinks.ts b/src/wallet/deepLinks.ts index 08bdc35..215f208 100644 --- a/src/wallet/deepLinks.ts +++ b/src/wallet/deepLinks.ts @@ -31,7 +31,7 @@ export function openWalletDeepLink(kind: WalletKind): void { } const downloadUrls: Record = { - tokenPocket: "https://www.tokenpocket.pro/en/download/app", + tokenPocket: "https://extension.tokenpocket.pro/", metaMask: "https://metamask.io/download/", imToken: "https://token.im/download", }; From 53dc35e7dc2e0d71487208d237ca84d36c7f551c Mon Sep 17 00:00:00 2001 From: TerryM Date: Thu, 4 Jun 2026 15:19:50 +0800 Subject: [PATCH 57/89] fix: restore mobile wallet login feedback --- src/wallet/AutoInjectedLogin.tsx | 14 +++++++++++--- src/wallet/WalletLoginModal.tsx | 6 +++++- src/wallet/WalletProvider.tsx | 19 +++++++++++-------- src/wallet/injected.ts | 9 +++++++++ 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/wallet/AutoInjectedLogin.tsx b/src/wallet/AutoInjectedLogin.tsx index 2b1abda..c3e8ab0 100644 --- a/src/wallet/AutoInjectedLogin.tsx +++ b/src/wallet/AutoInjectedLogin.tsx @@ -6,7 +6,7 @@ import { } from "./injected"; import { localWalletToken, useWallet } from "./WalletProvider"; -const AUTO_LOGIN_PARAM = "autoLogin"; +const AUTO_LOGIN_PARAMS = ["autoLogin", "autologin"]; const ETHEREUM_WAIT_MS = 8000; const ETHEREUM_POLL_MS = 200; @@ -17,9 +17,17 @@ function parseKind(value: string | null): WalletKind | null { return null; } +function autoLoginKindFromParams(params: URLSearchParams): WalletKind | null { + for (const key of AUTO_LOGIN_PARAMS) { + const kind = parseKind(params.get(key)); + if (kind) return kind; + } + return null; +} + function stripAutoLoginParam(): void { const url = new URL(window.location.href); - url.searchParams.delete(AUTO_LOGIN_PARAM); + for (const key of AUTO_LOGIN_PARAMS) url.searchParams.delete(key); const qs = url.searchParams.toString(); const next = url.pathname + (qs ? `?${qs}` : "") + url.hash; window.history.replaceState({}, "", next); @@ -49,7 +57,7 @@ export function AutoInjectedLogin() { useEffect(() => { if (typeof window === "undefined") return; const params = new URLSearchParams(window.location.search); - const kind = parseKind(params.get(AUTO_LOGIN_PARAM)); + const kind = autoLoginKindFromParams(params); if (!kind) return; stripAutoLoginParam(); diff --git a/src/wallet/WalletLoginModal.tsx b/src/wallet/WalletLoginModal.tsx index bc269e2..db2a2a5 100644 --- a/src/wallet/WalletLoginModal.tsx +++ b/src/wallet/WalletLoginModal.tsx @@ -10,7 +10,7 @@ import { import { localWalletToken, useWallet } from "./WalletProvider"; import { WalletBrandIcon } from "./WalletBrandIcon"; -const AUTO_LOGIN_PARAM = "autoLogin"; +const AUTO_LOGIN_PARAM = "autologin"; function supportsDirectPull(kind: WalletKind): boolean { return kind === "tokenPocket" || kind === "imToken"; @@ -97,6 +97,10 @@ export function WalletLoginModal() { setPendingLogin(null); try { const address = await connectInjectedWallet(kind); + if (mobileDevice) { + completeLogin(localWalletToken(address), address); + return; + } setPendingLogin({ kind, address }); setState("idle"); } catch (err) { diff --git a/src/wallet/WalletProvider.tsx b/src/wallet/WalletProvider.tsx index e20aa25..7e07755 100644 --- a/src/wallet/WalletProvider.tsx +++ b/src/wallet/WalletProvider.tsx @@ -98,20 +98,23 @@ export function WalletProvider({ children }: { children: ReactNode }) { }; }, [token]); - const completeLogin = useCallback((nextToken: string, wallet: string) => { - writeWalletToken(nextToken); - setToken(nextToken); - setAddress(wallet); - setStatus("loggedIn"); - setLoginModalOpen(false); - }, []); + const completeLogin = useCallback( + (nextToken: string, wallet: string) => { + writeWalletToken(nextToken); + setToken(nextToken); + setAddress(wallet); + setStatus("loggedIn"); + setLoginModalOpen(false); + showToast(t("walletLoginSuccess")); + }, + [showToast, t], + ); const signInInjected = useCallback( async (kind?: WalletKind) => { try { const res = await signInWithInjectedWallet(kind); completeLogin(res.token, res.wallet); - showToast(t("walletLoginSuccess")); } catch (error) { showToast(walletErrorMessage(error, t), "error"); throw error; diff --git a/src/wallet/injected.ts b/src/wallet/injected.ts index e1fa866..ed111c9 100644 --- a/src/wallet/injected.ts +++ b/src/wallet/injected.ts @@ -137,6 +137,11 @@ export function getInjectedEthereum(): EthereumProvider | null { return maybeWindow.ethereum ?? null; } +export function isTokenPocketBrowser(): boolean { + if (typeof navigator === "undefined") return false; + return /tokenpocket|tpwallet/i.test(navigator.userAgent || ""); +} + export function isImTokenBrowser(): boolean { if (typeof navigator === "undefined") return false; return /imtoken/i.test(navigator.userAgent || ""); @@ -156,6 +161,10 @@ export function getInjectedWallet(kind?: WalletKind): EthereumProvider | null { }); if (match) return match; + if (kind === "tokenPocket" && isTokenPocketBrowser()) { + return providers[0] ?? ethereum; + } + if (kind === "imToken" && isImTokenBrowser()) return providers[0] ?? ethereum; return null; From fd19ed438e3a76827f588a5eeaee51260aeaeb88 Mon Sep 17 00:00:00 2001 From: TerryM Date: Thu, 4 Jun 2026 15:29:22 +0800 Subject: [PATCH 58/89] fix: hide imtoken on desktop wallet login --- src/wallet/WalletLoginModal.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/wallet/WalletLoginModal.tsx b/src/wallet/WalletLoginModal.tsx index db2a2a5..0ab69b9 100644 --- a/src/wallet/WalletLoginModal.tsx +++ b/src/wallet/WalletLoginModal.tsx @@ -53,7 +53,7 @@ export function WalletLoginModal() { const { t } = useI18n(); const { closeLoginModal, completeLogin, loginModalOpen } = useWallet(); const [selected, setSelected] = useState(null); - const [mobileDevice, setMobileDevice] = useState(false); + const [mobileDevice, setMobileDevice] = useState(() => isMobileDevice()); const [state, setState] = useState("idle"); const [error, setError] = useState(""); const [pendingLogin, setPendingLogin] = useState(null); @@ -74,6 +74,9 @@ export function WalletLoginModal() { t(key).replaceAll("{wallet}", walletName(kind)); const walletHint = () => mobileDevice ? t("walletChooseMobile") : t("walletDesktopHint"); + const visibleWallets = mobileDevice + ? wallets + : wallets.filter(supportsDesktopExtension); const busy = state !== "idle"; const close = () => { @@ -179,7 +182,7 @@ export function WalletLoginModal() {
- {wallets.map((kind) => { + {visibleWallets.map((kind) => { const active = selected === kind; const connecting = active && state === "connecting"; return ( From 01eab88c0f3048975f5b8e4765c1dcf861a4febb Mon Sep 17 00:00:00 2001 From: TerryM Date: Thu, 4 Jun 2026 17:06:29 +0800 Subject: [PATCH 59/89] feat: connect wallet favorites to backend --- ...desktop-favorites-header-blank-page-fix.md | 39 +++ README.md | 4 +- ...ckend-wallet-favorites-production-fixes.md | 313 ++++++++++++++++++ src/components/ScrollToTop.tsx | 11 +- src/favorites/api.ts | 53 +-- src/index.css | 14 +- src/layouts/PublicLayout.tsx | 22 +- src/pages/Favorites/index.tsx | 7 +- src/wallet/AutoInjectedLogin.tsx | 6 +- src/wallet/WalletLoginModal.tsx | 17 +- src/wallet/WalletProvider.tsx | 32 +- src/wallet/api.ts | 21 +- src/wallet/injected.ts | 74 +---- src/wallet/useWalletConnectLogin.ts | 38 ++- 14 files changed, 479 insertions(+), 172 deletions(-) create mode 100644 .unipi/docs/fix/2026-06-04-desktop-favorites-header-blank-page-fix.md create mode 100644 docs/backend-wallet-favorites-production-fixes.md diff --git a/.unipi/docs/fix/2026-06-04-desktop-favorites-header-blank-page-fix.md b/.unipi/docs/fix/2026-06-04-desktop-favorites-header-blank-page-fix.md new file mode 100644 index 0000000..e6c0288 --- /dev/null +++ b/.unipi/docs/fix/2026-06-04-desktop-favorites-header-blank-page-fix.md @@ -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 `` 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 `
` 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. diff --git a/README.md b/README.md index ccbc9ea..92ccba1 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,7 @@ Create a local `.env` only when needed. Do not commit secrets. See `.env.example ## Wallet login notes -Wallet login is used to verify address ownership for user favorites. The primary stable paths are injected wallets (`window.ethereum`) and TokenPocket QR callback login. MetaMask/imToken QR login is a RainbowKit/Reown fallback and may be unstable on some China networks. - -The current frontend stores the wallet JWT in `localStorage` as a simple MVP session mechanism. This keeps the implementation small, but any future XSS vulnerability could expose a 30-day 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. +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 diff --git a/docs/backend-wallet-favorites-production-fixes.md b/docs/backend-wallet-favorites-production-fixes.md new file mode 100644 index 0000000..990330b --- /dev/null +++ b/docs/backend-wallet-favorites-production-fixes.md @@ -0,0 +1,313 @@ +# 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 ` +- 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": "", + "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 should be made explicit. + +### Favorites list + +```http +GET /api/favorites?lang=&limit=&page=&sort=&category=&q= +Authorization: Bearer +``` + +Current staging response observed: + +```json +{ + "items": [ + { + "id": "...", + "postType": "image", + "categoryId": 11, + "categorySlug": "official-assets", + "language": "zh", + "title": "..." + } + ] +} +``` + +Recommended production response: + +```json +{ + "items": [], + "page": 1, + "limit": 24, + "total": 0 +} +``` + +`page`, `limit`, and `total` are needed for correct pagination. + +### Favorite status by ids + +```http +GET /api/favorites?ids=id1,id2,id3 +Authorization: Bearer +``` + +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. diff --git a/src/components/ScrollToTop.tsx b/src/components/ScrollToTop.tsx index 89d5ca4..cd1f35b 100644 --- a/src/components/ScrollToTop.tsx +++ b/src/components/ScrollToTop.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useLayoutEffect, useRef } from "react"; import { useLocation } from "react-router-dom"; /** @@ -23,6 +23,15 @@ export function ScrollToTop() { const prevPathname = useRef(pathname); useEffect(() => { + if (!("scrollRestoration" in window.history)) return; + const previous = window.history.scrollRestoration; + window.history.scrollRestoration = "manual"; + return () => { + window.history.scrollRestoration = previous; + }; + }, []); + + useLayoutEffect(() => { const pathnameChanged = prevPathname.current !== pathname; prevPathname.current = pathname; diff --git a/src/favorites/api.ts b/src/favorites/api.ts index a25f47d..6f29b00 100644 --- a/src/favorites/api.ts +++ b/src/favorites/api.ts @@ -1,17 +1,12 @@ -import { apiBase, type Resource } from "../api"; +import { apiBase, itemsOrEmpty, type Resource } from "../api"; export type FavoriteSort = "favorited_at" | "published_at" | "hot"; -export type FavoriteItem = { - favoritedAt: string; - resource: Resource; -}; - export type FavoriteListResponse = { - items: FavoriteItem[]; - page: number; - limit: number; - total: number; + items: Resource[]; + page?: number; + limit?: number; + total?: number; }; export type FavoriteIdsResponse = { @@ -20,16 +15,24 @@ export type FavoriteIdsResponse = { export type FavoriteMutationResponse = { ok: boolean; - resourceId: string; - favorited: boolean; + changed?: boolean; + resourceId?: string; + favorited?: boolean; favoritedAt?: string; - favoriteCount: number; + favoriteCount?: number; }; function authHeaders(token: string): HeadersInit { return { Authorization: `Bearer ${token}` }; } +function authJSONHeaders(token: string): HeadersInit { + return { + ...authHeaders(token), + "Content-Type": "application/json", + }; +} + /** HTTP error that preserves the status code so callers can react to 401s. */ export class FavoriteHttpError extends Error { readonly status: number; @@ -68,7 +71,7 @@ export async function listFavorites( sp.set(key, String(value)); }); const suffix = sp.toString() ? `?${sp}` : ""; - const res = await fetch(`${apiBase}/api/me/favorites${suffix}`, { + const res = await fetch(`${apiBase}/api/favorites${suffix}`, { headers: authHeaders(token), }); return parseJSON(res); @@ -81,22 +84,23 @@ export async function getFavoriteIds( if (resourceIds.length === 0) return []; const uniqueIds = [...new Set(resourceIds)].slice(0, 100); const res = await fetch( - `${apiBase}/api/me/favorites/ids?resourceIds=${encodeURIComponent( - uniqueIds.join(","), - )}`, + `${apiBase}/api/favorites?ids=${encodeURIComponent(uniqueIds.join(","))}`, { headers: authHeaders(token) }, ); - const data = await parseJSON(res); - return data.ids; + const data = await parseJSON(res); + if ("ids" in data && Array.isArray(data.ids)) return data.ids; + if ("items" in data) return itemsOrEmpty(data.items).map((item) => item.id); + return []; } export async function addFavorite( token: string, resourceId: string, ): Promise { - const res = await fetch(`${apiBase}/api/me/favorites/${resourceId}`, { + const res = await fetch(`${apiBase}/api/posts/${resourceId}/favorite`, { method: "POST", - headers: authHeaders(token), + headers: authJSONHeaders(token), + body: JSON.stringify({ add: true }), }); return parseJSON(res); } @@ -105,9 +109,10 @@ export async function removeFavorite( token: string, resourceId: string, ): Promise { - const res = await fetch(`${apiBase}/api/me/favorites/${resourceId}`, { - method: "DELETE", - headers: authHeaders(token), + const res = await fetch(`${apiBase}/api/posts/${resourceId}/favorite`, { + method: "POST", + headers: authJSONHeaders(token), + body: JSON.stringify({ add: false }), }); return parseJSON(res); } diff --git a/src/index.css b/src/index.css index 750839e..6c804cc 100644 --- a/src/index.css +++ b/src/index.css @@ -50,20 +50,12 @@ header button { } } -/* Desktop header nav: thin scrollbar when links overflow (still 單列) */ .header-nav-scroll { - scrollbar-width: thin; - scrollbar-color: rgba(238, 183, 38, 0.45) transparent; + -ms-overflow-style: none; + scrollbar-width: none; } .header-nav-scroll::-webkit-scrollbar { - height: 4px; -} -.header-nav-scroll::-webkit-scrollbar-thumb { - background-color: rgba(238, 183, 38, 0.45); - border-radius: 9999px; -} -.header-nav-scroll::-webkit-scrollbar-track { - background: transparent; + display: none; } .gold-underline { diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index ce2f5cc..3ff1ce1 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -1,4 +1,10 @@ -import { ChevronDown, Menu, Search as SearchIcon, X } from "lucide-react"; +import { + ChevronDown, + Heart, + Menu, + Search as SearchIcon, + X, +} from "lucide-react"; import { AnimatePresence, m } from "framer-motion"; import { useEffect, useRef, useState } from "react"; import { Link, useLocation, useNavigate, useOutlet } from "react-router-dom"; @@ -690,6 +696,20 @@ export function PublicLayout() { ariaLabel={t("langLabel")} className="hidden h-10 w-36 md:block lg:w-40" /> + window.scrollTo({ top: 0, left: 0 })} + className={`hidden h-10 shrink-0 items-center justify-center gap-2 rounded-full border px-3 text-sm font-bold outline-none transition focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg md:inline-flex ${ + na("favorites") + ? "border-ark-gold bg-ark-gold text-black" + : "border-ark-line bg-[#1a1b20] text-neutral-200 hover:border-ark-gold/50 hover:text-ark-gold" + }`} + aria-current={na("favorites") ? "page" : undefined} + > + + {t("favorites")} +
diff --git a/src/pages/Favorites/index.tsx b/src/pages/Favorites/index.tsx index b8dcb06..5a9dcea 100644 --- a/src/pages/Favorites/index.tsx +++ b/src/pages/Favorites/index.tsx @@ -163,13 +163,12 @@ export default function Favorites() { page, limit: pageSize, includeUnavailable: true, - lang: langQuery(lang), }) .then((data) => { if (cancelled) return; - const resources = itemsOrEmpty(data.items).map((item) => item.resource); + const resources = itemsOrEmpty(data.items); setItems(resources); - setTotal(data.total); + setTotal(data.total ?? resources.length); resources.forEach((resource) => markFavorite(resource.id, true)); }) .catch((err) => { @@ -187,7 +186,7 @@ export default function Favorites() { return () => { cancelled = true; }; - }, [category, lang, markFavorite, page, query, reloadKey, sort, t, wallet]); + }, [category, markFavorite, page, query, reloadKey, sort, t, wallet]); const totalPages = Math.max(1, Math.ceil(total / pageSize)); const hasFilters = Boolean(category || query || sort !== "favorited_at"); diff --git a/src/wallet/AutoInjectedLogin.tsx b/src/wallet/AutoInjectedLogin.tsx index c3e8ab0..4191d6d 100644 --- a/src/wallet/AutoInjectedLogin.tsx +++ b/src/wallet/AutoInjectedLogin.tsx @@ -4,7 +4,7 @@ import { getInjectedWallet, type WalletKind, } from "./injected"; -import { localWalletToken, useWallet } from "./WalletProvider"; +import { useWallet } from "./WalletProvider"; const AUTO_LOGIN_PARAMS = ["autoLogin", "autologin"]; const ETHEREUM_WAIT_MS = 8000; @@ -52,7 +52,7 @@ function waitForInjected(kind: WalletKind): Promise { } export function AutoInjectedLogin() { - const { completeLogin, status } = useWallet(); + const { loginAddress, status } = useWallet(); useEffect(() => { if (typeof window === "undefined") return; @@ -69,7 +69,7 @@ export function AutoInjectedLogin() { try { const address = await connectInjectedWallet(kind); if (cancelled) return; - completeLogin(localWalletToken(address), address); + await loginAddress(address); } catch (err) { console.warn("[wallet-autologin] failed", err); } diff --git a/src/wallet/WalletLoginModal.tsx b/src/wallet/WalletLoginModal.tsx index 0ab69b9..479118c 100644 --- a/src/wallet/WalletLoginModal.tsx +++ b/src/wallet/WalletLoginModal.tsx @@ -7,7 +7,7 @@ import { getInjectedWallet, type WalletKind, } from "./injected"; -import { localWalletToken, useWallet } from "./WalletProvider"; +import { useWallet } from "./WalletProvider"; import { WalletBrandIcon } from "./WalletBrandIcon"; const AUTO_LOGIN_PARAM = "autologin"; @@ -51,7 +51,7 @@ function isMobileDevice(): boolean { export function WalletLoginModal() { const { t } = useI18n(); - const { closeLoginModal, completeLogin, loginModalOpen } = useWallet(); + const { closeLoginModal, loginAddress, loginModalOpen } = useWallet(); const [selected, setSelected] = useState(null); const [mobileDevice, setMobileDevice] = useState(() => isMobileDevice()); const [state, setState] = useState("idle"); @@ -101,7 +101,7 @@ export function WalletLoginModal() { try { const address = await connectInjectedWallet(kind); if (mobileDevice) { - completeLogin(localWalletToken(address), address); + await loginAddress(address); return; } setPendingLogin({ kind, address }); @@ -112,9 +112,16 @@ export function WalletLoginModal() { } }; - const confirmPendingLogin = () => { + const confirmPendingLogin = async () => { if (!pendingLogin) return; - completeLogin(localWalletToken(pendingLogin.address), pendingLogin.address); + setState("connecting"); + setError(""); + try { + await loginAddress(pendingLogin.address); + } catch (err) { + setState("idle"); + setError(walletErrorMessage(err, t)); + } }; const cancelPendingLogin = () => { diff --git a/src/wallet/WalletProvider.tsx b/src/wallet/WalletProvider.tsx index 7e07755..480afa7 100644 --- a/src/wallet/WalletProvider.tsx +++ b/src/wallet/WalletProvider.tsx @@ -9,7 +9,7 @@ import { } from "react"; import { useToast } from "../components/Toast"; import { useI18n } from "../i18n"; -import { fetchWalletMe } from "./api"; +import { fetchWalletMe, loginWithWallet } from "./api"; import { signInWithInjectedWallet, type WalletKind } from "./injected"; import { clearWalletToken, readWalletToken, writeWalletToken } from "./token"; @@ -22,18 +22,6 @@ function walletErrorMessage(error: unknown, t: Translate): string { return t(error.message) || t("walletLoginFailed"); } -const localWalletTokenPrefix = "local-wallet:"; - -export function localWalletToken(wallet: string): string { - return `${localWalletTokenPrefix}${wallet}`; -} - -function walletFromLocalToken(token: string): string | null { - return token.startsWith(localWalletTokenPrefix) - ? token.slice(localWalletTokenPrefix.length) - : null; -} - type WalletContextValue = { address: string | null; token: string | null; @@ -43,6 +31,7 @@ type WalletContextValue = { closeLoginModal: () => void; signInInjected: (kind?: WalletKind) => Promise; completeLogin: (token: string, wallet: string) => void; + loginAddress: (address: string) => Promise; logout: () => void; }; @@ -71,13 +60,6 @@ export function WalletProvider({ children }: { children: ReactNode }) { return; } - const localWallet = walletFromLocalToken(token); - if (localWallet) { - setAddress(localWallet); - setStatus("loggedIn"); - return; - } - setStatus("loading"); fetchWalletMe(token) .then((me) => { @@ -110,6 +92,14 @@ export function WalletProvider({ children }: { children: ReactNode }) { [showToast, t], ); + const loginAddress = useCallback( + async (walletAddress: string) => { + const res = await loginWithWallet(walletAddress); + completeLogin(res.token, res.wallet); + }, + [completeLogin], + ); + const signInInjected = useCallback( async (kind?: WalletKind) => { try { @@ -141,12 +131,14 @@ export function WalletProvider({ children }: { children: ReactNode }) { closeLoginModal: () => setLoginModalOpen(false), signInInjected, completeLogin, + loginAddress, logout, }), [ address, completeLogin, loginModalOpen, + loginAddress, logout, signInInjected, status, diff --git a/src/wallet/api.ts b/src/wallet/api.ts index b6c664f..e9a9cd3 100644 --- a/src/wallet/api.ts +++ b/src/wallet/api.ts @@ -1,11 +1,6 @@ import { apiBase, getJSONAuth, postJSON } from "../api"; -export type WalletNonceResponse = { - nonce: string; - message: string; -}; - -export type WalletVerifyResponse = { +export type WalletLoginResponse = { token: string; wallet: string; }; @@ -36,18 +31,8 @@ export type TokenPocketLoginResult = signature: string; }; -export function requestWalletNonce( - address: string, -): Promise { - return postJSON("/api/auth/wallet/nonce", { address }); -} - -export function verifyWalletSignature(params: { - address: string; - message: string; - signature: string; -}): Promise { - return postJSON("/api/auth/wallet/verify", params); +export function loginWithWallet(address: string): Promise { + return postJSON("/api/auth/wallet/login", { address }); } export function fetchWalletMe(token: string): Promise { diff --git a/src/wallet/injected.ts b/src/wallet/injected.ts index ed111c9..f2f75be 100644 --- a/src/wallet/injected.ts +++ b/src/wallet/injected.ts @@ -1,4 +1,4 @@ -import { requestWalletNonce, verifyWalletSignature } from "./api"; +import { loginWithWallet } from "./api"; export type WalletKind = "tokenPocket" | "metaMask" | "imToken"; @@ -26,12 +26,6 @@ function isAddress(value: unknown): value is string { return typeof value === "string" && /^0x[a-fA-F0-9]{40}$/.test(value); } -function utf8ToHex(value: string): string { - return `0x${Array.from(new TextEncoder().encode(value), (byte) => - byte.toString(16).padStart(2, "0"), - ).join("")}`; -} - function errorText(error: unknown): string { if (!error || typeof error !== "object") return String(error ?? ""); const parts: string[] = []; @@ -57,13 +51,6 @@ function normalizeWalletError(error: unknown): Error { return new Error(message || "Wallet login failed"); } -function shouldRetryPersonalSign(error: unknown): boolean { - const text = errorText(error); - return /wallet must has at least one account|wallet must has one account|must have at least one account|invalid params|invalid account|account not found/i.test( - text, - ); -} - async function ensureBnbChain(ethereum: EthereumProvider): Promise { const chainId = await ethereum .request({ method: "eth_chainId" }) @@ -106,31 +93,6 @@ async function requestInjectedAddress( return requestedAddress; } -async function personalSign(params: { - ethereum: EthereumProvider; - message: string; - address: string; -}): Promise { - const { ethereum, message, address } = params; - const hexMessage = utf8ToHex(message); - try { - return await ethereum.request({ - method: "personal_sign", - params: [hexMessage, address], - }); - } catch (error) { - if (!shouldRetryPersonalSign(error)) throw error; - return ethereum - .request({ - method: "personal_sign", - params: [address, hexMessage], - }) - .catch((retryError: unknown): never => { - throw normalizeWalletError(retryError); - }); - } -} - export function getInjectedEthereum(): EthereumProvider | null { if (typeof window === "undefined") return null; const maybeWindow = window as typeof window & { ethereum?: EthereumProvider }; @@ -214,35 +176,9 @@ export async function signInWithInjectedWallet(kind?: WalletKind): Promise<{ token: string; wallet: string; }> { - console.info("[wallet-login] start injected", { kind }); - logWalletProviders(); - const ethereum = getInjectedWallet(kind); - if (!ethereum) { - console.warn("[wallet-login] no injected provider found"); - throw new Error("No injected wallet found"); - } - - console.info("[wallet-login] requesting BNB wallet account…"); - const address = await requestInjectedAddress(ethereum); - console.info("[wallet-login] account", address); - - console.info("[wallet-login] ensuring BNB Chain (0x38)…"); - await ensureBnbChain(ethereum); - - console.info("[wallet-login] requesting nonce for", address); - const nonce = await requestWalletNonce(address); - console.info("[wallet-login] got nonce, requesting personal_sign…"); - const signature = await personalSign({ - ethereum, - message: nonce.message, - address, - }); - console.info("[wallet-login] signed, verifying with backend…"); - const result = await verifyWalletSignature({ - address, - message: nonce.message, - signature, - }); - console.info("[wallet-login] verified, wallet =", result.wallet); + const address = await connectInjectedWallet(kind); + console.info("[wallet-login] requesting backend login for", address); + const result = await loginWithWallet(address); + console.info("[wallet-login] logged in, wallet =", result.wallet); return result; } diff --git a/src/wallet/useWalletConnectLogin.ts b/src/wallet/useWalletConnectLogin.ts index b94da93..6968c8a 100644 --- a/src/wallet/useWalletConnectLogin.ts +++ b/src/wallet/useWalletConnectLogin.ts @@ -7,7 +7,7 @@ import { getInjectedWallet, type WalletKind, } from "./injected"; -import { localWalletToken, useWallet } from "./WalletProvider"; +import { useWallet } from "./WalletProvider"; export type WalletConnectLoginState = "idle" | "connecting" | "signing"; export type WalletConnectLoginMode = "deeplink" | "qr"; @@ -96,7 +96,7 @@ function connectorMatchesWallet( export function useWalletConnectLogin() { const available = hasWalletConnectProjectId(); - const { address: localAddress, completeLogin } = useWallet(); + const { address: localAddress, loginAddress } = useWallet(); const { address: wagmiAddress, isConnected: wagmiConnected } = useAccount(); const { connectAsync, connectors } = useConnect(); const { disconnectAsync } = useDisconnect(); @@ -140,12 +140,17 @@ export function useWalletConnectLogin() { chain: "BNB Chain", chainId: bsc.id, }); - completeLogin(localWalletToken(wagmiAddress), wagmiAddress); - console.info("[wallet-login] local wallet session completed", { - address: wagmiAddress, - }); + void loginAddress(wagmiAddress) + .then(() => { + console.info("[wallet-login] wallet session completed", { + address: wagmiAddress, + }); + }) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : "Wallet login failed"); + }); } - }, [completeLogin, localAddress, wagmiAddress, wagmiConnected]); + }, [localAddress, loginAddress, wagmiAddress, wagmiConnected]); const start = useCallback( async ( @@ -173,7 +178,7 @@ export function useWalletConnectLogin() { chain: "BNB Chain", chainId: bsc.id, }); - completeLogin(localWalletToken(injectedAddress), injectedAddress); + await loginAddress(injectedAddress); setState("idle"); return; } catch (err) { @@ -256,10 +261,17 @@ export function useWalletConnectLogin() { chain: "BNB Chain", chainId: bsc.id, }); - completeLogin(localWalletToken(address), address); - console.info("[wallet-login] local wallet session completed", { - address, - }); + void loginAddress(address) + .then(() => { + console.info("[wallet-login] wallet session completed", { + address, + }); + }) + .catch((err: unknown) => { + setError( + err instanceof Error ? err.message : "Wallet login failed", + ); + }); }; const pollId = window.setInterval(() => { void connector @@ -293,7 +305,7 @@ export function useWalletConnectLogin() { cleanupPollingRef.current = null; } }, - [available, completeLogin, connectAsync, connectors, disconnectAsync], + [available, connectAsync, connectors, disconnectAsync, loginAddress], ); return { From efaf92c4e46ded6a4774a4cc1580cecdcb40acd2 Mon Sep 17 00:00:00 2001 From: TerryM Date: Thu, 4 Jun 2026 17:10:11 +0800 Subject: [PATCH 60/89] fix: limit visible toasts --- src/components/Toast.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 71f79b7..3b4d912 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -32,6 +32,7 @@ export function useToast(): ToastContextValue { } const AUTO_DISMISS_MS = 3000; +const MAX_VISIBLE_TOASTS = 2; /** * App-level toast host. Renders an aria-live region so screen readers announce @@ -49,7 +50,9 @@ export function ToastProvider({ children }: { children: ReactNode }) { const showToast = useCallback( (message: string, variant: ToastVariant = "success") => { const id = (idRef.current += 1); - setToasts((prev) => [...prev, { id, message, variant }]); + setToasts((prev) => + [...prev, { id, message, variant }].slice(-MAX_VISIBLE_TOASTS), + ); window.setTimeout(() => dismiss(id), AUTO_DISMISS_MS); }, [dismiss], From 6471559b3bf482e95f317d0a7d410d72e1d2580d Mon Sep 17 00:00:00 2001 From: TerryM Date: Thu, 4 Jun 2026 17:15:14 +0800 Subject: [PATCH 61/89] fix: stabilize favorites page loading --- ...026-06-04-favorites-display-loading-fix.md | 41 +++++++++++++++++++ src/layouts/PublicLayout.tsx | 1 - src/pages/Favorites/index.tsx | 18 +++++++- 3 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 .unipi/docs/fix/2026-06-04-favorites-display-loading-fix.md diff --git a/.unipi/docs/fix/2026-06-04-favorites-display-loading-fix.md b/.unipi/docs/fix/2026-06-04-favorites-display-loading-fix.md new file mode 100644 index 0000000..ba7c05f --- /dev/null +++ b/.unipi/docs/fix/2026-06-04-favorites-display-loading-fix.md @@ -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. diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index 3ff1ce1..9a0b2fe 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -698,7 +698,6 @@ export function PublicLayout() { /> window.scrollTo({ top: 0, left: 0 })} className={`hidden h-10 shrink-0 items-center justify-center gap-2 rounded-full border px-3 text-sm font-bold outline-none transition focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg md:inline-flex ${ na("favorites") diff --git a/src/pages/Favorites/index.tsx b/src/pages/Favorites/index.tsx index 5a9dcea..68088ea 100644 --- a/src/pages/Favorites/index.tsx +++ b/src/pages/Favorites/index.tsx @@ -137,6 +137,7 @@ export default function Favorites() { const [items, setItems] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); + const [loaded, setLoaded] = useState(false); const [error, setError] = useState(""); const [reloadKey, setReloadKey] = useState(0); const [showFilters, setShowFilters] = useState(false); @@ -151,10 +152,13 @@ export default function Favorites() { if (!wallet.token || wallet.status !== "loggedIn") { setItems([]); setTotal(0); + setLoading(false); + setLoaded(false); return; } let cancelled = false; setLoading(true); + setLoaded(false); setError(""); listFavorites(wallet.token, { sort, @@ -170,6 +174,7 @@ export default function Favorites() { setItems(resources); setTotal(data.total ?? resources.length); resources.forEach((resource) => markFavorite(resource.id, true)); + setLoaded(true); }) .catch((err) => { if (cancelled) return; @@ -179,6 +184,7 @@ export default function Favorites() { return; } setError(err instanceof Error ? err.message : t("loadFailed")); + setLoaded(true); }) .finally(() => { if (!cancelled) setLoading(false); @@ -200,6 +206,16 @@ export default function Favorites() { [t], ); + if (wallet.status === "loading") { + return ( + + {Array.from({ length: 4 }).map((_, index) => ( + + ))} + + ); + } + if (wallet.status !== "loggedIn") { return ( @@ -330,7 +346,7 @@ export default function Favorites() { ) : null}
- {loading ? ( + {loading || !loaded ? (
{Array.from({ length: 4 }).map((_, index) => ( From 4f6cbbc31416b645099fd2977896ece2272f8ffd Mon Sep 17 00:00:00 2001 From: TerryM Date: Thu, 4 Jun 2026 17:25:55 +0800 Subject: [PATCH 62/89] fix: simplify favorites display --- src/pages/Favorites/index.tsx | 330 +++++++++++++--------------------- 1 file changed, 125 insertions(+), 205 deletions(-) diff --git a/src/pages/Favorites/index.tsx b/src/pages/Favorites/index.tsx index 68088ea..80f40af 100644 --- a/src/pages/Favorites/index.tsx +++ b/src/pages/Favorites/index.tsx @@ -1,4 +1,4 @@ -import { Heart, RotateCcw, Search, SlidersHorizontal, X } from "lucide-react"; +import { Heart, RotateCcw } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { @@ -10,14 +10,9 @@ import { type Resource, } from "../../api"; import { FavoriteButton } from "../../favorites/FavoriteButton"; -import { - isFavoritesAuthError, - listFavorites, - type FavoriteSort, -} from "../../favorites/api"; +import { isFavoritesAuthError, listFavorites } from "../../favorites/api"; import { useFavorites } from "../../favorites/FavoritesProvider"; import { langQuery, useI18n, type Lang } from "../../i18n"; -import { homePathForLang } from "../../languageRoutes"; import { Reveal } from "../../motion"; import { useSetPageTitle } from "../../components/PageTitleContext"; import { Skeleton } from "../../components/Skeleton"; @@ -25,16 +20,18 @@ import { useWallet } from "../../wallet/WalletProvider"; import { useLocalizedPath } from "../../useLocalizedPath"; import { cleanCategoryDisplayName } from "../../utils/categoryDisplay"; import { formatDateYmd } from "../../utils/format"; +import { resourceTypeLabel } from "../../resourceTypeLabels"; -const pageSize = 24; +const pageSize = 50; -function useCategories(lang: Lang) { +function useCategoryNameBySlug(lang: Lang): Map { const [categories, setCategories] = useState([]); useEffect(() => { const url = `/api/categories?lang=${encodeURIComponent(langQuery(lang))}`; const cached = readJSONCache(url); if (cached) setCategories(itemsOrEmpty(cached)); + let cancelled = false; getJSON(url) .then((items) => { @@ -43,19 +40,100 @@ function useCategories(lang: Lang) { .catch(() => { if (!cancelled && !cached) setCategories([]); }); + return () => { cancelled = true; }; }, [lang]); - return categories; + return useMemo(() => { + const map = new Map(); + categories.forEach((category) => map.set(category.slug, category.name)); + return map; + }, [categories]); } -function FavoriteResourceCard({ resource }: { resource: Resource }) { - const { t } = useI18n(); +type FavoriteAttachment = { + thumbnailUrl?: string; + thumbUrl?: string; + posterUrl?: string; + url?: string; +}; + +type FavoriteLocalization = { + title?: string; + text?: string; + description?: string; +}; + +type FavoriteResource = Resource & { + postType?: string; + sourceLanguage?: string; + createdAt?: string; + attachments?: FavoriteAttachment[]; + localizations?: Record; +}; + +function localizationKeys(lang: Lang): string[] { + if (lang === "zh-CN") return ["zh", "zh-CN", "zh-Hans"]; + return [lang]; +} + +function localizedResourceText( + resource: FavoriteResource, + lang: Lang, + field: "title" | "description", +): string { + for (const key of localizationKeys(lang)) { + const localized = resource.localizations?.[key]; + if (!localized) continue; + if (field === "title" && localized.title?.trim()) return localized.title; + if (field === "description") { + const text = localized.description || localized.text; + if (text?.trim()) return text; + } + } + if (field === "title") return resource.title; + return resource.description || resource.bodyText || ""; +} + +function firstAttachmentUrl(resource: FavoriteResource): string { + const attachment = resource.attachments?.[0]; + return ( + attachment?.thumbnailUrl || + attachment?.thumbUrl || + attachment?.posterUrl || + attachment?.url || + "" + ); +} + +function FavoriteResourceCard({ + categoryNameBySlug, + resource, +}: { + categoryNameBySlug: Map; + resource: FavoriteResource; +}) { + const { lang, t } = useI18n(); const lp = useLocalizedPath(); const unavailable = resource.availability === "unavailable"; - const cover = resource.coverImage || resource.previewUrl; + const cover = + resource.coverImage || resource.previewUrl || firstAttachmentUrl(resource); + const categoryLabel = + (resource.categorySlug && categoryNameBySlug.get(resource.categorySlug)) || + resource.categoryName || + resource.categorySlug || + "ARK"; + const typeLabel = resourceTypeLabel( + t, + resource.type || resource.postType || "resource", + ); + const date = + resource.updatedAt || resource.publishedAt || resource.createdAt || ""; + const title = localizedResourceText(resource, lang, "title"); + const description = localizedResourceText(resource, lang, "description"); + return (
) : null} @@ -94,22 +172,24 @@ function FavoriteResourceCard({ resource }: { resource: Resource }) {

- {resource.title} + {title}

- {resource.description ? ( + {description ? (

- {resource.description} + {description}

) : null}
- {cleanCategoryDisplayName(resource.categoryName)} + {cleanCategoryDisplayName(categoryLabel)} - {resource.type} - · - + {typeLabel} + {date ? ( + <> + · + + + ) : null} {typeof resource.favoriteCount === "number" ? ( · ♥ {resource.favoriteCount} ) : null} @@ -128,51 +208,37 @@ export default function Favorites() { const { lang, t } = useI18n(); const wallet = useWallet(); const { markFavorite } = useFavorites(); - const categories = useCategories(lang); - const [sort, setSort] = useState("favorited_at"); - const [category, setCategory] = useState(""); - const [queryInput, setQueryInput] = useState(""); - const [query, setQuery] = useState(""); - const [page, setPage] = useState(1); - const [items, setItems] = useState([]); - const [total, setTotal] = useState(0); + const categoryNameBySlug = useCategoryNameBySlug(lang); + const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [loaded, setLoaded] = useState(false); const [error, setError] = useState(""); const [reloadKey, setReloadKey] = useState(0); - const [showFilters, setShowFilters] = useState(false); useSetPageTitle(t("favorites")); - useEffect(() => { - setPage(1); - }, [sort, category, query]); - useEffect(() => { if (!wallet.token || wallet.status !== "loggedIn") { setItems([]); - setTotal(0); setLoading(false); setLoaded(false); + setError(""); return; } + let cancelled = false; setLoading(true); setLoaded(false); setError(""); + listFavorites(wallet.token, { - sort, - category, - q: query, - page, limit: pageSize, includeUnavailable: true, }) .then((data) => { if (cancelled) return; - const resources = itemsOrEmpty(data.items); + const resources = itemsOrEmpty(data.items) as FavoriteResource[]; setItems(resources); - setTotal(data.total ?? resources.length); resources.forEach((resource) => markFavorite(resource.id, true)); setLoaded(true); }) @@ -189,22 +255,11 @@ export default function Favorites() { .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; - }, [category, markFavorite, page, query, reloadKey, sort, t, wallet]); - - const totalPages = Math.max(1, Math.ceil(total / pageSize)); - const hasFilters = Boolean(category || query || sort !== "favorited_at"); - - const sortOptions = useMemo( - () => [ - { value: "favorited_at" as const, label: t("favoritesSortFavoritedAt") }, - { value: "published_at" as const, label: t("favoritesSortPublishedAt") }, - { value: "hot" as const, label: t("favoritesSortHot") }, - ], - [t], - ); + }, [markFavorite, reloadKey, t, wallet]); if (wallet.status === "loading") { return ( @@ -240,118 +295,11 @@ export default function Favorites() { } return ( - -
-
-
-

- {t("favorites")} -

-

- {t("favoritesLibraryTitle")} -

-
- - {t("backToHome")} - -
- -
{ - event.preventDefault(); - setQuery(queryInput.trim()); - }} - > - - - {/* Mobile-only toggle: collapse sort/category into a "Filters" drawer. */} - - -
- - - -
- - -
- - {hasFilters ? ( - - ) : null} -
- +
{loading || !loaded ? ( -
- {Array.from({ length: 4 }).map((_, index) => ( - - ))} -
+ Array.from({ length: 4 }).map((_, index) => ( + + )) ) : error ? (

{error}

@@ -368,49 +316,21 @@ export default function Favorites() {

- {hasFilters - ? t("favoritesNoFilteredTitle") - : t("favoritesEmptyTitle")} + {t("favoritesEmptyTitle")}

- {hasFilters - ? t("favoritesNoFilteredDesc") - : t("favoritesEmptyDesc")} + {t("favoritesEmptyDesc")}

) : ( -
- {items.map((resource) => ( - - ))} -
+ items.map((resource) => ( + + )) )} - - {totalPages > 1 ? ( -
- - - {t("pageIndicator") - .replace("{{c}}", String(page)) - .replace("{{p}}", String(totalPages))} - - -
- ) : null} - +
); } From ec98ff5a0379d46843347e1a79bc27682e63748b Mon Sep 17 00:00:00 2001 From: TerryM Date: Thu, 4 Jun 2026 17:46:09 +0800 Subject: [PATCH 63/89] fix: align favorites page with post adapter --- ...ckend-wallet-favorites-production-fixes.md | 48 ++++-- src/favorites/api.ts | 5 +- src/pages/Favorites/index.tsx | 161 ++++++------------ src/utils/postResourceAdapter.ts | 2 +- 4 files changed, 86 insertions(+), 130 deletions(-) diff --git a/docs/backend-wallet-favorites-production-fixes.md b/docs/backend-wallet-favorites-production-fixes.md index 990330b..55fb98b 100644 --- a/docs/backend-wallet-favorites-production-fixes.md +++ b/docs/backend-wallet-favorites-production-fixes.md @@ -187,7 +187,13 @@ If backend decides to keep the simplified `{ address }` login, please explicitly ## Priority 2 — Normalize favorites response contract -Frontend currently supports the staging response shape, but the response should be made explicit. +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=` 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 @@ -196,36 +202,50 @@ GET /api/favorites?lang=&limit=&page=&sort=&category=&q= Authorization: Bearer ``` -Current staging response observed: +Required production response: ```json { "items": [ { "id": "...", - "postType": "image", + "title": "...", + "description": "...", + "type": "...", "categoryId": 11, "categorySlug": "official-assets", - "language": "zh", - "title": "..." + "categoryName": "...", + "language": "...", + "sourceLanguage": "...", + "coverImage": "...", + "updatedAt": "...", + "publishedAt": "...", + "favoriteCount": 0, + "availability": "available" } - ] -} -``` - -Recommended production response: - -```json -{ - "items": [], + ], "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 diff --git a/src/favorites/api.ts b/src/favorites/api.ts index 6f29b00..30b1f72 100644 --- a/src/favorites/api.ts +++ b/src/favorites/api.ts @@ -1,9 +1,10 @@ -import { apiBase, itemsOrEmpty, type Resource } from "../api"; +import { apiBase, itemsOrEmpty } from "../api"; +import type { Post } from "../types/post"; export type FavoriteSort = "favorited_at" | "published_at" | "hot"; export type FavoriteListResponse = { - items: Resource[]; + items: Post[]; page?: number; limit?: number; total?: number; diff --git a/src/pages/Favorites/index.tsx b/src/pages/Favorites/index.tsx index 80f40af..9474251 100644 --- a/src/pages/Favorites/index.tsx +++ b/src/pages/Favorites/index.tsx @@ -18,20 +18,21 @@ import { useSetPageTitle } from "../../components/PageTitleContext"; import { Skeleton } from "../../components/Skeleton"; import { useWallet } from "../../wallet/WalletProvider"; import { useLocalizedPath } from "../../useLocalizedPath"; -import { cleanCategoryDisplayName } from "../../utils/categoryDisplay"; +import { postToResource } from "../../utils/postResourceAdapter"; import { formatDateYmd } from "../../utils/format"; -import { resourceTypeLabel } from "../../resourceTypeLabels"; +import { + resourceLanguageLabel, + resourceTypeLabel, +} from "../../resourceTypeLabels"; const pageSize = 50; -function useCategoryNameBySlug(lang: Lang): Map { +function useCategories(lang: Lang): Category[] { const [categories, setCategories] = useState([]); - useEffect(() => { const url = `/api/categories?lang=${encodeURIComponent(langQuery(lang))}`; const cached = readJSONCache(url); if (cached) setCategories(itemsOrEmpty(cached)); - let cancelled = false; getJSON(url) .then((items) => { @@ -40,99 +41,18 @@ function useCategoryNameBySlug(lang: Lang): Map { .catch(() => { if (!cancelled && !cached) setCategories([]); }); - return () => { cancelled = true; }; }, [lang]); - - return useMemo(() => { - const map = new Map(); - categories.forEach((category) => map.set(category.slug, category.name)); - return map; - }, [categories]); + return categories; } -type FavoriteAttachment = { - thumbnailUrl?: string; - thumbUrl?: string; - posterUrl?: string; - url?: string; -}; - -type FavoriteLocalization = { - title?: string; - text?: string; - description?: string; -}; - -type FavoriteResource = Resource & { - postType?: string; - sourceLanguage?: string; - createdAt?: string; - attachments?: FavoriteAttachment[]; - localizations?: Record; -}; - -function localizationKeys(lang: Lang): string[] { - if (lang === "zh-CN") return ["zh", "zh-CN", "zh-Hans"]; - return [lang]; -} - -function localizedResourceText( - resource: FavoriteResource, - lang: Lang, - field: "title" | "description", -): string { - for (const key of localizationKeys(lang)) { - const localized = resource.localizations?.[key]; - if (!localized) continue; - if (field === "title" && localized.title?.trim()) return localized.title; - if (field === "description") { - const text = localized.description || localized.text; - if (text?.trim()) return text; - } - } - if (field === "title") return resource.title; - return resource.description || resource.bodyText || ""; -} - -function firstAttachmentUrl(resource: FavoriteResource): string { - const attachment = resource.attachments?.[0]; - return ( - attachment?.thumbnailUrl || - attachment?.thumbUrl || - attachment?.posterUrl || - attachment?.url || - "" - ); -} - -function FavoriteResourceCard({ - categoryNameBySlug, - resource, -}: { - categoryNameBySlug: Map; - resource: FavoriteResource; -}) { - const { lang, t } = useI18n(); +function FavoriteResourceCard({ resource }: { resource: Resource }) { + const { t } = useI18n(); const lp = useLocalizedPath(); const unavailable = resource.availability === "unavailable"; - const cover = - resource.coverImage || resource.previewUrl || firstAttachmentUrl(resource); - const categoryLabel = - (resource.categorySlug && categoryNameBySlug.get(resource.categorySlug)) || - resource.categoryName || - resource.categorySlug || - "ARK"; - const typeLabel = resourceTypeLabel( - t, - resource.type || resource.postType || "resource", - ); - const date = - resource.updatedAt || resource.publishedAt || resource.createdAt || ""; - const title = localizedResourceText(resource, lang, "title"); - const description = localizedResourceText(resource, lang, "description"); + const cover = resource.coverImage || resource.previewUrl; return (
) : null} @@ -172,22 +92,34 @@ function FavoriteResourceCard({

- {title} + {resource.title}

- {description ? ( + {resource.description ? (

- {description} + {resource.description}

) : null}
- - {cleanCategoryDisplayName(categoryLabel)} - - {typeLabel} - {date ? ( + {resource.categoryName ? ( + + {resource.categoryName} + + ) : null} + {resource.type ? ( + {resourceTypeLabel(t, resource.type)} + ) : null} + {resource.language ? ( <> · - + {resourceLanguageLabel(t, resource.language)} + + ) : null} + {resource.updatedAt ? ( + <> + · + ) : null} {typeof resource.favoriteCount === "number" ? ( @@ -208,8 +140,10 @@ export default function Favorites() { const { lang, t } = useI18n(); const wallet = useWallet(); const { markFavorite } = useFavorites(); - const categoryNameBySlug = useCategoryNameBySlug(lang); - const [items, setItems] = useState([]); + const categories = useCategories(lang); + const [posts, setPosts] = useState< + Awaited>["items"] + >([]); const [loading, setLoading] = useState(false); const [loaded, setLoaded] = useState(false); const [error, setError] = useState(""); @@ -219,7 +153,7 @@ export default function Favorites() { useEffect(() => { if (!wallet.token || wallet.status !== "loggedIn") { - setItems([]); + setPosts([]); setLoading(false); setLoaded(false); setError(""); @@ -237,9 +171,9 @@ export default function Favorites() { }) .then((data) => { if (cancelled) return; - const resources = itemsOrEmpty(data.items) as FavoriteResource[]; - setItems(resources); - resources.forEach((resource) => markFavorite(resource.id, true)); + const items = itemsOrEmpty(data.items); + setPosts(items); + items.forEach((post) => markFavorite(post.id, true)); setLoaded(true); }) .catch((err) => { @@ -261,6 +195,11 @@ export default function Favorites() { }; }, [markFavorite, reloadKey, t, wallet]); + const resources = useMemo( + () => posts.map((post) => postToResource(post, lang, categories)), + [posts, lang, categories], + ); + if (wallet.status === "loading") { return ( @@ -312,7 +251,7 @@ export default function Favorites() { {t("walletRetry")}
- ) : items.length === 0 ? ( + ) : resources.length === 0 ? (

@@ -323,12 +262,8 @@ export default function Favorites() {

) : ( - items.map((resource) => ( - + resources.map((resource) => ( + )) )}
diff --git a/src/utils/postResourceAdapter.ts b/src/utils/postResourceAdapter.ts index de2466e..5321013 100644 --- a/src/utils/postResourceAdapter.ts +++ b/src/utils/postResourceAdapter.ts @@ -46,7 +46,7 @@ export function postToResource( title, description: postDisplayText(post, lang), type: inferType(post, first), - language: post.language, + language: post.sourceLanguage || post.language, categoryId: post.categoryId, categorySlug: post.categorySlug, categoryName: category?.name || post.categorySlug, From 1fcf2ea46d514d4e130b9767ed2e2d6a820c8a9d Mon Sep 17 00:00:00 2001 From: TerryM Date: Thu, 4 Jun 2026 17:50:03 +0800 Subject: [PATCH 64/89] fix: fall back to original language on post redirect --- ...vorite-other-language-post-redirect-fix.md | 43 +++++++++++++++++++ src/locales/en.ts | 2 + src/locales/id.ts | 2 + src/locales/ja.ts | 2 + src/locales/ko.ts | 2 + src/locales/ms.ts | 2 + src/locales/vi.ts | 2 + src/locales/zh-CN.ts | 1 + src/pages/PostRedirect/index.tsx | 27 ++++++++---- 9 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 .unipi/docs/fix/2026-06-04-favorite-other-language-post-redirect-fix.md diff --git a/.unipi/docs/fix/2026-06-04-favorite-other-language-post-redirect-fix.md b/.unipi/docs/fix/2026-06-04-favorite-other-language-post-redirect-fix.md new file mode 100644 index 0000000..d6dfdde --- /dev/null +++ b/.unipi/docs/fix/2026-06-04-favorite-other-language-post-redirect-fix.md @@ -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=`. 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. diff --git a/src/locales/en.ts b/src/locales/en.ts index c1eaf7d..95cdf62 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -166,6 +166,8 @@ export const enDict: Dict = { favoritesSortHot: "Hot resources", favoritesSearchPlaceholder: "Search your favorites", favoritesUnavailable: "Unavailable", + postShownInOriginalLanguage: + "This post is not available in your selected language. Showing the original.", favoritesClearFilters: "Clear filters", favorites: "My Favorites", favoritesComingSoon: "Coming Soon", diff --git a/src/locales/id.ts b/src/locales/id.ts index de412d9..c240f0c 100644 --- a/src/locales/id.ts +++ b/src/locales/id.ts @@ -166,6 +166,8 @@ export const idDict: Dict = { favoritesSortHot: "Sumber populer", favoritesSearchPlaceholder: "Cari favorit Anda", favoritesUnavailable: "Tidak tersedia", + postShownInOriginalLanguage: + "Postingan ini tidak tersedia dalam bahasa yang Anda pilih. Menampilkan bahasa aslinya.", favoritesClearFilters: "Hapus filter", favorites: "Favorit Saya", favoritesComingSoon: "Segera Hadir", diff --git a/src/locales/ja.ts b/src/locales/ja.ts index c2eea0b..f6d0015 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -166,6 +166,8 @@ export const jaDict: Dict = { favoritesSortHot: "人気資料", favoritesSearchPlaceholder: "お気に入りを検索", favoritesUnavailable: "利用不可", + postShownInOriginalLanguage: + "この投稿は選択した言語で提供されていないため、原語で表示します。", favoritesClearFilters: "フィルターをクリア", favorites: "お気に入り", favoritesComingSoon: "近日公開", diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 1c97aa5..7142e4c 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -164,6 +164,8 @@ export const koDict: Dict = { favoritesSortHot: "인기 자료", favoritesSearchPlaceholder: "내 즐겨찾기 검색", favoritesUnavailable: "사용 불가", + postShownInOriginalLanguage: + "이 게시물은 선택하신 언어로 제공되지 않아 원본 언어로 표시됩니다.", favoritesClearFilters: "필터 지우기", favorites: "내 즐겨찾기", favoritesComingSoon: "출시 예정", diff --git a/src/locales/ms.ts b/src/locales/ms.ts index 4aad10b..ceb109b 100644 --- a/src/locales/ms.ts +++ b/src/locales/ms.ts @@ -164,6 +164,8 @@ export const msDict: Dict = { favoritesSortHot: "Sumber popular", favoritesSearchPlaceholder: "Cari kegemaran anda", favoritesUnavailable: "Tidak tersedia", + postShownInOriginalLanguage: + "Pos ini tidak tersedia dalam bahasa pilihan anda. Memaparkan bahasa asal.", favoritesClearFilters: "Kosongkan penapis", favorites: "Kegemaran Saya", favoritesComingSoon: "Akan Hadir", diff --git a/src/locales/vi.ts b/src/locales/vi.ts index bdf6bf8..61a985d 100644 --- a/src/locales/vi.ts +++ b/src/locales/vi.ts @@ -163,6 +163,8 @@ export const viDict: Dict = { favoritesSortHot: "Tài nguyên hot", favoritesSearchPlaceholder: "Tìm trong yêu thích", favoritesUnavailable: "Không khả dụng", + postShownInOriginalLanguage: + "Bài đăng này không có trong ngôn ngữ bạn chọn. Đang hiển thị bằng ngôn ngữ gốc.", favoritesClearFilters: "Xóa bộ lọc", favorites: "Yêu thích của tôi", favoritesComingSoon: "Sắp ra mắt", diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index c54ba02..cde1b97 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -159,6 +159,7 @@ export const zhDict: Dict = { favoritesSortHot: "热门资料", favoritesSearchPlaceholder: "搜索我的收藏", favoritesUnavailable: "已下架", + postShownInOriginalLanguage: "该贴子暂未提供当前语言版本,以原语言显示。", favoritesClearFilters: "清除筛选", favorites: "我的收藏", favoritesComingSoon: "功能即将推出", diff --git a/src/pages/PostRedirect/index.tsx b/src/pages/PostRedirect/index.tsx index 7bd2f84..4a6c8f6 100644 --- a/src/pages/PostRedirect/index.tsx +++ b/src/pages/PostRedirect/index.tsx @@ -5,13 +5,15 @@ import { langQuery, useI18n } from "../../i18n"; import { useLocalizedPath } from "../../useLocalizedPath"; import { MOCK_POSTS } from "../../mocks/mockPosts"; import { POST_STREAM_USES_MOCK } from "../../components/messageStream/hooks/usePostStream"; +import { useToast } from "../../components/Toast"; import type { Post } from "../../types/post"; export function PostRedirect() { const { id } = useParams(); - const { lang } = useI18n(); + const { lang, t } = useI18n(); const navigate = useNavigate(); const lp = useLocalizedPath(); + const { showToast } = useToast(); useEffect(() => { if (!id) { @@ -30,16 +32,25 @@ export function PostRedirect() { return; } + const goToPost = (post: Post) => { + navigate(lp(`/browse?post=${encodeURIComponent(post.id)}`), { + replace: true, + }); + }; + getJSON( `/api/posts/${id}?lang=${encodeURIComponent(langQuery(lang))}`, ) - .then((post) => { - navigate(lp(`/browse?post=${encodeURIComponent(post.id)}`), { - replace: true, - }); - }) - .catch(() => navigate(lp("/browse"), { replace: true })); - }, [id, lang, navigate, lp]); + .then(goToPost) + .catch(() => { + getJSON(`/api/posts/${id}`) + .then((post) => { + showToast(t("postShownInOriginalLanguage")); + goToPost(post); + }) + .catch(() => navigate(lp("/browse"), { replace: true })); + }); + }, [id, lang, navigate, lp, showToast, t]); return
; } From 062f630798b9aa9ff4e646f85f70276d041910f9 Mon Sep 17 00:00:00 2001 From: TerryM Date: Thu, 4 Jun 2026 18:07:51 +0800 Subject: [PATCH 65/89] fix: dedupe post redirect language fallback --- src/pages/PostRedirect/index.tsx | 58 +++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/src/pages/PostRedirect/index.tsx b/src/pages/PostRedirect/index.tsx index 4a6c8f6..1f45565 100644 --- a/src/pages/PostRedirect/index.tsx +++ b/src/pages/PostRedirect/index.tsx @@ -1,25 +1,43 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { getJSON } from "../../api"; -import { langQuery, useI18n } from "../../i18n"; +import { langQuery, useI18n, type Lang } from "../../i18n"; import { useLocalizedPath } from "../../useLocalizedPath"; +import { localizePath } from "../../languageRoutes"; import { MOCK_POSTS } from "../../mocks/mockPosts"; import { POST_STREAM_USES_MOCK } from "../../components/messageStream/hooks/usePostStream"; import { useToast } from "../../components/Toast"; +import { Skeleton } from "../../components/Skeleton"; import type { Post } from "../../types/post"; +function postLangToUiLang(code: string | undefined): Lang | null { + if (!code) return null; + const lc = code.trim().toLowerCase(); + if (lc === "zh" || lc === "zh-cn" || lc === "zh-hans") return "zh-CN"; + if (lc === "en") return "en"; + if (lc === "ja") return "ja"; + if (lc === "ko") return "ko"; + if (lc === "vi") return "vi"; + if (lc === "id") return "id"; + if (lc === "ms") return "ms"; + return null; +} + export function PostRedirect() { const { id } = useParams(); const { lang, t } = useI18n(); const navigate = useNavigate(); const lp = useLocalizedPath(); const { showToast } = useToast(); + const handledIdRef = useRef(null); useEffect(() => { if (!id) { navigate(lp("/browse"), { replace: true }); return; } + if (handledIdRef.current === id) return; + handledIdRef.current = id; if (POST_STREAM_USES_MOCK) { const post = MOCK_POSTS.find((p) => p.id === id); @@ -32,25 +50,47 @@ export function PostRedirect() { return; } - const goToPost = (post: Post) => { - navigate(lp(`/browse?post=${encodeURIComponent(post.id)}`), { - replace: true, - }); + const goToPostInLang = (post: Post, targetLang: Lang) => { + navigate( + localizePath(`/browse?post=${encodeURIComponent(post.id)}`, targetLang), + { replace: true }, + ); }; getJSON( `/api/posts/${id}?lang=${encodeURIComponent(langQuery(lang))}`, ) - .then(goToPost) + .then((post) => goToPostInLang(post, lang)) .catch(() => { getJSON(`/api/posts/${id}`) .then((post) => { + const sourceLang = + postLangToUiLang(post.sourceLanguage) || + postLangToUiLang(post.language) || + lang; showToast(t("postShownInOriginalLanguage")); - goToPost(post); + goToPostInLang(post, sourceLang); }) .catch(() => navigate(lp("/browse"), { replace: true })); }); }, [id, lang, navigate, lp, showToast, t]); - return
; + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ +
+ ))} +
+ ); } From 292c7455491029a2a863eedb9806018fa96e229e Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 5 Jun 2026 12:22:41 +0800 Subject: [PATCH 66/89] feat(wallet): redesign drawer wallet states to match Figma 4476-15287/15669 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/locales/en.ts | 1 + src/locales/id.ts | 1 + src/locales/ja.ts | 1 + src/locales/ko.ts | 1 + src/locales/ms.ts | 1 + src/locales/vi.ts | 1 + src/locales/zh-CN.ts | 1 + src/wallet/WalletButton.tsx | 33 ++++++++++++++++++++++++--------- 8 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/locales/en.ts b/src/locales/en.ts index 95cdf62..c3e97cf 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -176,6 +176,7 @@ export const enDict: Dict = { close: "Close", walletConnect: "Connect Wallet", walletConnectedAs: "Connected wallet", + walletLoginAddress: "Login address", walletDisconnect: "Disconnect", walletLoginTitle: "Connect wallet", walletLoginDesc: diff --git a/src/locales/id.ts b/src/locales/id.ts index c240f0c..2111f0d 100644 --- a/src/locales/id.ts +++ b/src/locales/id.ts @@ -176,6 +176,7 @@ export const idDict: Dict = { close: "Tutup", walletConnect: "Hubungkan Dompet", walletConnectedAs: "Dompet terhubung", + walletLoginAddress: "Alamat login", walletDisconnect: "Putuskan", walletLoginTitle: "Hubungkan dompet", walletLoginDesc: diff --git a/src/locales/ja.ts b/src/locales/ja.ts index f6d0015..0eedd3a 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -175,6 +175,7 @@ export const jaDict: Dict = { close: "閉じる", walletConnect: "ウォレット接続", walletConnectedAs: "接続中のウォレット", + walletLoginAddress: "ログインアドレス", walletDisconnect: "切断", walletLoginTitle: "ウォレットを接続", walletLoginDesc: diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 7142e4c..17a8f73 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -174,6 +174,7 @@ export const koDict: Dict = { close: "닫기", walletConnect: "지갑 연결", walletConnectedAs: "연결된 지갑", + walletLoginAddress: "로그인 주소", walletDisconnect: "연결 해제", walletLoginTitle: "지갑 연결", walletLoginDesc: diff --git a/src/locales/ms.ts b/src/locales/ms.ts index ceb109b..69d482d 100644 --- a/src/locales/ms.ts +++ b/src/locales/ms.ts @@ -174,6 +174,7 @@ export const msDict: Dict = { close: "Tutup", walletConnect: "Sambung Dompet", walletConnectedAs: "Dompet disambungkan", + walletLoginAddress: "Alamat log masuk", walletDisconnect: "Putuskan", walletLoginTitle: "Sambung dompet", walletLoginDesc: diff --git a/src/locales/vi.ts b/src/locales/vi.ts index 61a985d..ebd3e2c 100644 --- a/src/locales/vi.ts +++ b/src/locales/vi.ts @@ -173,6 +173,7 @@ export const viDict: Dict = { close: "Đóng", walletConnect: "Kết nối ví", walletConnectedAs: "Ví đã kết nối", + walletLoginAddress: "Địa chỉ đăng nhập", walletDisconnect: "Ngắt kết nối", walletLoginTitle: "Kết nối ví", walletLoginDesc: diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index cde1b97..d0a3791 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -167,6 +167,7 @@ export const zhDict: Dict = { close: "关闭", walletConnect: "连接钱包", walletConnectedAs: "已连接钱包", + walletLoginAddress: "登录地址", walletDisconnect: "断开连接", walletLoginTitle: "连接钱包", walletLoginDesc: "签名验证钱包地址,不会发起交易,也不需要 Gas。", diff --git a/src/wallet/WalletButton.tsx b/src/wallet/WalletButton.tsx index 2beb350..a6ce384 100644 --- a/src/wallet/WalletButton.tsx +++ b/src/wallet/WalletButton.tsx @@ -1,4 +1,4 @@ -import { Heart } from "lucide-react"; +import { Heart, LogOut } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { Link } from "react-router-dom"; import { WalletIcon } from "../components/icons/WalletIcon"; @@ -40,17 +40,30 @@ export function WalletButton({ if (wallet.status === "loggedIn" && wallet.address) { if (compact) { return ( -
-
- - {shortenAddress(wallet.address)} +
+
+
+ {t("walletLoginAddress")} +
+
+ + {wallet.address.slice(0, 5)} + + + {wallet.address.slice(5, -5)} + + + {wallet.address.slice(-5)} + +
); @@ -108,8 +121,10 @@ export function WalletButton({ wallet.openLoginModal(); }} className={[ - "inline-flex h-10 items-center justify-center gap-2 rounded-full border border-ark-gold bg-ark-gold px-4 text-sm font-bold text-black outline-none transition hover:bg-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg", - compact ? "w-full" : "min-w-[124px] shrink-0 whitespace-nowrap", + "inline-flex items-center justify-center gap-2 rounded-full border border-ark-gold bg-ark-gold px-4 text-black outline-none transition hover:bg-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg", + compact + ? "h-12 w-full text-[15px] font-medium" + : "h-10 min-w-[124px] shrink-0 whitespace-nowrap text-sm font-bold", ].join(" ")} > From 486c09dd39443c7225718a08b56bb968481a8380 Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 5 Jun 2026 18:14:48 +0800 Subject: [PATCH 67/89] fix: simplify mobile favorite button state --- src/favorites/FavoriteButton.tsx | 17 +++- src/pages/Favorites/index.tsx | 152 +++---------------------------- 2 files changed, 25 insertions(+), 144 deletions(-) diff --git a/src/favorites/FavoriteButton.tsx b/src/favorites/FavoriteButton.tsx index d2824f1..47e27e8 100644 --- a/src/favorites/FavoriteButton.tsx +++ b/src/favorites/FavoriteButton.tsx @@ -57,16 +57,25 @@ export function FavoriteButton({ aria-label={isFavorite ? t("favoriteRemove") : t("favoriteAdd")} title={isFavorite ? t("favoriteRemove") : t("favoriteAdd")} className={[ - "inline-flex shrink-0 items-center justify-center rounded-full border outline-none transition active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait disabled:opacity-70", + "inline-flex shrink-0 items-center justify-center rounded-full border outline-none transition active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait md:disabled:opacity-70", dimension, isFavorite - ? "border-ark-gold/60 bg-ark-gold text-black hover:bg-ark-gold2" - : "border-white/10 bg-[#191921]/90 text-[#A8A9AE] hover:border-ark-gold hover:bg-[#191921] hover:text-ark-gold", + ? "border-ark-gold/60 bg-ark-gold text-black md:hover:bg-ark-gold2" + : "border-white/10 bg-[#191921]/90 text-[#A8A9AE] md:hover:border-ark-gold md:hover:bg-[#191921] md:hover:text-ark-gold", className, ].join(" ")} + aria-busy={pending} > {pending ? ( - + <> + + + + + ) : ( )} diff --git a/src/pages/Favorites/index.tsx b/src/pages/Favorites/index.tsx index 9474251..66d60b1 100644 --- a/src/pages/Favorites/index.tsx +++ b/src/pages/Favorites/index.tsx @@ -1,146 +1,21 @@ import { Heart, RotateCcw } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; -import { Link } from "react-router-dom"; -import { - assetUrl, - getJSON, - itemsOrEmpty, - readJSONCache, - type Category, - type Resource, -} from "../../api"; -import { FavoriteButton } from "../../favorites/FavoriteButton"; +import { useEffect, useState } from "react"; +import { itemsOrEmpty } from "../../api"; import { isFavoritesAuthError, listFavorites } from "../../favorites/api"; import { useFavorites } from "../../favorites/FavoritesProvider"; -import { langQuery, useI18n, type Lang } from "../../i18n"; +import { useI18n } from "../../i18n"; import { Reveal } from "../../motion"; +import { MessageBubble } from "../../components/messageStream/MessageBubble"; import { useSetPageTitle } from "../../components/PageTitleContext"; import { Skeleton } from "../../components/Skeleton"; import { useWallet } from "../../wallet/WalletProvider"; -import { useLocalizedPath } from "../../useLocalizedPath"; -import { postToResource } from "../../utils/postResourceAdapter"; -import { formatDateYmd } from "../../utils/format"; -import { - resourceLanguageLabel, - resourceTypeLabel, -} from "../../resourceTypeLabels"; const pageSize = 50; -function useCategories(lang: Lang): Category[] { - const [categories, setCategories] = useState([]); - useEffect(() => { - const url = `/api/categories?lang=${encodeURIComponent(langQuery(lang))}`; - const cached = readJSONCache(url); - if (cached) setCategories(itemsOrEmpty(cached)); - let cancelled = false; - getJSON(url) - .then((items) => { - if (!cancelled) setCategories(itemsOrEmpty(items)); - }) - .catch(() => { - if (!cancelled && !cached) setCategories([]); - }); - return () => { - cancelled = true; - }; - }, [lang]); - return categories; -} - -function FavoriteResourceCard({ resource }: { resource: Resource }) { - const { t } = useI18n(); - const lp = useLocalizedPath(); - const unavailable = resource.availability === "unavailable"; - const cover = resource.coverImage || resource.previewUrl; - - return ( -
- {!unavailable ? ( - - ) : null} -
- {cover && !unavailable ? ( - - ) : ( -
- -
- )} - {unavailable ? ( - - {t("favoritesUnavailable")} - - ) : null} -
- -
-

- {resource.title} -

- {resource.description ? ( -

- {resource.description} -

- ) : null} -
- {resource.categoryName ? ( - - {resource.categoryName} - - ) : null} - {resource.type ? ( - {resourceTypeLabel(t, resource.type)} - ) : null} - {resource.language ? ( - <> - · - {resourceLanguageLabel(t, resource.language)} - - ) : null} - {resource.updatedAt ? ( - <> - · - - - ) : null} - {typeof resource.favoriteCount === "number" ? ( - · ♥ {resource.favoriteCount} - ) : null} -
-
- - -
- ); -} - export default function Favorites() { - const { lang, t } = useI18n(); + const { t } = useI18n(); const wallet = useWallet(); const { markFavorite } = useFavorites(); - const categories = useCategories(lang); const [posts, setPosts] = useState< Awaited>["items"] >([]); @@ -195,14 +70,9 @@ export default function Favorites() { }; }, [markFavorite, reloadKey, t, wallet]); - const resources = useMemo( - () => posts.map((post) => postToResource(post, lang, categories)), - [posts, lang, categories], - ); - if (wallet.status === "loading") { return ( - + {Array.from({ length: 4 }).map((_, index) => ( ))} @@ -234,7 +104,7 @@ export default function Favorites() { } return ( -
+
{loading || !loaded ? ( Array.from({ length: 4 }).map((_, index) => ( @@ -251,7 +121,7 @@ export default function Favorites() { {t("walletRetry")}
- ) : resources.length === 0 ? ( + ) : posts.length === 0 ? (

@@ -262,8 +132,10 @@ export default function Favorites() {

) : ( - resources.map((resource) => ( - + posts.map((post, index) => ( + + + )) )}
From a9ec46e0087fed2dd91f9818392a06f79e00f3e1 Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 5 Jun 2026 18:16:33 +0800 Subject: [PATCH 68/89] fix: refresh favorites after unfavorite --- src/components/PopularRankList.tsx | 24 ++++++--- .../messageStream/MessageBubble.tsx | 13 ++++- src/favorites/FavoriteButton.tsx | 9 +++- src/favorites/FavoritesProvider.tsx | 14 +++--- src/pages/Favorites/index.tsx | 49 ++++++++++++++++--- 5 files changed, 88 insertions(+), 21 deletions(-) diff --git a/src/components/PopularRankList.tsx b/src/components/PopularRankList.tsx index 64c4a13..e9f71c4 100644 --- a/src/components/PopularRankList.tsx +++ b/src/components/PopularRankList.tsx @@ -83,14 +83,18 @@ function RankBadge({ index }: { index: number }) { ); } -function PopularRankRow({ +export function PopularRankRow({ post, index, categories, + browseSort = "popular", + onFavoriteChange, }: { post: Post; index: number; categories: Category[]; + browseSort?: string; + onFavoriteChange?: (postId: string, favorited: boolean) => void; }) { const { t, lang } = useI18n(); const navigate = useNavigate(); @@ -126,11 +130,12 @@ function PopularRankRow({
- + + onFavoriteChange?.(post.id, favorited) + } + /> {r.isDownloadable ? (
+
); } From 4e459aa4be85712b9514ff8c3a48e2f0767b055e Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 5 Jun 2026 18:25:17 +0800 Subject: [PATCH 70/89] fix: hide rank badges on favorites --- src/components/PopularRankList.tsx | 34 ++++++++++++++++++++++-------- src/pages/Favorites/index.tsx | 1 + 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/components/PopularRankList.tsx b/src/components/PopularRankList.tsx index e9f71c4..b43268b 100644 --- a/src/components/PopularRankList.tsx +++ b/src/components/PopularRankList.tsx @@ -88,12 +88,14 @@ export function PopularRankRow({ index, categories, browseSort = "popular", + showRank = true, onFavoriteChange, }: { post: Post; index: number; categories: Category[]; browseSort?: string; + showRank?: boolean; onFavoriteChange?: (postId: string, favorited: boolean) => void; }) { const { t, lang } = useI18n(); @@ -106,7 +108,7 @@ export function PopularRankRow({ const r = postToResource(post, lang, categories); const cover = r.coverImage && !coverFailed ? assetUrl(r.coverImage) : ""; - const isTop3 = index < MEDALS.length; + const isTop3 = showRank && index < MEDALS.length; const handleDownload = async () => { if (isDownloading || !r.downloadPostId || !r.downloadAttachmentId) return; @@ -127,7 +129,13 @@ export function PopularRankRow({ }; return ( -
+
) : null} From 908f89ac245bf7b3225fa324dba405a733bd7839 Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 5 Jun 2026 18:45:32 +0800 Subject: [PATCH 72/89] fix: clarify multi-document downloads --- .../messageStream/MessageBubble.tsx | 7 +++++++ .../messageStream/bubbles/FileDocBubble.tsx | 21 ++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/components/messageStream/MessageBubble.tsx b/src/components/messageStream/MessageBubble.tsx index 66d4105..4df78e4 100644 --- a/src/components/messageStream/MessageBubble.tsx +++ b/src/components/messageStream/MessageBubble.tsx @@ -9,6 +9,7 @@ import { VideoBubble } from "./bubbles/VideoBubble"; import { LinkPreviewCard } from "./LinkPreviewCard"; import { formatDateTime } from "./utils/formatTime"; import { FavoriteButton } from "../../favorites/FavoriteButton"; +import { BubbleAttachmentDownloadButton } from "./BubbleAttachmentDownloadButton"; export type MessageBubbleVariant = "default" | "latest"; @@ -106,6 +107,12 @@ export function MessageBubble({ onFavoriteChange?.(post.id, favorited) } /> + {isFileBubble && post.attachments.length === 1 ? ( + + ) : null}
) : null} diff --git a/src/components/messageStream/bubbles/FileDocBubble.tsx b/src/components/messageStream/bubbles/FileDocBubble.tsx index bb97223..2d44503 100644 --- a/src/components/messageStream/bubbles/FileDocBubble.tsx +++ b/src/components/messageStream/bubbles/FileDocBubble.tsx @@ -19,7 +19,15 @@ import { FavoriteButton } from "../../../favorites/FavoriteButton"; import { BubbleAttachmentDownloadButton } from "../BubbleAttachmentDownloadButton"; import type { MessageBubbleVariant } from "../MessageBubble"; -function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) { +function AttachmentRow({ + postId, + att, + showDownload, +}: { + postId: string; + att: Attachment; + showDownload: boolean; +}) { const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename }); const displayFilename = filenameWithExtension(att.filename, att.mime); const [previewFailed, setPreviewFailed] = useState(false); @@ -70,7 +78,9 @@ function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) { {formatBytes(att.sizeBytes)}
- + {showDownload ? ( + + ) : null}
); } @@ -195,7 +205,12 @@ export function FileDocBubble({ return (
{post.attachments.map((att) => ( - + = 2} + /> ))} {text ? ( From 2d003c6fef5542702a6247e37de04997e3f48829 Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 5 Jun 2026 18:47:04 +0800 Subject: [PATCH 73/89] fix: unify home download button sizing --- src/components/LatestUpdateCard.tsx | 6 +++--- src/components/RecommendedCard.tsx | 6 +++--- .../messageStream/BubbleAttachmentDownloadButton.tsx | 2 +- src/components/messageStream/bubbles/FileDocBubble.tsx | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/LatestUpdateCard.tsx b/src/components/LatestUpdateCard.tsx index e901272..43fc786 100644 --- a/src/components/LatestUpdateCard.tsx +++ b/src/components/LatestUpdateCard.tsx @@ -64,15 +64,15 @@ function LatestActions({ isDownloading ? t("downloading") : `Download ${attachment.filename}` } aria-busy={isDownloading} - className="relative z-20 flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#191921] transition hover:bg-[#22232D] disabled:cursor-wait" + className="relative z-20 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#191921] text-white outline-none transition hover:bg-[#22232D] focus-visible:ring-2 focus-visible:ring-ark-gold/70 disabled:cursor-wait" > {isDownloading ? ( ) : ( - + )} ) : null} diff --git a/src/components/RecommendedCard.tsx b/src/components/RecommendedCard.tsx index 486bdcb..f3566ca 100644 --- a/src/components/RecommendedCard.tsx +++ b/src/components/RecommendedCard.tsx @@ -193,7 +193,7 @@ export function RecommendedCard({ type="button" className={ useFigmaDesign - ? "relative z-20 flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#191921] text-white outline-none transition hover:bg-[#22232D] active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait" + ? "relative z-20 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#191921] text-white outline-none transition hover:bg-[#22232D] active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait" : "relative z-20 shrink-0 rounded-lg p-1 text-white outline-none transition hover:bg-ark-gold/10 active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait" } title={isDownloading ? t("downloading") : t("download")} @@ -208,11 +208,11 @@ export function RecommendedCard({ > {isDownloading ? ( ) : useFigmaDesign ? ( - + ) : ( )} diff --git a/src/components/messageStream/BubbleAttachmentDownloadButton.tsx b/src/components/messageStream/BubbleAttachmentDownloadButton.tsx index 7c3993a..68e8825 100644 --- a/src/components/messageStream/BubbleAttachmentDownloadButton.tsx +++ b/src/components/messageStream/BubbleAttachmentDownloadButton.tsx @@ -60,7 +60,7 @@ export function BubbleAttachmentDownloadButton({ {isDownloading ? ( ) : ( - + )} ); diff --git a/src/components/messageStream/bubbles/FileDocBubble.tsx b/src/components/messageStream/bubbles/FileDocBubble.tsx index 2d44503..a109184 100644 --- a/src/components/messageStream/bubbles/FileDocBubble.tsx +++ b/src/components/messageStream/bubbles/FileDocBubble.tsx @@ -167,7 +167,7 @@ function LatestFileCard({ post }: { post: Post }) { type="button" onClick={handleDownload} disabled={isDownloading} - className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#191921] transition hover:bg-[#22232D] disabled:cursor-wait" + className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#191921] transition hover:bg-[#22232D] disabled:cursor-wait" aria-label={ isDownloading ? t("downloading") : `Download ${att.filename}` } @@ -175,11 +175,11 @@ function LatestFileCard({ post }: { post: Post }) { > {isDownloading ? ( ) : ( - + )}
From 9f5367ae12997a331dbbbf4220c485a33b148f50 Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 5 Jun 2026 18:52:25 +0800 Subject: [PATCH 74/89] fix: show only selected post from favorites --- src/components/PopularRankList.tsx | 3 + .../messageStream/MessageStream.tsx | 74 ++++++++++++------- src/pages/Favorites/index.tsx | 1 + 3 files changed, 51 insertions(+), 27 deletions(-) diff --git a/src/components/PopularRankList.tsx b/src/components/PopularRankList.tsx index f73b295..a68f119 100644 --- a/src/components/PopularRankList.tsx +++ b/src/components/PopularRankList.tsx @@ -89,6 +89,7 @@ export function PopularRankRow({ categories, browseSort = "popular", showRank = true, + singlePostLink = false, onFavoriteChange, }: { post: Post; @@ -96,6 +97,7 @@ export function PopularRankRow({ categories: Category[]; browseSort?: string; showRank?: boolean; + singlePostLink?: boolean; onFavoriteChange?: (postId: string, favorited: boolean) => void; }) { const { t, lang } = useI18n(); @@ -142,6 +144,7 @@ export function PopularRankRow({ const params = new URLSearchParams(); if (browseSort) params.set("sort", browseSort); params.set("post", post.id); + if (singlePostLink) params.set("single", "1"); navigate(lp(`/browse?${params.toString()}`)); }} aria-label={r.title} diff --git a/src/components/messageStream/MessageStream.tsx b/src/components/messageStream/MessageStream.tsx index dac45ae..f37a2d5 100644 --- a/src/components/messageStream/MessageStream.tsx +++ b/src/components/messageStream/MessageStream.tsx @@ -24,6 +24,7 @@ export function MessageStream({ scope }: MessageStreamProps) { const type = sp.get("type") || "all"; const q = (sp.get("q") || "").trim(); const sort = sp.get("sort") || ""; + const singlePostMode = sp.get("single") === "1" && !!sp.get("post"); const params = useMemo( () => ({ scope, type, q, sort, lang }), @@ -55,6 +56,7 @@ export function MessageStream({ scope }: MessageStreamProps) { }, [q]); useEffect(() => { + if (singlePostMode) return; const el = sentinelRef.current; if (!el) return; const io = new IntersectionObserver( @@ -75,7 +77,7 @@ export function MessageStream({ scope }: MessageStreamProps) { ); io.observe(el); return () => io.disconnect(); - }, [loadMore]); + }, [loadMore, singlePostMode]); // When arriving with a `?post=` query (or legacy `#post-` hash), // scroll to that bubble — loading more pages until it shows up — then give @@ -90,13 +92,19 @@ export function MessageStream({ scope }: MessageStreamProps) { ); const [isFetchingTargetPost, setIsFetchingTargetPost] = useState(false); const [targetPostFetchFailed, setTargetPostFetchFailed] = useState(false); - const targetAlreadyInBaseItems = useMemo( + const baseTargetPost = useMemo( () => - !!queryTargetPostId && - items.some((post) => post.id === queryTargetPostId), + queryTargetPostId + ? (items.find((post) => post.id === queryTargetPostId) ?? null) + : null, [items, queryTargetPostId], ); + const targetAlreadyInBaseItems = !!baseTargetPost; const streamItems = useMemo(() => { + if (singlePostMode) { + if (baseTargetPost) return [baseTargetPost]; + return resolvedTargetPost ? [resolvedTargetPost] : []; + } if ( resolvedTargetPost && !items.some((post) => post.id === resolvedTargetPost.id) @@ -104,7 +112,7 @@ export function MessageStream({ scope }: MessageStreamProps) { return [resolvedTargetPost, ...items]; } return items; - }, [items, resolvedTargetPost]); + }, [baseTargetPost, items, resolvedTargetPost, singlePostMode]); const groups = useGroupedByDay(streamItems, lang); // Lock only engages while we are actively running the smooth-scroll animation // — not during the wait/pagination phase — so the page never feels frozen @@ -355,30 +363,40 @@ export function MessageStream({ scope }: MessageStreamProps) { // their specific post, not just lazily loading the feed. const targetInLoadedItems = !!queryTargetPostId && streamItems.some((p) => p.id === queryTargetPostId); - const isSearchingDeepTarget = - !!queryTargetPostId && - !targetInLoadedItems && - !error && - (isFetchingTargetPost || hasMore || isLoading); - const targetNotFoundInStream = - !!queryTargetPostId && - !targetInLoadedItems && - !error && - targetPostFetchFailed && - !hasMore && - !isLoading && - streamItems.length > 0; + const isSearchingDeepTarget = singlePostMode + ? !!queryTargetPostId && !targetInLoadedItems && isFetchingTargetPost + : !!queryTargetPostId && + !targetInLoadedItems && + !error && + (isFetchingTargetPost || hasMore || isLoading); + const targetNotFoundInStream = singlePostMode + ? !!queryTargetPostId && + !targetInLoadedItems && + targetPostFetchFailed && + !isFetchingTargetPost + : !!queryTargetPostId && + !targetInLoadedItems && + !error && + targetPostFetchFailed && + !hasMore && + !isLoading && + streamItems.length > 0; return (
{/* Filters stay pinned below the global header (which shows the page name) so users can switch filters while scrolling. */} -
- updateParam("type", v)} /> -
+ {!singlePostMode ? ( +
+ updateParam("type", v)} + /> +
+ ) : null}
{isSearchingDeepTarget ? ( @@ -432,7 +450,7 @@ export function MessageStream({ scope }: MessageStreamProps) {

) : null} - {error ? ( + {!singlePostMode && error ? (
) : null} - {isLoading && !error ? ( + {!singlePostMode && isLoading && !error ? (
)} -
+ {!singlePostMode ? ( +
+ ) : null}
); diff --git a/src/pages/Favorites/index.tsx b/src/pages/Favorites/index.tsx index 8245ecd..1514f7c 100644 --- a/src/pages/Favorites/index.tsx +++ b/src/pages/Favorites/index.tsx @@ -169,6 +169,7 @@ export default function Favorites() { categories={categories} browseSort="" showRank={false} + singlePostLink onFavoriteChange={(_, favorited) => { if (!favorited) setReloadKey((value) => value + 1); }} From abfd92b16ac4ac1eea8b34a086143142aad801c9 Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 5 Jun 2026 18:56:11 +0800 Subject: [PATCH 75/89] fix: avoid unnecessary favorites reloads --- src/favorites/FavoritesProvider.tsx | 5 ++++ src/pages/Favorites/index.tsx | 45 ++++++++++++++++++++++++----- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/favorites/FavoritesProvider.tsx b/src/favorites/FavoritesProvider.tsx index 81c2946..9ac816d 100644 --- a/src/favorites/FavoritesProvider.tsx +++ b/src/favorites/FavoritesProvider.tsx @@ -23,6 +23,7 @@ type FavoriteStatus = "unknown" | "favorited" | "notFavorited"; type FavoritesContextValue = { favoriteIds: Set; pendingIds: Set; + mutationVersion: number; statusFor: (resourceId: string) => FavoriteStatus; ensureFavoriteIds: (resourceIds: string[]) => Promise; toggleFavorite: (resourceId: string) => Promise; @@ -45,6 +46,7 @@ export function FavoritesProvider({ children }: { children: ReactNode }) { const [favoriteIds, setFavoriteIds] = useState>(() => new Set()); const [knownIds, setKnownIds] = useState>(() => new Set()); const [pendingIds, setPendingIds] = useState>(() => new Set()); + const [mutationVersion, setMutationVersion] = useState(0); const pendingAfterLoginRef = useRef(null); const lastAddressRef = useRef(null); const knownIdsRef = useRef>(new Set()); @@ -170,6 +172,7 @@ export function FavoritesProvider({ children }: { children: ReactNode }) { showToast( currentlyFavorite ? t("favoriteRemoved") : t("favoriteAdded"), ); + setMutationVersion((value) => value + 1); return nextFavorited; } catch (error) { markFavorite(resourceId, currentlyFavorite); @@ -221,6 +224,7 @@ export function FavoritesProvider({ children }: { children: ReactNode }) { () => ({ favoriteIds, pendingIds, + mutationVersion, statusFor, ensureFavoriteIds, toggleFavorite, @@ -230,6 +234,7 @@ export function FavoritesProvider({ children }: { children: ReactNode }) { ensureFavoriteIds, favoriteIds, markFavorite, + mutationVersion, pendingIds, statusFor, toggleFavorite, diff --git a/src/pages/Favorites/index.tsx b/src/pages/Favorites/index.tsx index 1514f7c..386ac8f 100644 --- a/src/pages/Favorites/index.tsx +++ b/src/pages/Favorites/index.tsx @@ -12,6 +12,17 @@ import { useWallet } from "../../wallet/WalletProvider"; const pageSize = 50; +type FavoritePosts = Awaited>["items"]; + +type FavoriteListCache = { + address: string; + lang: Lang; + mutationVersion: number; + posts: FavoritePosts; +}; + +let favoriteListCache: FavoriteListCache | null = null; + function useCategories(lang: Lang): Category[] { const [categories, setCategories] = useState(() => { const cached = readJSONCache( @@ -43,11 +54,9 @@ function useCategories(lang: Lang): Category[] { export default function Favorites() { const { lang, t } = useI18n(); const wallet = useWallet(); - const { markFavorite } = useFavorites(); + const { markFavorite, mutationVersion } = useFavorites(); const categories = useCategories(lang); - const [posts, setPosts] = useState< - Awaited>["items"] - >([]); + const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(false); const [loaded, setLoaded] = useState(false); const [error, setError] = useState(""); @@ -56,7 +65,7 @@ export default function Favorites() { useSetPageTitle(t("favorites")); useEffect(() => { - if (!wallet.token || wallet.status !== "loggedIn") { + if (!wallet.token || wallet.status !== "loggedIn" || !wallet.address) { setPosts([]); setLoading(false); setLoaded(false); @@ -64,18 +73,40 @@ export default function Favorites() { return; } + const walletAddress = wallet.address; + const walletToken = wallet.token; + + if ( + reloadKey === 0 && + favoriteListCache?.address === walletAddress && + favoriteListCache.lang === lang && + favoriteListCache.mutationVersion === mutationVersion + ) { + setPosts(favoriteListCache.posts); + setLoading(false); + setLoaded(true); + setError(""); + return; + } + let cancelled = false; setLoading(true); setLoaded(false); setError(""); - listFavorites(wallet.token, { + listFavorites(walletToken, { limit: pageSize, includeUnavailable: true, }) .then((data) => { if (cancelled) return; const items = itemsOrEmpty(data.items); + favoriteListCache = { + address: walletAddress, + lang, + mutationVersion, + posts: items, + }; setPosts(items); items.forEach((post) => markFavorite(post.id, true)); setLoaded(true); @@ -97,7 +128,7 @@ export default function Favorites() { return () => { cancelled = true; }; - }, [markFavorite, reloadKey, t, wallet]); + }, [lang, markFavorite, mutationVersion, reloadKey, t, wallet]); if (wallet.status === "loading") { return ( From 7a33a62c8f2105cf9f000e1ebfada68281a4ae47 Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 5 Jun 2026 19:06:53 +0800 Subject: [PATCH 76/89] 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. --- src/App.tsx | 204 +++++++++--------- src/components/InAppDownloadGuide.tsx | 179 +++++++++++++++ src/components/LatestUpdateCard.tsx | 4 +- .../messageStream/AttachmentDownloadPill.tsx | 4 +- .../BubbleAttachmentDownloadButton.tsx | 4 +- .../messageStream/bubbles/FileDocBubble.tsx | 4 +- .../messageStream/bubbles/VideoBubble.tsx | 4 +- .../messageStream/utils/downloadFile.ts | 70 +++++- src/locales/en.ts | 12 ++ src/locales/id.ts | 12 ++ src/locales/ja.ts | 12 ++ src/locales/ko.ts | 12 ++ src/locales/ms.ts | 12 ++ src/locales/vi.ts | 12 ++ src/locales/zh-CN.ts | 12 ++ src/utils/inAppBrowser.ts | 43 ++++ 16 files changed, 494 insertions(+), 106 deletions(-) create mode 100644 src/components/InAppDownloadGuide.tsx create mode 100644 src/utils/inAppBrowser.ts diff --git a/src/App.tsx b/src/App.tsx index 0888165..66ef903 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { I18nProvider } from "./i18n"; import { MotionProvider } from "./motion"; import { ToastProvider } from "./components/Toast"; import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide"; +import { InAppDownloadGuideProvider } from "./components/InAppDownloadGuide"; import { FavoritesProvider } from "./favorites/FavoritesProvider"; import { AutoInjectedLogin } from "./wallet/AutoInjectedLogin"; import { RainbowWalletProvider } from "./wallet/RainbowWalletProvider"; @@ -62,120 +63,127 @@ export default function App() { - - - - - - - - - }> - } - /> - } /> - } - /> - } - /> - } - /> - } /> - } - /> - } - /> + + + + + + + + + + }> + + } + /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> - {localizedHomeRoutes.map((route) => ( - + {localizedHomeRoutes.map((route) => ( + + + } + /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + ))} + + + {/* Legacy long-form language URLs → short-code + redirects. Shared links (e.g. WeChat) keep working. */} + {legacyLanguageRedirects.map((redirect) => ( + + } /> - } /> } - /> - } - /> - } - /> - } - /> - } - /> - } + path={`${redirect.from}/*`} + element={ + + } /> ))} - - {/* Legacy long-form language URLs → short-code - redirects. Shared links (e.g. WeChat) keep working. */} - {legacyLanguageRedirects.map((redirect) => ( - + {adminEnabled ? ( + AdminRouteTree() + ) : ( - } + path={`${adminUiPrefix}/*`} + element={} /> - - } - /> - - ))} + )} - {adminEnabled ? ( - AdminRouteTree() - ) : ( } /> - )} - - } - /> - - - - - - - + + + + + + + + diff --git a/src/components/InAppDownloadGuide.tsx b/src/components/InAppDownloadGuide.tsx new file mode 100644 index 0000000..65acbc5 --- /dev/null +++ b/src/components/InAppDownloadGuide.tsx @@ -0,0 +1,179 @@ +import { Copy, X } from "lucide-react"; +import { useEffect, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { useI18n } from "../i18n"; +import { useToast } from "./Toast"; +import { + IN_APP_DOWNLOAD_GUIDE_EVENT, + type InAppDownloadGuideDetail, +} from "./messageStream/utils/downloadFile"; +import { inAppBrowserName } from "../utils/inAppBrowser"; + +async function copyTextToClipboard(text: string): Promise { + try { + if ( + typeof navigator !== "undefined" && + navigator.clipboard && + typeof navigator.clipboard.writeText === "function" + ) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // fall through to legacy path + } + try { + const ta = document.createElement("textarea"); + ta.value = text; + ta.setAttribute("readonly", ""); + ta.style.position = "fixed"; + ta.style.top = "0"; + ta.style.left = "0"; + ta.style.opacity = "0"; + document.body.append(ta); + ta.select(); + const ok = document.execCommand("copy"); + ta.remove(); + return ok; + } catch { + return false; + } +} + +export function InAppDownloadGuideProvider({ + children, +}: { + children: ReactNode; +}) { + const { t } = useI18n(); + const { showToast } = useToast(); + const [detail, setDetail] = useState(null); + + useEffect(() => { + const onShow = (event: Event) => { + const ce = event as CustomEvent; + if (!ce.detail) return; + setDetail(ce.detail); + }; + window.addEventListener(IN_APP_DOWNLOAD_GUIDE_EVENT, onShow); + return () => + window.removeEventListener(IN_APP_DOWNLOAD_GUIDE_EVENT, onShow); + }, []); + + useEffect(() => { + if (!detail) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") setDetail(null); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [detail]); + + const close = () => setDetail(null); + + const handleCopy = async () => { + if (!detail) return; + const ok = await copyTextToClipboard(detail.url); + if (ok) { + showToast(t("inAppDownloadCopied")); + } else { + showToast(t("inAppDownloadCopyFail"), "error"); + } + }; + + const browser = inAppBrowserName(); + const intro = browser + ? t("inAppDownloadIntroNamed").replace("{browser}", browser) + : t("inAppDownloadIntro"); + + return ( + <> + {children} + {detail + ? createPortal( +
+
event.stopPropagation()} + > +
+
+

+ {t("download")} +

+

+ {t("inAppDownloadTitle")} +

+
+ +
+ +
+

{intro}

+ +
    +
  1. + + 1 + + + {t("inAppDownloadStepCopy")} + +
  2. +
  3. + + 2 + + + {t("inAppDownloadStepOpen")} + +
  4. +
  5. + + 3 + + + {t("inAppDownloadStepDownload")} + +
  6. +
+ +
+

+ {detail.url} +

+
+ + +
+
+
, + document.body, + ) + : null} + + ); +} diff --git a/src/components/LatestUpdateCard.tsx b/src/components/LatestUpdateCard.tsx index 43fc786..ef30bff 100644 --- a/src/components/LatestUpdateCard.tsx +++ b/src/components/LatestUpdateCard.tsx @@ -38,7 +38,9 @@ function LatestActions({ if (!attachment || isDownloading) return; setIsDownloading(true); try { - await downloadAttachment(post.id, attachment.id, attachment.filename); + await downloadAttachment(post.id, attachment.id, attachment.filename, { + sizeBytes: attachment.sizeBytes, + }); const mediaKind = mediaSaveKindFromAttachment(attachment); if (mediaKind) showSaveToAlbumGuide(mediaKind); } catch { diff --git a/src/components/messageStream/AttachmentDownloadPill.tsx b/src/components/messageStream/AttachmentDownloadPill.tsx index 6ddbedc..863d46d 100644 --- a/src/components/messageStream/AttachmentDownloadPill.tsx +++ b/src/components/messageStream/AttachmentDownloadPill.tsx @@ -53,7 +53,9 @@ export function AttachmentDownloadPill({ pauseActiveVideos(); setIsDownloading(true); try { - await downloadAttachment(postId, attachment.id, attachment.filename); + await downloadAttachment(postId, attachment.id, attachment.filename, { + sizeBytes: attachment.sizeBytes, + }); const mediaKind = mediaSaveKindFromAttachment(attachment); if (mediaKind) showSaveToAlbumGuide(mediaKind); } catch { diff --git a/src/components/messageStream/BubbleAttachmentDownloadButton.tsx b/src/components/messageStream/BubbleAttachmentDownloadButton.tsx index 68e8825..ee425b3 100644 --- a/src/components/messageStream/BubbleAttachmentDownloadButton.tsx +++ b/src/components/messageStream/BubbleAttachmentDownloadButton.tsx @@ -32,7 +32,9 @@ export function BubbleAttachmentDownloadButton({ pauseActiveVideos(); setIsDownloading(true); try { - await downloadAttachment(postId, attachment.id, displayFilename); + await downloadAttachment(postId, attachment.id, displayFilename, { + sizeBytes: attachment.sizeBytes, + }); const mediaKind = mediaSaveKindFromAttachment(attachment); if (mediaKind) showSaveToAlbumGuide(mediaKind); } catch { diff --git a/src/components/messageStream/bubbles/FileDocBubble.tsx b/src/components/messageStream/bubbles/FileDocBubble.tsx index a109184..69e20ae 100644 --- a/src/components/messageStream/bubbles/FileDocBubble.tsx +++ b/src/components/messageStream/bubbles/FileDocBubble.tsx @@ -102,7 +102,9 @@ function LatestFileCard({ post }: { post: Post }) { if (isDownloading) return; setIsDownloading(true); try { - await downloadAttachment(post.id, att.id, displayFilename); + await downloadAttachment(post.id, att.id, displayFilename, { + sizeBytes: att.sizeBytes, + }); const mediaKind = mediaSaveKindFromAttachment(att); if (mediaKind) showSaveToAlbumGuide(mediaKind); } catch { diff --git a/src/components/messageStream/bubbles/VideoBubble.tsx b/src/components/messageStream/bubbles/VideoBubble.tsx index 6b096be..a6a2232 100644 --- a/src/components/messageStream/bubbles/VideoBubble.tsx +++ b/src/components/messageStream/bubbles/VideoBubble.tsx @@ -183,7 +183,9 @@ function AttachmentListDownloadButton({ pauseActiveVideos(); setIsDownloading(true); try { - await downloadAttachment(postId, attachment.id, attachment.filename); + await downloadAttachment(postId, attachment.id, attachment.filename, { + sizeBytes: attachment.sizeBytes, + }); showSaveToAlbumGuide("video"); } catch { showToast(t("downloadFail"), "error"); diff --git a/src/components/messageStream/utils/downloadFile.ts b/src/components/messageStream/utils/downloadFile.ts index 4e26764..a1787ff 100644 --- a/src/components/messageStream/utils/downloadFile.ts +++ b/src/components/messageStream/utils/downloadFile.ts @@ -1,4 +1,18 @@ import { assetUrl } from "../../../api"; +import { isInAppBrowser } from "../../../utils/inAppBrowser"; + +export const IN_APP_DOWNLOAD_GUIDE_EVENT = "ark:in-app-download-guide"; + +export type InAppDownloadGuideDetail = { url: string; filename: string }; + +/** + * Files larger than this skip the fetch→Blob path so mobile WebViews and + * memory-constrained devices do not OOM. They fall back to the anchor + * download (relies on the backend's `Content-Disposition: attachment`). + */ +const MAX_BLOB_DOWNLOAD_BYTES = 50 * 1024 * 1024; + +export type DownloadOptions = { sizeBytes?: number }; export function pauseActiveVideos() { document.querySelectorAll("video").forEach((video) => { @@ -18,12 +32,62 @@ export async function downloadAttachment( postId: string, attachmentId: string, filename: string, + options?: DownloadOptions, ) { - return downloadFile(attachmentDownloadUrl(postId, attachmentId), filename); + return downloadFile( + attachmentDownloadUrl(postId, attachmentId), + filename, + options, + ); } -export async function downloadFile(url: string, filename: string) { - triggerDownload(url, filename || "download"); +export async function downloadFile( + url: string, + filename: string, + options?: DownloadOptions, +) { + const safeFilename = filename || "download"; + + // In-app WebViews (WeChat / TokenPocket / Telegram / iOS WKWebView / …) + // ignore `Content-Disposition: attachment` and have no system download + // manager, so an anchor click would just open the file inline. Surface an + // "open in external browser" guide instead of silently failing the user. + if (typeof window !== "undefined" && isInAppBrowser()) { + window.dispatchEvent( + new CustomEvent(IN_APP_DOWNLOAD_GUIDE_EVENT, { + detail: { url, filename: safeFilename }, + }), + ); + return; + } + + // Normal browsers: prefer fetch → Blob → object URL so the file always lands + // in the Downloads folder with the original filename, even when the browser + // would otherwise inline-preview the response (Chrome and Safari do this for + // PDFs / images regardless of Content-Disposition). Fall back to the anchor + // download for large files (avoid loading them entirely into memory) or when + // fetch fails for any reason. + const sizeBytes = options?.sizeBytes ?? 0; + if (sizeBytes <= MAX_BLOB_DOWNLOAD_BYTES) { + try { + const res = await fetch(url, { credentials: "omit" }); + if (res.ok) { + const blob = await res.blob(); + const objectUrl = URL.createObjectURL(blob); + try { + triggerDownload(objectUrl, safeFilename); + } finally { + // Give the browser a moment to start the download before revoking. + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 4000); + } + return; + } + } catch { + // fall through to anchor fallback + } + } + + triggerDownload(url, safeFilename); } function triggerDownload(url: string, filename: string) { diff --git a/src/locales/en.ts b/src/locales/en.ts index c3e97cf..dd01afb 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -264,4 +264,16 @@ export const enDict: Dict = { featureUnavailableDesc: "This feature is not available yet.", confirm: "Got it", backToHome: "Back to Home", + inAppDownloadTitle: "Please open in your system browser to download", + inAppDownloadIntro: + "Your current in-app browser cannot download files. Open the page in your system browser (Safari, Chrome, etc.) and tap download again.", + inAppDownloadIntroNamed: + "{browser} cannot download files directly. Open the page in your system browser (Safari, Chrome, etc.) and tap download again.", + inAppDownloadStepCopy: "Tap “Copy link” below.", + inAppDownloadStepOpen: + "Open the menu (top-right), choose “Open in browser”, and paste the link if needed.", + inAppDownloadStepDownload: + "Tap the download button on the page — the file will save to your downloads.", + inAppDownloadCopied: "Link copied", + inAppDownloadCopyFail: "Could not copy the link, please copy it manually", }; diff --git a/src/locales/id.ts b/src/locales/id.ts index 2111f0d..568bc52 100644 --- a/src/locales/id.ts +++ b/src/locales/id.ts @@ -267,4 +267,16 @@ export const idDict: Dict = { featureUnavailableDesc: "Fitur ini belum tersedia.", confirm: "Mengerti", backToHome: "Kembali ke Beranda", + inAppDownloadTitle: "Silakan buka di peramban sistem untuk mengunduh", + inAppDownloadIntro: + "Peramban dalam aplikasi saat ini tidak dapat mengunduh berkas. Buka halaman ini di peramban sistem (Safari, Chrome, dll.) lalu ketuk unduh lagi.", + inAppDownloadIntroNamed: + "{browser} tidak dapat mengunduh berkas secara langsung. Buka halaman ini di peramban sistem (Safari, Chrome, dll.) lalu ketuk unduh lagi.", + inAppDownloadStepCopy: "Ketuk “Salin tautan” di bawah.", + inAppDownloadStepOpen: + "Buka menu di kanan atas, pilih “Buka di peramban”, tempelkan tautan jika diperlukan.", + inAppDownloadStepDownload: + "Di peramban sistem, ketuk lagi tombol unduh dan berkas akan tersimpan.", + inAppDownloadCopied: "Tautan disalin", + inAppDownloadCopyFail: "Tidak dapat menyalin, silakan salin secara manual", }; diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 0eedd3a..c96b231 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -268,4 +268,16 @@ export const jaDict: Dict = { featureUnavailableDesc: "この機能はまだご利用いただけません。", confirm: "了解", backToHome: "ホームへ戻る", + inAppDownloadTitle: "システムブラウザで開いてダウンロードしてください", + inAppDownloadIntro: + "現在のアプリ内ブラウザはファイルをダウンロードできません。Safari や Chrome などのシステムブラウザでページを開いてから、もう一度ダウンロードしてください。", + inAppDownloadIntroNamed: + "{browser} のアプリ内ブラウザはファイルを直接ダウンロードできません。Safari や Chrome などのシステムブラウザでページを開いてから、もう一度ダウンロードしてください。", + inAppDownloadStepCopy: "下の「リンクをコピー」をタップします。", + inAppDownloadStepOpen: + "右上のメニューから「ブラウザで開く」を選び、必要に応じてリンクを貼り付けます。", + inAppDownloadStepDownload: + "システムブラウザで再度ダウンロードボタンをタップすると、ファイルがダウンロード先に保存されます。", + inAppDownloadCopied: "リンクをコピーしました", + inAppDownloadCopyFail: "コピーに失敗しました。手動でコピーしてください", }; diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 17a8f73..d2eaaad 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -263,4 +263,16 @@ export const koDict: Dict = { featureUnavailableDesc: "이 기능은 아직 사용할 수 없습니다.", confirm: "확인", backToHome: "홈으로", + inAppDownloadTitle: "시스템 브라우저에서 열어 다운로드하세요", + inAppDownloadIntro: + "현재 앱 내 브라우저는 파일을 다운로드할 수 없습니다. Safari나 Chrome 등 시스템 브라우저에서 페이지를 연 다음 다시 다운로드해 주세요.", + inAppDownloadIntroNamed: + "{browser} 앱 내 브라우저는 파일을 직접 다운로드할 수 없습니다. Safari나 Chrome 등 시스템 브라우저에서 페이지를 연 다음 다시 다운로드해 주세요.", + inAppDownloadStepCopy: "아래의 “링크 복사”를 누릅니다.", + inAppDownloadStepOpen: + "오른쪽 위 메뉴에서 “브라우저로 열기”를 선택하고 필요하면 링크를 붙여 넣습니다.", + inAppDownloadStepDownload: + "시스템 브라우저에서 다운로드 버튼을 다시 누르면 파일이 저장됩니다.", + inAppDownloadCopied: "링크를 복사했습니다", + inAppDownloadCopyFail: "복사하지 못했습니다. 직접 복사해 주세요", }; diff --git a/src/locales/ms.ts b/src/locales/ms.ts index 69d482d..b7a8f62 100644 --- a/src/locales/ms.ts +++ b/src/locales/ms.ts @@ -266,4 +266,16 @@ export const msDict: Dict = { featureUnavailableDesc: "Ciri ini belum tersedia.", confirm: "Faham", backToHome: "Kembali ke Laman Utama", + inAppDownloadTitle: "Sila buka dalam pelayar sistem untuk muat turun", + inAppDownloadIntro: + "Pelayar dalam aplikasi semasa tidak dapat memuat turun fail. Buka halaman ini dalam pelayar sistem (Safari, Chrome, dsb.), kemudian ketik muat turun semula.", + inAppDownloadIntroNamed: + "{browser} tidak dapat memuat turun fail secara langsung. Buka halaman ini dalam pelayar sistem (Safari, Chrome, dsb.), kemudian ketik muat turun semula.", + inAppDownloadStepCopy: "Ketik “Salin pautan” di bawah.", + inAppDownloadStepOpen: + "Buka menu di atas kanan, pilih “Buka dalam pelayar”, tampal pautan jika perlu.", + inAppDownloadStepDownload: + "Dalam pelayar sistem, ketik butang muat turun semula dan fail akan disimpan.", + inAppDownloadCopied: "Pautan disalin", + inAppDownloadCopyFail: "Tidak dapat menyalin, sila salin secara manual", }; diff --git a/src/locales/vi.ts b/src/locales/vi.ts index ebd3e2c..e0c3a57 100644 --- a/src/locales/vi.ts +++ b/src/locales/vi.ts @@ -262,4 +262,16 @@ export const viDict: Dict = { featureUnavailableDesc: "Tính năng này hiện chưa khả dụng.", confirm: "Đã hiểu", backToHome: "Về trang chủ", + inAppDownloadTitle: "Vui lòng mở bằng trình duyệt hệ thống để tải", + inAppDownloadIntro: + "Trình duyệt trong ứng dụng hiện tại không thể tải tệp. Hãy mở trang trong trình duyệt hệ thống (Safari, Chrome…) rồi nhấn tải lại.", + inAppDownloadIntroNamed: + "{browser} không thể tải tệp trực tiếp. Hãy mở trang trong trình duyệt hệ thống (Safari, Chrome…) rồi nhấn tải lại.", + inAppDownloadStepCopy: "Nhấn “Sao chép liên kết” bên dưới.", + inAppDownloadStepOpen: + "Mở menu ở góc trên bên phải, chọn “Mở bằng trình duyệt”, dán liên kết nếu cần.", + inAppDownloadStepDownload: + "Trong trình duyệt hệ thống, nhấn lại nút tải để lưu tệp.", + inAppDownloadCopied: "Đã sao chép liên kết", + inAppDownloadCopyFail: "Không sao chép được, vui lòng tự sao chép", }; diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index d0a3791..e947052 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -245,4 +245,16 @@ export const zhDict: Dict = { featureUnavailableDesc: "该功能暂未开放。", confirm: "知道了", backToHome: "返回首页", + inAppDownloadTitle: "请使用系统浏览器打开后下载", + inAppDownloadIntro: + "当前内置浏览器无法直接下载文件,请在系统浏览器(Safari、Chrome 等)中打开页面后再点击下载。", + inAppDownloadIntroNamed: + "{browser} 内置浏览器无法直接下载文件,请在系统浏览器(Safari、Chrome 等)中打开页面后再点击下载。", + inAppDownloadStepCopy: "点击下方「复制链接」。", + inAppDownloadStepOpen: + "点右上角菜单,选择「在浏览器中打开」,如需要请把刚才复制的链接粘贴进地址栏。", + inAppDownloadStepDownload: + "在系统浏览器中再次点击下载按钮,文件会保存到下载文件夹。", + inAppDownloadCopied: "链接已复制", + inAppDownloadCopyFail: "复制失败,请手动复制", }; diff --git a/src/utils/inAppBrowser.ts b/src/utils/inAppBrowser.ts new file mode 100644 index 0000000..575275a --- /dev/null +++ b/src/utils/inAppBrowser.ts @@ -0,0 +1,43 @@ +/** + * Detect popular in-app WebViews that block file downloads or ignore + * `Content-Disposition: attachment`, so the UI can show an "open in external + * browser" guide instead of silently opening the file inline. + */ + +function ua(): string { + if (typeof navigator === "undefined") return ""; + return navigator.userAgent || ""; +} + +const PATTERNS: Array<{ re: RegExp; name: string }> = [ + { re: /MicroMessenger/i, name: "WeChat" }, + { re: /TokenPocket/i, name: "TokenPocket" }, + { re: /imToken/i, name: "imToken" }, + { re: /Trust(Wallet|Browser)/i, name: "Trust Wallet" }, + { re: /MetaMask/i, name: "MetaMask" }, + { re: /Telegram/i, name: "Telegram" }, + { re: /\bLine\//i, name: "LINE" }, + { re: /FBAN|FBAV/i, name: "Facebook" }, + { re: /Instagram/i, name: "Instagram" }, + { re: /Twitter|\bX\//i, name: "Twitter/X" }, + { re: /Weibo/i, name: "Weibo" }, + { re: /MQQBrowser/i, name: "QQ Browser" }, + { re: /\bQQ\//i, name: "QQ" }, + { re: /MiuiBrowser/i, name: "Mi Browser" }, + { re: /Snapchat/i, name: "Snapchat" }, +]; + +export function isInAppBrowser(): boolean { + const agent = ua(); + if (!agent) return false; + return PATTERNS.some(({ re }) => re.test(agent)); +} + +export function inAppBrowserName(): string | null { + const agent = ua(); + if (!agent) return null; + for (const { re, name } of PATTERNS) { + if (re.test(agent)) return name; + } + return null; +} From 356d8a0207ca257f8e7374dd9d09b9b4571221a9 Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 5 Jun 2026 19:10:44 +0800 Subject: [PATCH 77/89] fix: use absolute URL in in-app download guide 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. --- src/components/messageStream/utils/downloadFile.ts | 12 +++++++++++- src/locales/en.ts | 11 ++++++----- src/locales/id.ts | 12 ++++++------ src/locales/ja.ts | 12 ++++++------ src/locales/ko.ts | 12 ++++++------ src/locales/ms.ts | 11 ++++++----- src/locales/vi.ts | 12 ++++++------ src/locales/zh-CN.ts | 11 +++++------ 8 files changed, 52 insertions(+), 41 deletions(-) diff --git a/src/components/messageStream/utils/downloadFile.ts b/src/components/messageStream/utils/downloadFile.ts index a1787ff..326d604 100644 --- a/src/components/messageStream/utils/downloadFile.ts +++ b/src/components/messageStream/utils/downloadFile.ts @@ -55,7 +55,7 @@ export async function downloadFile( if (typeof window !== "undefined" && isInAppBrowser()) { window.dispatchEvent( new CustomEvent(IN_APP_DOWNLOAD_GUIDE_EVENT, { - detail: { url, filename: safeFilename }, + detail: { url: toAbsoluteUrl(url), filename: safeFilename }, }), ); return; @@ -90,6 +90,16 @@ export async function downloadFile( triggerDownload(url, safeFilename); } +function toAbsoluteUrl(url: string): string { + if (/^https?:\/\//i.test(url)) return url; + if (typeof window === "undefined") return url; + try { + return new URL(url, window.location.origin).toString(); + } catch { + return url; + } +} + function triggerDownload(url: string, filename: string) { const a = document.createElement("a"); a.href = url; diff --git a/src/locales/en.ts b/src/locales/en.ts index dd01afb..ef79b74 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -266,14 +266,15 @@ export const enDict: Dict = { backToHome: "Back to Home", inAppDownloadTitle: "Please open in your system browser to download", inAppDownloadIntro: - "Your current in-app browser cannot download files. Open the page in your system browser (Safari, Chrome, etc.) and tap download again.", + "Your current in-app browser cannot download files. Copy the link below and open it in your system browser — the file will save directly.", inAppDownloadIntroNamed: - "{browser} cannot download files directly. Open the page in your system browser (Safari, Chrome, etc.) and tap download again.", - inAppDownloadStepCopy: "Tap “Copy link” below.", + "{browser} cannot download files directly. Copy the link below and open it in your system browser — the file will save directly.", + inAppDownloadStepCopy: + 'Tap "Copy link" below — that is the direct file download URL.', inAppDownloadStepOpen: - "Open the menu (top-right), choose “Open in browser”, and paste the link if needed.", + "Open your system browser (Safari, Chrome, etc.) and paste the link into the address bar.", inAppDownloadStepDownload: - "Tap the download button on the page — the file will save to your downloads.", + "The file will start downloading to your downloads folder automatically.", inAppDownloadCopied: "Link copied", inAppDownloadCopyFail: "Could not copy the link, please copy it manually", }; diff --git a/src/locales/id.ts b/src/locales/id.ts index 568bc52..583f9b0 100644 --- a/src/locales/id.ts +++ b/src/locales/id.ts @@ -269,14 +269,14 @@ export const idDict: Dict = { backToHome: "Kembali ke Beranda", inAppDownloadTitle: "Silakan buka di peramban sistem untuk mengunduh", inAppDownloadIntro: - "Peramban dalam aplikasi saat ini tidak dapat mengunduh berkas. Buka halaman ini di peramban sistem (Safari, Chrome, dll.) lalu ketuk unduh lagi.", + "Peramban dalam aplikasi saat ini tidak dapat mengunduh berkas. Salin tautan di bawah dan buka di peramban sistem — berkas akan langsung tersimpan.", inAppDownloadIntroNamed: - "{browser} tidak dapat mengunduh berkas secara langsung. Buka halaman ini di peramban sistem (Safari, Chrome, dll.) lalu ketuk unduh lagi.", - inAppDownloadStepCopy: "Ketuk “Salin tautan” di bawah.", + "{browser} tidak dapat mengunduh berkas secara langsung. Salin tautan di bawah dan buka di peramban sistem — berkas akan langsung tersimpan.", + inAppDownloadStepCopy: + 'Ketuk "Salin tautan" di bawah (ini adalah URL unduhan langsung berkas).', inAppDownloadStepOpen: - "Buka menu di kanan atas, pilih “Buka di peramban”, tempelkan tautan jika diperlukan.", - inAppDownloadStepDownload: - "Di peramban sistem, ketuk lagi tombol unduh dan berkas akan tersimpan.", + "Buka peramban sistem (Safari, Chrome, dll.) dan tempel tautan ke bilah alamat.", + inAppDownloadStepDownload: "Berkas akan otomatis terunduh ke folder Unduhan.", inAppDownloadCopied: "Tautan disalin", inAppDownloadCopyFail: "Tidak dapat menyalin, silakan salin secara manual", }; diff --git a/src/locales/ja.ts b/src/locales/ja.ts index c96b231..2a7943c 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -270,14 +270,14 @@ export const jaDict: Dict = { backToHome: "ホームへ戻る", inAppDownloadTitle: "システムブラウザで開いてダウンロードしてください", inAppDownloadIntro: - "現在のアプリ内ブラウザはファイルをダウンロードできません。Safari や Chrome などのシステムブラウザでページを開いてから、もう一度ダウンロードしてください。", + "現在のアプリ内ブラウザではファイルをダウンロードできません。下のリンクをコピーし、システムブラウザで開けば自動的にダウンロードされます。", inAppDownloadIntroNamed: - "{browser} のアプリ内ブラウザはファイルを直接ダウンロードできません。Safari や Chrome などのシステムブラウザでページを開いてから、もう一度ダウンロードしてください。", - inAppDownloadStepCopy: "下の「リンクをコピー」をタップします。", + "{browser} のアプリ内ブラウザではファイルをダウンロードできません。下のリンクをコピーし、システムブラウザで開けば自動的にダウンロードされます。", + inAppDownloadStepCopy: + "下の「リンクをコピー」をタップします(ファイルの直接ダウンロード URL です)。", inAppDownloadStepOpen: - "右上のメニューから「ブラウザで開く」を選び、必要に応じてリンクを貼り付けます。", - inAppDownloadStepDownload: - "システムブラウザで再度ダウンロードボタンをタップすると、ファイルがダウンロード先に保存されます。", + "システムブラウザ(Safari、Chrome など)を開き、アドレスバーにリンクを貼り付けます。", + inAppDownloadStepDownload: "ファイルは自動的にダウンロード先に保存されます。", inAppDownloadCopied: "リンクをコピーしました", inAppDownloadCopyFail: "コピーに失敗しました。手動でコピーしてください", }; diff --git a/src/locales/ko.ts b/src/locales/ko.ts index d2eaaad..4e5b395 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -265,14 +265,14 @@ export const koDict: Dict = { backToHome: "홈으로", inAppDownloadTitle: "시스템 브라우저에서 열어 다운로드하세요", inAppDownloadIntro: - "현재 앱 내 브라우저는 파일을 다운로드할 수 없습니다. Safari나 Chrome 등 시스템 브라우저에서 페이지를 연 다음 다시 다운로드해 주세요.", + "현재 앱 내 브라우저로는 파일을 다운로드할 수 없습니다. 아래 링크를 복사해서 시스템 브라우저에서 열면 바로 저장됩니다.", inAppDownloadIntroNamed: - "{browser} 앱 내 브라우저는 파일을 직접 다운로드할 수 없습니다. Safari나 Chrome 등 시스템 브라우저에서 페이지를 연 다음 다시 다운로드해 주세요.", - inAppDownloadStepCopy: "아래의 “링크 복사”를 누릅니다.", + "{browser} 앱 내 브라우저로는 파일을 다운로드할 수 없습니다. 아래 링크를 복사해서 시스템 브라우저에서 열면 바로 저장됩니다.", + inAppDownloadStepCopy: + '아래의 "링크 복사"를 누릅니다(파일 직접 다운로드 주소입니다).', inAppDownloadStepOpen: - "오른쪽 위 메뉴에서 “브라우저로 열기”를 선택하고 필요하면 링크를 붙여 넣습니다.", - inAppDownloadStepDownload: - "시스템 브라우저에서 다운로드 버튼을 다시 누르면 파일이 저장됩니다.", + "시스템 브라우저(Safari, Chrome 등)를 열고 주소창에 링크를 붙여 넣습니다.", + inAppDownloadStepDownload: "파일이 자동으로 다운로드 폴더에 저장됩니다.", inAppDownloadCopied: "링크를 복사했습니다", inAppDownloadCopyFail: "복사하지 못했습니다. 직접 복사해 주세요", }; diff --git a/src/locales/ms.ts b/src/locales/ms.ts index b7a8f62..f3761fc 100644 --- a/src/locales/ms.ts +++ b/src/locales/ms.ts @@ -268,14 +268,15 @@ export const msDict: Dict = { backToHome: "Kembali ke Laman Utama", inAppDownloadTitle: "Sila buka dalam pelayar sistem untuk muat turun", inAppDownloadIntro: - "Pelayar dalam aplikasi semasa tidak dapat memuat turun fail. Buka halaman ini dalam pelayar sistem (Safari, Chrome, dsb.), kemudian ketik muat turun semula.", + "Pelayar dalam aplikasi semasa tidak dapat memuat turun fail. Salin pautan di bawah dan buka dalam pelayar sistem — fail akan disimpan terus.", inAppDownloadIntroNamed: - "{browser} tidak dapat memuat turun fail secara langsung. Buka halaman ini dalam pelayar sistem (Safari, Chrome, dsb.), kemudian ketik muat turun semula.", - inAppDownloadStepCopy: "Ketik “Salin pautan” di bawah.", + "{browser} tidak dapat memuat turun fail secara langsung. Salin pautan di bawah dan buka dalam pelayar sistem — fail akan disimpan terus.", + inAppDownloadStepCopy: + 'Ketik "Salin pautan" di bawah (ini adalah URL muat turun fail terus).', inAppDownloadStepOpen: - "Buka menu di atas kanan, pilih “Buka dalam pelayar”, tampal pautan jika perlu.", + "Buka pelayar sistem (Safari, Chrome, dsb.) dan tampal pautan ke bar alamat.", inAppDownloadStepDownload: - "Dalam pelayar sistem, ketik butang muat turun semula dan fail akan disimpan.", + "Fail akan dimuat turun secara automatik ke folder Muat Turun.", inAppDownloadCopied: "Pautan disalin", inAppDownloadCopyFail: "Tidak dapat menyalin, sila salin secara manual", }; diff --git a/src/locales/vi.ts b/src/locales/vi.ts index e0c3a57..3e1d376 100644 --- a/src/locales/vi.ts +++ b/src/locales/vi.ts @@ -264,14 +264,14 @@ export const viDict: Dict = { backToHome: "Về trang chủ", inAppDownloadTitle: "Vui lòng mở bằng trình duyệt hệ thống để tải", inAppDownloadIntro: - "Trình duyệt trong ứng dụng hiện tại không thể tải tệp. Hãy mở trang trong trình duyệt hệ thống (Safari, Chrome…) rồi nhấn tải lại.", + "Trình duyệt trong ứng dụng hiện tại không thể tải tệp. Sao chép liên kết bên dưới và mở trong trình duyệt hệ thống — tệp sẽ tự động tải về.", inAppDownloadIntroNamed: - "{browser} không thể tải tệp trực tiếp. Hãy mở trang trong trình duyệt hệ thống (Safari, Chrome…) rồi nhấn tải lại.", - inAppDownloadStepCopy: "Nhấn “Sao chép liên kết” bên dưới.", + "{browser} không thể tải tệp trực tiếp. Sao chép liên kết bên dưới và mở trong trình duyệt hệ thống — tệp sẽ tự động tải về.", + inAppDownloadStepCopy: + 'Nhấn "Sao chép liên kết" bên dưới (đây là URL tải tệp trực tiếp).', inAppDownloadStepOpen: - "Mở menu ở góc trên bên phải, chọn “Mở bằng trình duyệt”, dán liên kết nếu cần.", - inAppDownloadStepDownload: - "Trong trình duyệt hệ thống, nhấn lại nút tải để lưu tệp.", + "Mở trình duyệt hệ thống (Safari, Chrome…) và dán liên kết vào thanh địa chỉ.", + inAppDownloadStepDownload: "Tệp sẽ tự động tải xuống thư mục Tải về.", inAppDownloadCopied: "Đã sao chép liên kết", inAppDownloadCopyFail: "Không sao chép được, vui lòng tự sao chép", }; diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index e947052..356c09d 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -247,14 +247,13 @@ export const zhDict: Dict = { backToHome: "返回首页", inAppDownloadTitle: "请使用系统浏览器打开后下载", inAppDownloadIntro: - "当前内置浏览器无法直接下载文件,请在系统浏览器(Safari、Chrome 等)中打开页面后再点击下载。", + "当前内置浏览器无法下载文件。复制下方链接,到系统浏览器打开即可直接下载。", inAppDownloadIntroNamed: - "{browser} 内置浏览器无法直接下载文件,请在系统浏览器(Safari、Chrome 等)中打开页面后再点击下载。", - inAppDownloadStepCopy: "点击下方「复制链接」。", + "{browser} 内置浏览器无法下载文件。复制下方链接,到系统浏览器打开即可直接下载。", + inAppDownloadStepCopy: "点击下方「复制链接」(这是文件的直接下载地址)。", inAppDownloadStepOpen: - "点右上角菜单,选择「在浏览器中打开」,如需要请把刚才复制的链接粘贴进地址栏。", - inAppDownloadStepDownload: - "在系统浏览器中再次点击下载按钮,文件会保存到下载文件夹。", + "打开系统浏览器(Safari、Chrome 等),把链接粘贴到地址栏。", + inAppDownloadStepDownload: "文件会自动开始下载到下载文件夹。", inAppDownloadCopied: "链接已复制", inAppDownloadCopyFail: "复制失败,请手动复制", }; From 3275aff121a348622eb62d3a73c1970300b0dc01 Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 5 Jun 2026 19:15:10 +0800 Subject: [PATCH 78/89] fix: hide download URL in in-app browser guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/InAppDownloadGuide.tsx | 30 +++++++++++---------------- src/locales/en.ts | 13 ++++++------ src/locales/id.ts | 14 +++++++------ src/locales/ja.ts | 15 +++++++------- src/locales/ko.ts | 15 +++++++------- src/locales/ms.ts | 13 ++++++------ src/locales/vi.ts | 14 +++++++------ src/locales/zh-CN.ts | 14 +++++++------ 8 files changed, 66 insertions(+), 62 deletions(-) diff --git a/src/components/InAppDownloadGuide.tsx b/src/components/InAppDownloadGuide.tsx index 65acbc5..04b63e2 100644 --- a/src/components/InAppDownloadGuide.tsx +++ b/src/components/InAppDownloadGuide.tsx @@ -47,13 +47,13 @@ export function InAppDownloadGuideProvider({ }) { const { t } = useI18n(); const { showToast } = useToast(); - const [detail, setDetail] = useState(null); + const [open, setOpen] = useState(false); useEffect(() => { const onShow = (event: Event) => { const ce = event as CustomEvent; if (!ce.detail) return; - setDetail(ce.detail); + setOpen(true); }; window.addEventListener(IN_APP_DOWNLOAD_GUIDE_EVENT, onShow); return () => @@ -61,19 +61,19 @@ export function InAppDownloadGuideProvider({ }, []); useEffect(() => { - if (!detail) return; + if (!open) return; const onKey = (event: KeyboardEvent) => { - if (event.key === "Escape") setDetail(null); + if (event.key === "Escape") setOpen(false); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [detail]); + }, [open]); - const close = () => setDetail(null); + const close = () => setOpen(false); - const handleCopy = async () => { - if (!detail) return; - const ok = await copyTextToClipboard(detail.url); + const handleCopyPageLink = async () => { + if (typeof window === "undefined") return; + const ok = await copyTextToClipboard(window.location.href); if (ok) { showToast(t("inAppDownloadCopied")); } else { @@ -89,7 +89,7 @@ export function InAppDownloadGuideProvider({ return ( <> {children} - {detail + {open ? createPortal(
-
-

- {detail.url} -

-
-
diff --git a/src/locales/en.ts b/src/locales/en.ts index ef79b74..177dfcd 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -266,15 +266,16 @@ export const enDict: Dict = { backToHome: "Back to Home", inAppDownloadTitle: "Please open in your system browser to download", inAppDownloadIntro: - "Your current in-app browser cannot download files. Copy the link below and open it in your system browser — the file will save directly.", + "Your current in-app browser cannot download files. Open this page in your system browser, then tap download again.", inAppDownloadIntroNamed: - "{browser} cannot download files directly. Copy the link below and open it in your system browser — the file will save directly.", + "{browser} cannot download files directly. Open this page in your system browser, then tap download again.", inAppDownloadStepCopy: - 'Tap "Copy link" below — that is the direct file download URL.', + 'Tap the menu (top-right) and choose "Open in default browser".', inAppDownloadStepOpen: - "Open your system browser (Safari, Chrome, etc.) and paste the link into the address bar.", + "If that option is missing, tap “Copy page link” below and open it in Safari, Chrome, or another browser.", inAppDownloadStepDownload: - "The file will start downloading to your downloads folder automatically.", - inAppDownloadCopied: "Link copied", + "Once the page opens in your system browser, tap the download button again to save the file.", + inAppDownloadCopyPageLink: "Copy page link", + inAppDownloadCopied: "Page link copied", inAppDownloadCopyFail: "Could not copy the link, please copy it manually", }; diff --git a/src/locales/id.ts b/src/locales/id.ts index 583f9b0..94442ba 100644 --- a/src/locales/id.ts +++ b/src/locales/id.ts @@ -269,14 +269,16 @@ export const idDict: Dict = { backToHome: "Kembali ke Beranda", inAppDownloadTitle: "Silakan buka di peramban sistem untuk mengunduh", inAppDownloadIntro: - "Peramban dalam aplikasi saat ini tidak dapat mengunduh berkas. Salin tautan di bawah dan buka di peramban sistem — berkas akan langsung tersimpan.", + "Peramban dalam aplikasi saat ini tidak dapat mengunduh berkas. Buka halaman ini di peramban sistem, lalu ketuk unduh lagi.", inAppDownloadIntroNamed: - "{browser} tidak dapat mengunduh berkas secara langsung. Salin tautan di bawah dan buka di peramban sistem — berkas akan langsung tersimpan.", + "{browser} tidak dapat mengunduh berkas secara langsung. Buka halaman ini di peramban sistem, lalu ketuk unduh lagi.", inAppDownloadStepCopy: - 'Ketuk "Salin tautan" di bawah (ini adalah URL unduhan langsung berkas).', + 'Buka menu di kanan atas dan pilih "Buka di peramban".', inAppDownloadStepOpen: - "Buka peramban sistem (Safari, Chrome, dll.) dan tempel tautan ke bilah alamat.", - inAppDownloadStepDownload: "Berkas akan otomatis terunduh ke folder Unduhan.", - inAppDownloadCopied: "Tautan disalin", + "Jika opsi itu tidak ada, ketuk “Salin tautan halaman” di bawah lalu buka di Safari, Chrome, dll.", + inAppDownloadStepDownload: + "Setelah halaman terbuka di peramban sistem, ketuk tombol unduh lagi untuk menyimpan berkas.", + inAppDownloadCopyPageLink: "Salin tautan halaman", + inAppDownloadCopied: "Tautan halaman disalin", inAppDownloadCopyFail: "Tidak dapat menyalin, silakan salin secara manual", }; diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 2a7943c..34d44f4 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -270,14 +270,15 @@ export const jaDict: Dict = { backToHome: "ホームへ戻る", inAppDownloadTitle: "システムブラウザで開いてダウンロードしてください", inAppDownloadIntro: - "現在のアプリ内ブラウザではファイルをダウンロードできません。下のリンクをコピーし、システムブラウザで開けば自動的にダウンロードされます。", + "現在のアプリ内ブラウザはファイルをダウンロードできません。このページをシステムブラウザで開き、もう一度ダウンロードしてください。", inAppDownloadIntroNamed: - "{browser} のアプリ内ブラウザではファイルをダウンロードできません。下のリンクをコピーし、システムブラウザで開けば自動的にダウンロードされます。", - inAppDownloadStepCopy: - "下の「リンクをコピー」をタップします(ファイルの直接ダウンロード URL です)。", + "{browser} はファイルを直接ダウンロードできません。このページをシステムブラウザで開き、もう一度ダウンロードしてください。", + inAppDownloadStepCopy: "右上のメニューを開き、「ブラウザで開く」を選びます。", inAppDownloadStepOpen: - "システムブラウザ(Safari、Chrome など)を開き、アドレスバーにリンクを貼り付けます。", - inAppDownloadStepDownload: "ファイルは自動的にダウンロード先に保存されます。", - inAppDownloadCopied: "リンクをコピーしました", + "該当の項目がなければ、下の「ページリンクをコピー」をタップし、Safari や Chrome で開いてください。", + inAppDownloadStepDownload: + "システムブラウザでページが開いたら、もう一度ダウンロードボタンをタップしてファイルを保存します。", + inAppDownloadCopyPageLink: "ページリンクをコピー", + inAppDownloadCopied: "ページリンクをコピーしました", inAppDownloadCopyFail: "コピーに失敗しました。手動でコピーしてください", }; diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 4e5b395..e60c7f2 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -265,14 +265,15 @@ export const koDict: Dict = { backToHome: "홈으로", inAppDownloadTitle: "시스템 브라우저에서 열어 다운로드하세요", inAppDownloadIntro: - "현재 앱 내 브라우저로는 파일을 다운로드할 수 없습니다. 아래 링크를 복사해서 시스템 브라우저에서 열면 바로 저장됩니다.", + "현재 앱 내 브라우저는 파일을 다운로드할 수 없습니다. 시스템 브라우저에서 이 페이지를 다시 열어 다운로드 버튼을 눌러 주세요.", inAppDownloadIntroNamed: - "{browser} 앱 내 브라우저로는 파일을 다운로드할 수 없습니다. 아래 링크를 복사해서 시스템 브라우저에서 열면 바로 저장됩니다.", - inAppDownloadStepCopy: - '아래의 "링크 복사"를 누릅니다(파일 직접 다운로드 주소입니다).', + "{browser}는 파일을 직접 다운로드할 수 없습니다. 시스템 브라우저에서 이 페이지를 다시 열어 다운로드 버튼을 눌러 주세요.", + inAppDownloadStepCopy: '오른쪽 위 메뉴에서 "브라우저로 열기"를 선택하세요.', inAppDownloadStepOpen: - "시스템 브라우저(Safari, Chrome 등)를 열고 주소창에 링크를 붙여 넣습니다.", - inAppDownloadStepDownload: "파일이 자동으로 다운로드 폴더에 저장됩니다.", - inAppDownloadCopied: "링크를 복사했습니다", + "해당 옵션이 없다면 아래 “페이지 링크 복사”를 눌러 Safari나 Chrome 등에서 여세요.", + inAppDownloadStepDownload: + "시스템 브라우저에서 페이지가 열리면 다운로드 버튼을 다시 눌러 파일을 저장하세요.", + inAppDownloadCopyPageLink: "페이지 링크 복사", + inAppDownloadCopied: "페이지 링크가 복사되었습니다", inAppDownloadCopyFail: "복사하지 못했습니다. 직접 복사해 주세요", }; diff --git a/src/locales/ms.ts b/src/locales/ms.ts index f3761fc..72282c6 100644 --- a/src/locales/ms.ts +++ b/src/locales/ms.ts @@ -268,15 +268,16 @@ export const msDict: Dict = { backToHome: "Kembali ke Laman Utama", inAppDownloadTitle: "Sila buka dalam pelayar sistem untuk muat turun", inAppDownloadIntro: - "Pelayar dalam aplikasi semasa tidak dapat memuat turun fail. Salin pautan di bawah dan buka dalam pelayar sistem — fail akan disimpan terus.", + "Pelayar dalam aplikasi semasa tidak dapat memuat turun fail. Buka halaman ini dalam pelayar sistem, kemudian ketik muat turun semula.", inAppDownloadIntroNamed: - "{browser} tidak dapat memuat turun fail secara langsung. Salin pautan di bawah dan buka dalam pelayar sistem — fail akan disimpan terus.", + "{browser} tidak dapat memuat turun fail secara langsung. Buka halaman ini dalam pelayar sistem, kemudian ketik muat turun semula.", inAppDownloadStepCopy: - 'Ketik "Salin pautan" di bawah (ini adalah URL muat turun fail terus).', + 'Buka menu di atas kanan dan pilih "Buka dalam pelayar".', inAppDownloadStepOpen: - "Buka pelayar sistem (Safari, Chrome, dsb.) dan tampal pautan ke bar alamat.", + "Jika pilihan tersebut tiada, ketik “Salin pautan halaman” di bawah dan buka dalam Safari, Chrome, dll.", inAppDownloadStepDownload: - "Fail akan dimuat turun secara automatik ke folder Muat Turun.", - inAppDownloadCopied: "Pautan disalin", + "Selepas halaman dibuka dalam pelayar sistem, ketik butang muat turun semula untuk menyimpan fail.", + inAppDownloadCopyPageLink: "Salin pautan halaman", + inAppDownloadCopied: "Pautan halaman disalin", inAppDownloadCopyFail: "Tidak dapat menyalin, sila salin secara manual", }; diff --git a/src/locales/vi.ts b/src/locales/vi.ts index 3e1d376..0c0d07d 100644 --- a/src/locales/vi.ts +++ b/src/locales/vi.ts @@ -264,14 +264,16 @@ export const viDict: Dict = { backToHome: "Về trang chủ", inAppDownloadTitle: "Vui lòng mở bằng trình duyệt hệ thống để tải", inAppDownloadIntro: - "Trình duyệt trong ứng dụng hiện tại không thể tải tệp. Sao chép liên kết bên dưới và mở trong trình duyệt hệ thống — tệp sẽ tự động tải về.", + "Trình duyệt trong ứng dụng hiện tại không thể tải tệp. Hãy mở trang này trong trình duyệt hệ thống rồi nhấn tải lại.", inAppDownloadIntroNamed: - "{browser} không thể tải tệp trực tiếp. Sao chép liên kết bên dưới và mở trong trình duyệt hệ thống — tệp sẽ tự động tải về.", + "{browser} không thể tải tệp trực tiếp. Hãy mở trang này trong trình duyệt hệ thống rồi nhấn tải lại.", inAppDownloadStepCopy: - 'Nhấn "Sao chép liên kết" bên dưới (đây là URL tải tệp trực tiếp).', + 'Mở menu ở góc trên bên phải và chọn "Mở trong trình duyệt".', inAppDownloadStepOpen: - "Mở trình duyệt hệ thống (Safari, Chrome…) và dán liên kết vào thanh địa chỉ.", - inAppDownloadStepDownload: "Tệp sẽ tự động tải xuống thư mục Tải về.", - inAppDownloadCopied: "Đã sao chép liên kết", + "Nếu không có lựa chọn đó, nhấn “Sao chép liên kết trang” bên dưới rồi mở trong Safari, Chrome,…", + inAppDownloadStepDownload: + "Khi trang được mở trong trình duyệt hệ thống, hãy nhấn lại nút tải để lưu tệp.", + inAppDownloadCopyPageLink: "Sao chép liên kết trang", + inAppDownloadCopied: "Đã sao chép liên kết trang", inAppDownloadCopyFail: "Không sao chép được, vui lòng tự sao chép", }; diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 356c09d..0e3fc85 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -247,13 +247,15 @@ export const zhDict: Dict = { backToHome: "返回首页", inAppDownloadTitle: "请使用系统浏览器打开后下载", inAppDownloadIntro: - "当前内置浏览器无法下载文件。复制下方链接,到系统浏览器打开即可直接下载。", + "当前内置浏览器无法下载文件。请在系统浏览器中打开本页面,然后再次点击下载按钮。", inAppDownloadIntroNamed: - "{browser} 内置浏览器无法下载文件。复制下方链接,到系统浏览器打开即可直接下载。", - inAppDownloadStepCopy: "点击下方「复制链接」(这是文件的直接下载地址)。", + "{browser} 内置浏览器无法下载文件。请在系统浏览器中打开本页面,然后再次点击下载按钮。", + inAppDownloadStepCopy: "点击右上角菜单,选择「在默认浏览器中打开」。", inAppDownloadStepOpen: - "打开系统浏览器(Safari、Chrome 等),把链接粘贴到地址栏。", - inAppDownloadStepDownload: "文件会自动开始下载到下载文件夹。", - inAppDownloadCopied: "链接已复制", + "如果没有该选项,可点击下方「复制页面链接」,再到 Safari、Chrome 等浏览器中打开。", + inAppDownloadStepDownload: + "系统浏览器打开页面后,再次点击下载按钮即可把文件保存到下载文件夹。", + inAppDownloadCopyPageLink: "复制页面链接", + inAppDownloadCopied: "页面链接已复制", inAppDownloadCopyFail: "复制失败,请手动复制", }; From 69bef7ee6e3839f19f5b499025ca6098c94f7a7f Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 5 Jun 2026 19:31:27 +0800 Subject: [PATCH 79/89] fix: hide download button on favorites cards Add `showDownload` prop to PopularRankRow (default true to keep Home popular cards unchanged) and pass `showDownload={false}` from the favorites list. --- src/components/PopularRankList.tsx | 4 +++- src/pages/Favorites/index.tsx | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/PopularRankList.tsx b/src/components/PopularRankList.tsx index a68f119..c939352 100644 --- a/src/components/PopularRankList.tsx +++ b/src/components/PopularRankList.tsx @@ -89,6 +89,7 @@ export function PopularRankRow({ categories, browseSort = "popular", showRank = true, + showDownload = true, singlePostLink = false, onFavoriteChange, }: { @@ -97,6 +98,7 @@ export function PopularRankRow({ categories: Category[]; browseSort?: string; showRank?: boolean; + showDownload?: boolean; singlePostLink?: boolean; onFavoriteChange?: (postId: string, favorited: boolean) => void; }) { @@ -211,7 +213,7 @@ export function PopularRankRow({ onFavoriteChange?.(post.id, favorited) } /> - {r.isDownloadable ? ( + {showDownload && r.isDownloadable ? ( ) : null}
diff --git a/src/components/PopularRankList.tsx b/src/components/PopularRankList.tsx index c939352..12123df 100644 --- a/src/components/PopularRankList.tsx +++ b/src/components/PopularRankList.tsx @@ -4,7 +4,6 @@ import { FileText, Image as ImageIcon, Link as LinkIcon, - LoaderCircle, Music, Presentation, Video, @@ -21,9 +20,6 @@ import { cleanCategoryDisplayName } from "../utils/categoryDisplay"; import { formatDateYmd } from "../utils/format"; import { postToResource } from "../utils/postResourceAdapter"; import type { Post } from "../types/post"; -import { downloadAttachment } from "./messageStream/utils/downloadFile"; -import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide"; -import { useToast } from "./Toast"; import { FavoriteButton } from "../favorites/FavoriteButton"; const MEDALS = ["🥇", "🥈", "🥉"]; @@ -105,31 +101,18 @@ export function PopularRankRow({ const { t, lang } = useI18n(); const navigate = useNavigate(); const lp = useLocalizedPath(); - const { showToast } = useToast(); - const { showSaveToAlbumGuide } = useSaveToAlbumGuide(); - const [isDownloading, setIsDownloading] = useState(false); const [coverFailed, setCoverFailed] = useState(false); const r = postToResource(post, lang, categories); const cover = r.coverImage && !coverFailed ? assetUrl(r.coverImage) : ""; const isTop3 = showRank && index < MEDALS.length; - const handleDownload = async () => { - if (isDownloading || !r.downloadPostId || !r.downloadAttachmentId) return; - setIsDownloading(true); - try { - await downloadAttachment( - r.downloadPostId, - r.downloadAttachmentId, - r.title, - ); - const mediaKind = mediaSaveKindFromType(r.type); - if (mediaKind) showSaveToAlbumGuide(mediaKind); - } catch { - showToast(t("downloadFail"), "error"); - } finally { - setIsDownloading(false); - } + const goToPost = () => { + const params = new URLSearchParams(); + if (browseSort) params.set("sort", browseSort); + params.set("post", post.id); + if (singlePostLink) params.set("single", "1"); + navigate(lp(`/browse?${params.toString()}`)); }; return ( @@ -142,13 +125,7 @@ export function PopularRankRow({ > ) : null}
diff --git a/src/components/RecommendedCard.tsx b/src/components/RecommendedCard.tsx index f3566ca..2dbbb2b 100644 --- a/src/components/RecommendedCard.tsx +++ b/src/components/RecommendedCard.tsx @@ -1,20 +1,13 @@ -import { Download, LoaderCircle } from "lucide-react"; import { m } from "framer-motion"; -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import type { Resource } from "../api"; import { assetUrl } from "../api"; import { useI18n } from "../i18n"; import { useLocalizedPath } from "../useLocalizedPath"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { formatDateYmd } from "../utils/format"; import { DownloadCloudIcon } from "./icons/DownloadCloudIcon"; import { officialRecommendationCoverFallbacks } from "./FigmaBanner"; -import { - downloadAttachment, - downloadFile, -} from "./messageStream/utils/downloadFile"; -import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide"; -import { useToast } from "./Toast"; import { FavoriteButton } from "../favorites/FavoriteButton"; function isPlaceholderAsset(path: string | undefined | null) { @@ -53,9 +46,7 @@ export function RecommendedCard({ }) { const { t } = useI18n(); const lp = useLocalizedPath(); - const { showToast } = useToast(); - const { showSaveToAlbumGuide } = useSaveToAlbumGuide(); - const [isDownloading, setIsDownloading] = useState(false); + const navigate = useNavigate(); const figmaCover = officialRecommendationCoverFallbacks[ visualIndex % officialRecommendationCoverFallbacks.length @@ -77,26 +68,10 @@ export function RecommendedCard({ ? assetUrl(r.fileUrl || r.previewUrl) : ""; - const handleDownload = async () => { - if (isDownloading) return; - setIsDownloading(true); - try { - if (r.downloadPostId && r.downloadAttachmentId) { - await downloadAttachment( - r.downloadPostId, - r.downloadAttachmentId, - displayTitle, - ); - } else { - await downloadFile(dl, displayTitle); - } - const mediaKind = mediaSaveKindFromType(r.type); - if (mediaKind) showSaveToAlbumGuide(mediaKind); - } catch { - showToast(t("downloadFail"), "error"); - } finally { - setIsDownloading(false); - } + const goToPost = () => { + // Same destination as the card-wide overlay link, so the user lands on the + // post and can choose exactly which attachment to download. + navigate(lp(`/resource/${r.id}`)); }; return ( @@ -193,29 +168,18 @@ export function RecommendedCard({ type="button" className={ useFigmaDesign - ? "relative z-20 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#191921] text-white outline-none transition hover:bg-[#22232D] active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait" - : "relative z-20 shrink-0 rounded-lg p-1 text-white outline-none transition hover:bg-ark-gold/10 active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait" + ? "relative z-20 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#191921] text-white outline-none transition hover:bg-[#22232D] active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80" + : "relative z-20 shrink-0 rounded-lg p-1 text-white outline-none transition hover:bg-ark-gold/10 active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80" } - title={isDownloading ? t("downloading") : t("download")} - aria-label={isDownloading ? t("downloading") : t("download")} - aria-busy={isDownloading} - disabled={isDownloading} + title={t("download")} + aria-label={t("download")} onClick={(e) => { e.preventDefault(); e.stopPropagation(); - void handleDownload(); + goToPost(); }} > - {isDownloading ? ( - - ) : useFigmaDesign ? ( - - ) : ( - - )} + ) : null}
From ee3f2c43eba77ddc697e1fe9215e08649448f65b Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 5 Jun 2026 21:09:42 +0800 Subject: [PATCH 81/89] Revert "fix: download button on cards opens the post page" This reverts commit 75ccfd78ed8d8b49b94e54932b08141143868c21. --- src/components/LatestUpdateCard.tsx | 52 +++++++++++++++++++----- src/components/PopularRankList.tsx | 55 ++++++++++++++++++------- src/components/RecommendedCard.tsx | 62 +++++++++++++++++++++++------ 3 files changed, 131 insertions(+), 38 deletions(-) diff --git a/src/components/LatestUpdateCard.tsx b/src/components/LatestUpdateCard.tsx index 16d62b8..ef30bff 100644 --- a/src/components/LatestUpdateCard.tsx +++ b/src/components/LatestUpdateCard.tsx @@ -1,10 +1,17 @@ -import { Play } from "lucide-react"; -import { Link, useNavigate } from "react-router-dom"; +import { LoaderCircle, Play } from "lucide-react"; +import { useState } from "react"; +import { Link } from "react-router-dom"; import { FavoriteButton } from "../favorites/FavoriteButton"; import { useI18n } from "../i18n"; import { useLocalizedPath } from "../useLocalizedPath"; import type { Attachment, Post } from "../types/post"; import { DownloadCloudIcon } from "./icons/DownloadCloudIcon"; +import { + mediaSaveKindFromAttachment, + useSaveToAlbumGuide, +} from "./SaveToAlbumGuide"; +import { useToast } from "./Toast"; +import { downloadAttachment } from "./messageStream/utils/downloadFile"; import { fileIcon } from "./messageStream/utils/fileIcon"; import { filenameWithExtension, @@ -23,11 +30,24 @@ function LatestActions({ attachment?: Attachment; }) { const { t } = useI18n(); - const lp = useLocalizedPath(); - const navigate = useNavigate(); + const { showToast } = useToast(); + const { showSaveToAlbumGuide } = useSaveToAlbumGuide(); + const [isDownloading, setIsDownloading] = useState(false); - const goToPost = () => { - navigate(lp(`/browse?post=${encodeURIComponent(post.id)}`)); + const handleDownload = async () => { + if (!attachment || isDownloading) return; + setIsDownloading(true); + try { + await downloadAttachment(post.id, attachment.id, attachment.filename, { + sizeBytes: attachment.sizeBytes, + }); + const mediaKind = mediaSaveKindFromAttachment(attachment); + if (mediaKind) showSaveToAlbumGuide(mediaKind); + } catch { + showToast(t("downloadFail"), "error"); + } finally { + setIsDownloading(false); + } }; return ( @@ -39,13 +59,23 @@ function LatestActions({ onClick={(event) => { event.preventDefault(); event.stopPropagation(); - goToPost(); + void handleDownload(); }} - aria-label={t("download")} - title={t("download")} - className="relative z-20 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#191921] text-white outline-none transition hover:bg-[#22232D] focus-visible:ring-2 focus-visible:ring-ark-gold/70" + disabled={isDownloading} + aria-label={ + isDownloading ? t("downloading") : `Download ${attachment.filename}` + } + aria-busy={isDownloading} + className="relative z-20 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#191921] text-white outline-none transition hover:bg-[#22232D] focus-visible:ring-2 focus-visible:ring-ark-gold/70 disabled:cursor-wait" > - + {isDownloading ? ( + + ) : ( + + )} ) : null}
diff --git a/src/components/PopularRankList.tsx b/src/components/PopularRankList.tsx index 12123df..c939352 100644 --- a/src/components/PopularRankList.tsx +++ b/src/components/PopularRankList.tsx @@ -4,6 +4,7 @@ import { FileText, Image as ImageIcon, Link as LinkIcon, + LoaderCircle, Music, Presentation, Video, @@ -20,6 +21,9 @@ import { cleanCategoryDisplayName } from "../utils/categoryDisplay"; import { formatDateYmd } from "../utils/format"; import { postToResource } from "../utils/postResourceAdapter"; import type { Post } from "../types/post"; +import { downloadAttachment } from "./messageStream/utils/downloadFile"; +import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide"; +import { useToast } from "./Toast"; import { FavoriteButton } from "../favorites/FavoriteButton"; const MEDALS = ["🥇", "🥈", "🥉"]; @@ -101,18 +105,31 @@ export function PopularRankRow({ const { t, lang } = useI18n(); const navigate = useNavigate(); const lp = useLocalizedPath(); + const { showToast } = useToast(); + const { showSaveToAlbumGuide } = useSaveToAlbumGuide(); + const [isDownloading, setIsDownloading] = useState(false); const [coverFailed, setCoverFailed] = useState(false); const r = postToResource(post, lang, categories); const cover = r.coverImage && !coverFailed ? assetUrl(r.coverImage) : ""; const isTop3 = showRank && index < MEDALS.length; - const goToPost = () => { - const params = new URLSearchParams(); - if (browseSort) params.set("sort", browseSort); - params.set("post", post.id); - if (singlePostLink) params.set("single", "1"); - navigate(lp(`/browse?${params.toString()}`)); + const handleDownload = async () => { + if (isDownloading || !r.downloadPostId || !r.downloadAttachmentId) return; + setIsDownloading(true); + try { + await downloadAttachment( + r.downloadPostId, + r.downloadAttachmentId, + r.title, + ); + const mediaKind = mediaSaveKindFromType(r.type); + if (mediaKind) showSaveToAlbumGuide(mediaKind); + } catch { + showToast(t("downloadFail"), "error"); + } finally { + setIsDownloading(false); + } }; return ( @@ -125,7 +142,13 @@ export function PopularRankRow({ > ) : null} diff --git a/src/components/RecommendedCard.tsx b/src/components/RecommendedCard.tsx index 2dbbb2b..f3566ca 100644 --- a/src/components/RecommendedCard.tsx +++ b/src/components/RecommendedCard.tsx @@ -1,13 +1,20 @@ +import { Download, LoaderCircle } from "lucide-react"; import { m } from "framer-motion"; -import { Link, useNavigate } from "react-router-dom"; +import { Link } from "react-router-dom"; import type { Resource } from "../api"; import { assetUrl } from "../api"; import { useI18n } from "../i18n"; import { useLocalizedPath } from "../useLocalizedPath"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { formatDateYmd } from "../utils/format"; import { DownloadCloudIcon } from "./icons/DownloadCloudIcon"; import { officialRecommendationCoverFallbacks } from "./FigmaBanner"; +import { + downloadAttachment, + downloadFile, +} from "./messageStream/utils/downloadFile"; +import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide"; +import { useToast } from "./Toast"; import { FavoriteButton } from "../favorites/FavoriteButton"; function isPlaceholderAsset(path: string | undefined | null) { @@ -46,7 +53,9 @@ export function RecommendedCard({ }) { const { t } = useI18n(); const lp = useLocalizedPath(); - const navigate = useNavigate(); + const { showToast } = useToast(); + const { showSaveToAlbumGuide } = useSaveToAlbumGuide(); + const [isDownloading, setIsDownloading] = useState(false); const figmaCover = officialRecommendationCoverFallbacks[ visualIndex % officialRecommendationCoverFallbacks.length @@ -68,10 +77,26 @@ export function RecommendedCard({ ? assetUrl(r.fileUrl || r.previewUrl) : ""; - const goToPost = () => { - // Same destination as the card-wide overlay link, so the user lands on the - // post and can choose exactly which attachment to download. - navigate(lp(`/resource/${r.id}`)); + const handleDownload = async () => { + if (isDownloading) return; + setIsDownloading(true); + try { + if (r.downloadPostId && r.downloadAttachmentId) { + await downloadAttachment( + r.downloadPostId, + r.downloadAttachmentId, + displayTitle, + ); + } else { + await downloadFile(dl, displayTitle); + } + const mediaKind = mediaSaveKindFromType(r.type); + if (mediaKind) showSaveToAlbumGuide(mediaKind); + } catch { + showToast(t("downloadFail"), "error"); + } finally { + setIsDownloading(false); + } }; return ( @@ -168,18 +193,29 @@ export function RecommendedCard({ type="button" className={ useFigmaDesign - ? "relative z-20 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#191921] text-white outline-none transition hover:bg-[#22232D] active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80" - : "relative z-20 shrink-0 rounded-lg p-1 text-white outline-none transition hover:bg-ark-gold/10 active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80" + ? "relative z-20 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#191921] text-white outline-none transition hover:bg-[#22232D] active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait" + : "relative z-20 shrink-0 rounded-lg p-1 text-white outline-none transition hover:bg-ark-gold/10 active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait" } - title={t("download")} - aria-label={t("download")} + title={isDownloading ? t("downloading") : t("download")} + aria-label={isDownloading ? t("downloading") : t("download")} + aria-busy={isDownloading} + disabled={isDownloading} onClick={(e) => { e.preventDefault(); e.stopPropagation(); - goToPost(); + void handleDownload(); }} > - + {isDownloading ? ( + + ) : useFigmaDesign ? ( + + ) : ( + + )} ) : null} From 4c684d75a3f40057e3cea8d6cb93c0337fcc42df Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 5 Jun 2026 21:09:42 +0800 Subject: [PATCH 82/89] Revert "fix: hide download URL in in-app browser guide" This reverts commit 3275aff121a348622eb62d3a73c1970300b0dc01. --- src/components/InAppDownloadGuide.tsx | 30 ++++++++++++++++----------- src/locales/en.ts | 13 ++++++------ src/locales/id.ts | 14 ++++++------- src/locales/ja.ts | 15 +++++++------- src/locales/ko.ts | 15 +++++++------- src/locales/ms.ts | 13 ++++++------ src/locales/vi.ts | 14 ++++++------- src/locales/zh-CN.ts | 14 ++++++------- 8 files changed, 62 insertions(+), 66 deletions(-) diff --git a/src/components/InAppDownloadGuide.tsx b/src/components/InAppDownloadGuide.tsx index 04b63e2..65acbc5 100644 --- a/src/components/InAppDownloadGuide.tsx +++ b/src/components/InAppDownloadGuide.tsx @@ -47,13 +47,13 @@ export function InAppDownloadGuideProvider({ }) { const { t } = useI18n(); const { showToast } = useToast(); - const [open, setOpen] = useState(false); + const [detail, setDetail] = useState(null); useEffect(() => { const onShow = (event: Event) => { const ce = event as CustomEvent; if (!ce.detail) return; - setOpen(true); + setDetail(ce.detail); }; window.addEventListener(IN_APP_DOWNLOAD_GUIDE_EVENT, onShow); return () => @@ -61,19 +61,19 @@ export function InAppDownloadGuideProvider({ }, []); useEffect(() => { - if (!open) return; + if (!detail) return; const onKey = (event: KeyboardEvent) => { - if (event.key === "Escape") setOpen(false); + if (event.key === "Escape") setDetail(null); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [open]); + }, [detail]); - const close = () => setOpen(false); + const close = () => setDetail(null); - const handleCopyPageLink = async () => { - if (typeof window === "undefined") return; - const ok = await copyTextToClipboard(window.location.href); + const handleCopy = async () => { + if (!detail) return; + const ok = await copyTextToClipboard(detail.url); if (ok) { showToast(t("inAppDownloadCopied")); } else { @@ -89,7 +89,7 @@ export function InAppDownloadGuideProvider({ return ( <> {children} - {open + {detail ? createPortal(
+
+

+ {detail.url} +

+
+
diff --git a/src/locales/en.ts b/src/locales/en.ts index 177dfcd..ef79b74 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -266,16 +266,15 @@ export const enDict: Dict = { backToHome: "Back to Home", inAppDownloadTitle: "Please open in your system browser to download", inAppDownloadIntro: - "Your current in-app browser cannot download files. Open this page in your system browser, then tap download again.", + "Your current in-app browser cannot download files. Copy the link below and open it in your system browser — the file will save directly.", inAppDownloadIntroNamed: - "{browser} cannot download files directly. Open this page in your system browser, then tap download again.", + "{browser} cannot download files directly. Copy the link below and open it in your system browser — the file will save directly.", inAppDownloadStepCopy: - 'Tap the menu (top-right) and choose "Open in default browser".', + 'Tap "Copy link" below — that is the direct file download URL.', inAppDownloadStepOpen: - "If that option is missing, tap “Copy page link” below and open it in Safari, Chrome, or another browser.", + "Open your system browser (Safari, Chrome, etc.) and paste the link into the address bar.", inAppDownloadStepDownload: - "Once the page opens in your system browser, tap the download button again to save the file.", - inAppDownloadCopyPageLink: "Copy page link", - inAppDownloadCopied: "Page link copied", + "The file will start downloading to your downloads folder automatically.", + inAppDownloadCopied: "Link copied", inAppDownloadCopyFail: "Could not copy the link, please copy it manually", }; diff --git a/src/locales/id.ts b/src/locales/id.ts index 94442ba..583f9b0 100644 --- a/src/locales/id.ts +++ b/src/locales/id.ts @@ -269,16 +269,14 @@ export const idDict: Dict = { backToHome: "Kembali ke Beranda", inAppDownloadTitle: "Silakan buka di peramban sistem untuk mengunduh", inAppDownloadIntro: - "Peramban dalam aplikasi saat ini tidak dapat mengunduh berkas. Buka halaman ini di peramban sistem, lalu ketuk unduh lagi.", + "Peramban dalam aplikasi saat ini tidak dapat mengunduh berkas. Salin tautan di bawah dan buka di peramban sistem — berkas akan langsung tersimpan.", inAppDownloadIntroNamed: - "{browser} tidak dapat mengunduh berkas secara langsung. Buka halaman ini di peramban sistem, lalu ketuk unduh lagi.", + "{browser} tidak dapat mengunduh berkas secara langsung. Salin tautan di bawah dan buka di peramban sistem — berkas akan langsung tersimpan.", inAppDownloadStepCopy: - 'Buka menu di kanan atas dan pilih "Buka di peramban".', + 'Ketuk "Salin tautan" di bawah (ini adalah URL unduhan langsung berkas).', inAppDownloadStepOpen: - "Jika opsi itu tidak ada, ketuk “Salin tautan halaman” di bawah lalu buka di Safari, Chrome, dll.", - inAppDownloadStepDownload: - "Setelah halaman terbuka di peramban sistem, ketuk tombol unduh lagi untuk menyimpan berkas.", - inAppDownloadCopyPageLink: "Salin tautan halaman", - inAppDownloadCopied: "Tautan halaman disalin", + "Buka peramban sistem (Safari, Chrome, dll.) dan tempel tautan ke bilah alamat.", + inAppDownloadStepDownload: "Berkas akan otomatis terunduh ke folder Unduhan.", + inAppDownloadCopied: "Tautan disalin", inAppDownloadCopyFail: "Tidak dapat menyalin, silakan salin secara manual", }; diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 34d44f4..2a7943c 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -270,15 +270,14 @@ export const jaDict: Dict = { backToHome: "ホームへ戻る", inAppDownloadTitle: "システムブラウザで開いてダウンロードしてください", inAppDownloadIntro: - "現在のアプリ内ブラウザはファイルをダウンロードできません。このページをシステムブラウザで開き、もう一度ダウンロードしてください。", + "現在のアプリ内ブラウザではファイルをダウンロードできません。下のリンクをコピーし、システムブラウザで開けば自動的にダウンロードされます。", inAppDownloadIntroNamed: - "{browser} はファイルを直接ダウンロードできません。このページをシステムブラウザで開き、もう一度ダウンロードしてください。", - inAppDownloadStepCopy: "右上のメニューを開き、「ブラウザで開く」を選びます。", + "{browser} のアプリ内ブラウザではファイルをダウンロードできません。下のリンクをコピーし、システムブラウザで開けば自動的にダウンロードされます。", + inAppDownloadStepCopy: + "下の「リンクをコピー」をタップします(ファイルの直接ダウンロード URL です)。", inAppDownloadStepOpen: - "該当の項目がなければ、下の「ページリンクをコピー」をタップし、Safari や Chrome で開いてください。", - inAppDownloadStepDownload: - "システムブラウザでページが開いたら、もう一度ダウンロードボタンをタップしてファイルを保存します。", - inAppDownloadCopyPageLink: "ページリンクをコピー", - inAppDownloadCopied: "ページリンクをコピーしました", + "システムブラウザ(Safari、Chrome など)を開き、アドレスバーにリンクを貼り付けます。", + inAppDownloadStepDownload: "ファイルは自動的にダウンロード先に保存されます。", + inAppDownloadCopied: "リンクをコピーしました", inAppDownloadCopyFail: "コピーに失敗しました。手動でコピーしてください", }; diff --git a/src/locales/ko.ts b/src/locales/ko.ts index e60c7f2..4e5b395 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -265,15 +265,14 @@ export const koDict: Dict = { backToHome: "홈으로", inAppDownloadTitle: "시스템 브라우저에서 열어 다운로드하세요", inAppDownloadIntro: - "현재 앱 내 브라우저는 파일을 다운로드할 수 없습니다. 시스템 브라우저에서 이 페이지를 다시 열어 다운로드 버튼을 눌러 주세요.", + "현재 앱 내 브라우저로는 파일을 다운로드할 수 없습니다. 아래 링크를 복사해서 시스템 브라우저에서 열면 바로 저장됩니다.", inAppDownloadIntroNamed: - "{browser}는 파일을 직접 다운로드할 수 없습니다. 시스템 브라우저에서 이 페이지를 다시 열어 다운로드 버튼을 눌러 주세요.", - inAppDownloadStepCopy: '오른쪽 위 메뉴에서 "브라우저로 열기"를 선택하세요.', + "{browser} 앱 내 브라우저로는 파일을 다운로드할 수 없습니다. 아래 링크를 복사해서 시스템 브라우저에서 열면 바로 저장됩니다.", + inAppDownloadStepCopy: + '아래의 "링크 복사"를 누릅니다(파일 직접 다운로드 주소입니다).', inAppDownloadStepOpen: - "해당 옵션이 없다면 아래 “페이지 링크 복사”를 눌러 Safari나 Chrome 등에서 여세요.", - inAppDownloadStepDownload: - "시스템 브라우저에서 페이지가 열리면 다운로드 버튼을 다시 눌러 파일을 저장하세요.", - inAppDownloadCopyPageLink: "페이지 링크 복사", - inAppDownloadCopied: "페이지 링크가 복사되었습니다", + "시스템 브라우저(Safari, Chrome 등)를 열고 주소창에 링크를 붙여 넣습니다.", + inAppDownloadStepDownload: "파일이 자동으로 다운로드 폴더에 저장됩니다.", + inAppDownloadCopied: "링크를 복사했습니다", inAppDownloadCopyFail: "복사하지 못했습니다. 직접 복사해 주세요", }; diff --git a/src/locales/ms.ts b/src/locales/ms.ts index 72282c6..f3761fc 100644 --- a/src/locales/ms.ts +++ b/src/locales/ms.ts @@ -268,16 +268,15 @@ export const msDict: Dict = { backToHome: "Kembali ke Laman Utama", inAppDownloadTitle: "Sila buka dalam pelayar sistem untuk muat turun", inAppDownloadIntro: - "Pelayar dalam aplikasi semasa tidak dapat memuat turun fail. Buka halaman ini dalam pelayar sistem, kemudian ketik muat turun semula.", + "Pelayar dalam aplikasi semasa tidak dapat memuat turun fail. Salin pautan di bawah dan buka dalam pelayar sistem — fail akan disimpan terus.", inAppDownloadIntroNamed: - "{browser} tidak dapat memuat turun fail secara langsung. Buka halaman ini dalam pelayar sistem, kemudian ketik muat turun semula.", + "{browser} tidak dapat memuat turun fail secara langsung. Salin pautan di bawah dan buka dalam pelayar sistem — fail akan disimpan terus.", inAppDownloadStepCopy: - 'Buka menu di atas kanan dan pilih "Buka dalam pelayar".', + 'Ketik "Salin pautan" di bawah (ini adalah URL muat turun fail terus).', inAppDownloadStepOpen: - "Jika pilihan tersebut tiada, ketik “Salin pautan halaman” di bawah dan buka dalam Safari, Chrome, dll.", + "Buka pelayar sistem (Safari, Chrome, dsb.) dan tampal pautan ke bar alamat.", inAppDownloadStepDownload: - "Selepas halaman dibuka dalam pelayar sistem, ketik butang muat turun semula untuk menyimpan fail.", - inAppDownloadCopyPageLink: "Salin pautan halaman", - inAppDownloadCopied: "Pautan halaman disalin", + "Fail akan dimuat turun secara automatik ke folder Muat Turun.", + inAppDownloadCopied: "Pautan disalin", inAppDownloadCopyFail: "Tidak dapat menyalin, sila salin secara manual", }; diff --git a/src/locales/vi.ts b/src/locales/vi.ts index 0c0d07d..3e1d376 100644 --- a/src/locales/vi.ts +++ b/src/locales/vi.ts @@ -264,16 +264,14 @@ export const viDict: Dict = { backToHome: "Về trang chủ", inAppDownloadTitle: "Vui lòng mở bằng trình duyệt hệ thống để tải", inAppDownloadIntro: - "Trình duyệt trong ứng dụng hiện tại không thể tải tệp. Hãy mở trang này trong trình duyệt hệ thống rồi nhấn tải lại.", + "Trình duyệt trong ứng dụng hiện tại không thể tải tệp. Sao chép liên kết bên dưới và mở trong trình duyệt hệ thống — tệp sẽ tự động tải về.", inAppDownloadIntroNamed: - "{browser} không thể tải tệp trực tiếp. Hãy mở trang này trong trình duyệt hệ thống rồi nhấn tải lại.", + "{browser} không thể tải tệp trực tiếp. Sao chép liên kết bên dưới và mở trong trình duyệt hệ thống — tệp sẽ tự động tải về.", inAppDownloadStepCopy: - 'Mở menu ở góc trên bên phải và chọn "Mở trong trình duyệt".', + 'Nhấn "Sao chép liên kết" bên dưới (đây là URL tải tệp trực tiếp).', inAppDownloadStepOpen: - "Nếu không có lựa chọn đó, nhấn “Sao chép liên kết trang” bên dưới rồi mở trong Safari, Chrome,…", - inAppDownloadStepDownload: - "Khi trang được mở trong trình duyệt hệ thống, hãy nhấn lại nút tải để lưu tệp.", - inAppDownloadCopyPageLink: "Sao chép liên kết trang", - inAppDownloadCopied: "Đã sao chép liên kết trang", + "Mở trình duyệt hệ thống (Safari, Chrome…) và dán liên kết vào thanh địa chỉ.", + inAppDownloadStepDownload: "Tệp sẽ tự động tải xuống thư mục Tải về.", + inAppDownloadCopied: "Đã sao chép liên kết", inAppDownloadCopyFail: "Không sao chép được, vui lòng tự sao chép", }; diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 0e3fc85..356c09d 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -247,15 +247,13 @@ export const zhDict: Dict = { backToHome: "返回首页", inAppDownloadTitle: "请使用系统浏览器打开后下载", inAppDownloadIntro: - "当前内置浏览器无法下载文件。请在系统浏览器中打开本页面,然后再次点击下载按钮。", + "当前内置浏览器无法下载文件。复制下方链接,到系统浏览器打开即可直接下载。", inAppDownloadIntroNamed: - "{browser} 内置浏览器无法下载文件。请在系统浏览器中打开本页面,然后再次点击下载按钮。", - inAppDownloadStepCopy: "点击右上角菜单,选择「在默认浏览器中打开」。", + "{browser} 内置浏览器无法下载文件。复制下方链接,到系统浏览器打开即可直接下载。", + inAppDownloadStepCopy: "点击下方「复制链接」(这是文件的直接下载地址)。", inAppDownloadStepOpen: - "如果没有该选项,可点击下方「复制页面链接」,再到 Safari、Chrome 等浏览器中打开。", - inAppDownloadStepDownload: - "系统浏览器打开页面后,再次点击下载按钮即可把文件保存到下载文件夹。", - inAppDownloadCopyPageLink: "复制页面链接", - inAppDownloadCopied: "页面链接已复制", + "打开系统浏览器(Safari、Chrome 等),把链接粘贴到地址栏。", + inAppDownloadStepDownload: "文件会自动开始下载到下载文件夹。", + inAppDownloadCopied: "链接已复制", inAppDownloadCopyFail: "复制失败,请手动复制", }; From ec8ef5b774c3711438adf0327adc744e0ba98adf Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 5 Jun 2026 21:17:34 +0800 Subject: [PATCH 83/89] fix: hide URL preview in in-app download guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/InAppDownloadGuide.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/components/InAppDownloadGuide.tsx b/src/components/InAppDownloadGuide.tsx index 65acbc5..fa4187c 100644 --- a/src/components/InAppDownloadGuide.tsx +++ b/src/components/InAppDownloadGuide.tsx @@ -154,12 +154,6 @@ export function InAppDownloadGuideProvider({ -
-

- {detail.url} -

-
- ); })} @@ -259,7 +261,7 @@ function MobileLanguageButton({ {open ? (
@@ -282,7 +284,9 @@ function MobileLanguageButton({ }`} > - {option.label} + + {option.label} + ); })} @@ -694,7 +698,7 @@ export function PublicLayout() { lang={lang} setLang={changeLang} ariaLabel={t("langLabel")} - className="hidden h-10 w-36 md:block lg:w-40" + className="hidden h-10 md:block" /> Date: Fri, 5 Jun 2026 21:39:05 +0800 Subject: [PATCH 85/89] fix: stop language picker shrinking in flex header 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". --- src/layouts/PublicLayout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index 5ca46e9..6a250ad 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -152,7 +152,7 @@ function LanguageDropdown({