feat: add wallet provider foundation

This commit is contained in:
TerryM
2026-06-02 00:28:22 +08:00
parent df20005357
commit 71dac8373e
17 changed files with 8276 additions and 85 deletions

View File

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

7833
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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,6 +30,8 @@ export default function App() {
<I18nProvider>
<MotionProvider>
<ToastProvider>
<RainbowWalletProvider>
<WalletProvider>
<SaveToAlbumGuideProvider>
<AdminRouterModeProvider value="absolute">
<ImageLightboxProvider>
@@ -60,7 +64,10 @@ export default function App() {
path="/resource/:id"
element={<PostRedirect />}
/>
<Route path="/favorites" element={<Favorites />} />
<Route
path="/favorites"
element={<Favorites />}
/>
{/* Each non-English language gets its own nested tree. */}
{localizedHomeRoutes.map((route) => (
@@ -68,7 +75,9 @@ export default function App() {
<Route
index
element={
<LocalizedHomePage targetLang={route.lang} />
<LocalizedHomePage
targetLang={route.lang}
/>
}
/>
<Route path="browse" element={<Browse />} />
@@ -84,12 +93,18 @@ export default function App() {
path="category/:slug"
element={<CategoryPage />}
/>
<Route path="search" element={<SearchPage />} />
<Route
path="search"
element={<SearchPage />}
/>
<Route
path="resource/:id"
element={<PostRedirect />}
/>
<Route path="favorites" element={<Favorites />} />
<Route
path="favorites"
element={<Favorites />}
/>
</Route>
))}
</Route>
@@ -103,7 +118,10 @@ export default function App() {
/>
)}
<Route path="*" element={<Navigate to="/" replace />} />
<Route
path="*"
element={<Navigate to="/" replace />}
/>
</Routes>
</BrowserRouter>
</PageTitleProvider>
@@ -111,6 +129,8 @@ export default function App() {
</ImageLightboxProvider>
</AdminRouterModeProvider>
</SaveToAlbumGuideProvider>
</WalletProvider>
</RainbowWalletProvider>
</ToastProvider>
</MotionProvider>
</I18nProvider>

View File

@@ -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",

View File

@@ -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",

View File

@@ -143,6 +143,9 @@ export const jaDict: Dict = {
favorites: "お気に入り",
favoritesComingSoon: "近日公開",
favoritesComingSoonDesc: "ログインとお気に入り機能は開発中です。お楽しみに。",
walletLoginSuccess: "ウォレットを接続しました",
walletLoginFailed: "ウォレットログインに失敗しました",
walletDisconnected: "ウォレットを切断しました",
featureUnavailable: "未公開",
featureUnavailableDesc: "この機能はまだご利用いただけません。",
confirm: "了解",

View File

@@ -143,6 +143,9 @@ export const koDict: Dict = {
favoritesComingSoon: "출시 예정",
favoritesComingSoonDesc:
"로그인과 즐겨찾기 기능을 개발 중입니다. 많은 기대 부탁드립니다.",
walletLoginSuccess: "지갑이 연결되었습니다",
walletLoginFailed: "지갑 로그인에 실패했습니다",
walletDisconnected: "지갑 연결이 해제되었습니다",
featureUnavailable: "준비 중",
featureUnavailableDesc: "이 기능은 아직 사용할 수 없습니다.",
confirm: "확인",

View File

@@ -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",

View File

@@ -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",

View File

@@ -140,6 +140,9 @@ export const zhDict: Dict = {
favorites: "我的收藏",
favoritesComingSoon: "功能即将推出",
favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。",
walletLoginSuccess: "钱包已连接",
walletLoginFailed: "钱包登录失败",
walletDisconnected: "钱包已断开",
featureUnavailable: "未开放",
featureUnavailableDesc: "该功能暂未开放。",
confirm: "知道了",

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

@@ -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;
}

View File

@@ -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 (
<WagmiProvider config={wagmiConfig}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider
modalSize="compact"
theme={darkTheme({
accentColor: "#d7b46a",
accentColorForeground: "#08070c",
borderRadius: "large",
fontStack: "system",
overlayBlur: "small",
})}
>
{children}
</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
);
}
export function hasWalletConnectProjectId(): boolean {
return Boolean(import.meta.env.VITE_WALLETCONNECT_PROJECT_ID);
}

View File

@@ -0,0 +1,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<void>;
completeLogin: (token: string, wallet: string) => void;
logout: () => void;
};
const WalletContext = createContext<WalletContextValue | null>(null);
export function shortenAddress(address: string): string {
if (address.length <= 12) return address;
return `${address.slice(0, 6)}...${address.slice(-4)}`;
}
export function WalletProvider({ children }: { children: ReactNode }) {
const { t } = useI18n();
const { showToast } = useToast();
const [token, setToken] = useState<string | null>(() => readWalletToken());
const [address, setAddress] = useState<string | null>(null);
const [status, setStatus] = useState<WalletStatus>(
token ? "loading" : "loggedOut",
);
const [loginModalOpen, setLoginModalOpen] = useState(false);
useEffect(() => {
let cancelled = false;
if (!token) {
setStatus("loggedOut");
setAddress(null);
return;
}
setStatus("loading");
fetchWalletMe(token)
.then((me) => {
if (cancelled) return;
setAddress(me.wallet);
setStatus("loggedIn");
})
.catch(() => {
if (cancelled) return;
clearWalletToken();
setToken(null);
setAddress(null);
setStatus("loggedOut");
});
return () => {
cancelled = true;
};
}, [token]);
const completeLogin = useCallback((nextToken: string, wallet: string) => {
writeWalletToken(nextToken);
setToken(nextToken);
setAddress(wallet);
setStatus("loggedIn");
setLoginModalOpen(false);
}, []);
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<WalletContextValue>(
() => ({
address,
token,
status,
loginModalOpen,
openLoginModal: () => setLoginModalOpen(true),
closeLoginModal: () => setLoginModalOpen(false),
signInInjected,
completeLogin,
logout,
}),
[
address,
completeLogin,
loginModalOpen,
logout,
signInInjected,
status,
token,
],
);
return (
<WalletContext.Provider value={value}>{children}</WalletContext.Provider>
);
}
export function useWallet() {
const ctx = useContext(WalletContext);
if (!ctx) throw new Error("useWallet must be used within WalletProvider");
return ctx;
}

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

@@ -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<WalletNonceResponse> {
return postJSON<WalletNonceResponse>("/api/auth/wallet/nonce", { address });
}
export function verifyWalletSignature(params: {
address: string;
message: string;
signature: string;
}): Promise<WalletVerifyResponse> {
return postJSON<WalletVerifyResponse>("/api/auth/wallet/verify", params);
}
export function fetchWalletMe(token: string): Promise<WalletMeResponse> {
return getJSONAuth<WalletMeResponse>("/api/auth/wallet/me", token);
}
export function createTokenPocketLoginRequest(): Promise<TokenPocketLoginRequest> {
return postJSON<TokenPocketLoginRequest>(
"/api/auth/wallet/tp-login-request",
{},
);
}
export async function fetchTokenPocketLoginResult(
actionId: string,
): Promise<TokenPocketLoginResult> {
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<TokenPocketLoginResult>;
}

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

@@ -0,0 +1,39 @@
import { requestWalletNonce, verifyWalletSignature } from "./api";
export type EthereumProvider = {
request: <T = unknown>(args: {
method: string;
params?: unknown[];
}) => Promise<T>;
};
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<string[]>({
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<string>({
method: "personal_sign",
params: [nonce.message, address],
});
return verifyWalletSignature({
address,
message: nonce.message,
signature,
});
}

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

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