feat: 添加 Admin Dashboard — React 19 SPA,包含 7 个页面

- Dashboard: 健康状态轮询、状态卡片、内存统计、快捷操作
- Login: 二维码展示 + 3 秒自动轮询 + 倒计时 + 登出
- Browser: 探索/搜索/用户三标签页,Feed 网格、详情面板、评论树
- Publish: 图文/视频发布表单,支持标签、可见性、定时发布
- Interactions: 点赞/取消点赞、收藏、评论、回复 + 操作日志
- API Tester: 端点选择器、请求体编辑器、cURL 生成、响应查看、历史记录
- Settings: Token 配置、服务器 URL 设置

后端改动:
- app.ts: 生产环境提供 dist/web/ 静态文件服务 + SPA fallback
- Dockerfile: 添加 web 构建阶段
- package.json: 添加 build:web、build:all、dev:web 脚本

技术栈: React 19 + TypeScript + Vite 6 + Tailwind CSS(暗色主题)
产物: 85.5 KB gzip JS + 4 KB gzip CSS,零重型依赖
This commit is contained in:
2026-03-01 13:58:55 +08:00
parent 6d35387e2b
commit c6a8177718
51 changed files with 5665 additions and 1 deletions
+2
View File
@@ -1,5 +1,7 @@
node_modules/
dist/
web/node_modules/
web/dist/
*.tsbuildinfo
# Environment
+5 -1
View File
@@ -28,9 +28,13 @@ RUN npx rebrowser-playwright install chromium
COPY tsconfig.json tsup.config.ts ./
COPY src/ src/
# Build the project
# Build the backend
RUN npm run build
# Build the web dashboard
COPY web/ web/
RUN cd web && npm ci && npm run build && mkdir -p ../dist/web && cp -r dist/* ../dist/web/
# Remove devDependencies to slim down node_modules for production
RUN npm prune --omit=dev
+3
View File
@@ -9,7 +9,10 @@
},
"scripts": {
"build": "tsup",
"build:web": "cd web && npm run build && mkdir -p ../dist/web && cp -r dist/* ../dist/web/",
"build:all": "npm run build && npm run build:web",
"dev": "tsup --watch",
"dev:web": "cd web && npm run dev",
"start": "node dist/index.js",
"test": "vitest run",
"test:watch": "vitest",
+46
View File
@@ -1,4 +1,7 @@
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
@@ -147,6 +150,9 @@ export class AppServer {
}
}
// Serve the web dashboard (static SPA) in production.
this.setupWebDashboard();
// Re-register the error handler so it sits after any plugin routes.
this.app.use(errorHandler);
@@ -290,6 +296,46 @@ export class AppServer {
});
}
// -- Private: Web Dashboard (SPA static files) ----------------------------
private setupWebDashboard(): void {
// Resolve the web dashboard dist directory relative to this file.
// In the built output: dist/server/app.js → dist/web/ is at ../web
const thisDir = path.dirname(fileURLToPath(import.meta.url));
const webDir = path.resolve(thisDir, '..', 'web');
if (!fs.existsSync(webDir)) {
logger.debug({ webDir }, 'Web dashboard dist not found, skipping static mount');
return;
}
logger.info({ webDir }, 'Mounting web dashboard');
// Serve static assets
this.app.use(express.static(webDir, { index: false }));
// SPA fallback: any GET that doesn't match /api, /sse, /messages, /health
// returns index.html so client-side routing works.
this.app.get('*', (req, res, next) => {
// Skip API / MCP / health routes
if (
req.path.startsWith('/api') ||
req.path.startsWith('/sse') ||
req.path.startsWith('/messages') ||
req.path === '/health'
) {
next();
return;
}
const indexPath = path.join(webDir, 'index.html');
if (fs.existsSync(indexPath)) {
res.sendFile(indexPath);
} else {
next();
}
});
}
private async buildHealthResponse(): Promise<Record<string, unknown>> {
// Memory usage
const mem = process.memoryUsage();
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Social MCP - Admin Dashboard</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🤖</text></svg>" />
</head>
<body class="bg-dark-bg text-dark-text">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2808
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "social-mcp-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+33
View File
@@ -0,0 +1,33 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider } from '@/context/AuthContext';
import { ToastProvider } from '@/context/ToastContext';
import { Layout } from '@/components/layout/Layout';
import { DashboardPage } from '@/pages/DashboardPage';
import { LoginPage } from '@/pages/LoginPage';
import { BrowserPage } from '@/pages/BrowserPage';
import { PublishPage } from '@/pages/PublishPage';
import { InteractionsPage } from '@/pages/InteractionsPage';
import { ApiTesterPage } from '@/pages/ApiTesterPage';
import { SettingsPage } from '@/pages/SettingsPage';
export default function App() {
return (
<AuthProvider>
<ToastProvider>
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route index element={<DashboardPage />} />
<Route path="login" element={<LoginPage />} />
<Route path="browser" element={<BrowserPage />} />
<Route path="publish" element={<PublishPage />} />
<Route path="interactions" element={<InteractionsPage />} />
<Route path="api-tester" element={<ApiTesterPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
</Routes>
</BrowserRouter>
</ToastProvider>
</AuthProvider>
);
}
+76
View File
@@ -0,0 +1,76 @@
const getBaseUrl = (): string => {
return localStorage.getItem('smcp_server_url') || '';
};
const getToken = (): string | null => {
return localStorage.getItem('smcp_token');
};
export class ApiError extends Error {
constructor(
public status: number,
public code: string,
message: string,
) {
super(message);
this.name = 'ApiError';
}
}
export async function apiFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const baseUrl = getBaseUrl();
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...((options.headers as Record<string, string>) || {}),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const res = await fetch(`${baseUrl}${path}`, {
...options,
headers,
});
if (!res.ok) {
let code = 'UNKNOWN';
let message = `HTTP ${res.status}`;
try {
const body = await res.json();
if (body.error) {
code = body.error.code || code;
message = body.error.message || message;
}
} catch {
// ignore parse errors
}
throw new ApiError(res.status, code, message);
}
return res.json() as Promise<T>;
}
export function generateCurl(
method: string,
path: string,
body?: unknown,
): string {
const baseUrl = getBaseUrl() || 'http://127.0.0.1:3000';
const token = getToken();
const parts = [`curl -X ${method}`];
parts.push(`'${baseUrl}${path}'`);
if (token) {
parts.push(`-H 'Authorization: Bearer ${token}'`);
}
parts.push(`-H 'Content-Type: application/json'`);
if (body && method !== 'GET') {
parts.push(`-d '${JSON.stringify(body)}'`);
}
return parts.join(' \\\n ');
}
+110
View File
@@ -0,0 +1,110 @@
import { apiFetch } from './client';
import type {
LoginStatus,
QRCodeResult,
Feed,
FeedDetail,
UserProfile,
SearchFilters,
HealthResponse,
ApiResponse,
PublishResult,
InteractionResult,
CommentResult,
} from './types';
// Health (no auth required)
export const getHealth = () =>
apiFetch<HealthResponse>('/health');
// Login
export const getLoginStatus = () =>
apiFetch<ApiResponse<LoginStatus>>('/api/xhs/login/status');
export const getLoginQRCode = () =>
apiFetch<ApiResponse<QRCodeResult>>('/api/xhs/login/qrcode');
export const deleteCookies = () =>
apiFetch<ApiResponse<{ message: string }>>('/api/xhs/login/cookies', { method: 'DELETE' });
// Feeds
export const listFeeds = () =>
apiFetch<ApiResponse<Feed[]>>('/api/xhs/feeds');
export const searchFeeds = (keyword: string, filters?: SearchFilters) =>
apiFetch<ApiResponse<Feed[]>>('/api/xhs/search', {
method: 'POST',
body: JSON.stringify({ keyword, filters }),
});
export const getFeedDetail = (feedId: string, xsecToken: string, loadAllComments = false) =>
apiFetch<ApiResponse<FeedDetail>>('/api/xhs/feeds/detail', {
method: 'POST',
body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken, load_all_comments: loadAllComments }),
});
// User
export const getUserProfile = (userId: string, xsecToken: string) =>
apiFetch<ApiResponse<UserProfile>>('/api/xhs/user/profile', {
method: 'POST',
body: JSON.stringify({ user_id: userId, xsec_token: xsecToken }),
});
// Publish
export const publishImage = (data: {
title: string;
content: string;
images: string[];
tags?: string[];
schedule_at?: string;
is_original?: boolean;
visibility?: 'public' | 'private' | 'friends';
}) =>
apiFetch<ApiResponse<PublishResult>>('/api/xhs/publish/image', {
method: 'POST',
body: JSON.stringify(data),
});
export const publishVideo = (data: {
title: string;
content: string;
video: string;
tags?: string[];
schedule_at?: string;
visibility?: 'public' | 'private' | 'friends';
}) =>
apiFetch<ApiResponse<PublishResult>>('/api/xhs/publish/video', {
method: 'POST',
body: JSON.stringify(data),
});
// Interactions
export const postComment = (feedId: string, xsecToken: string, content: string) =>
apiFetch<ApiResponse<CommentResult>>('/api/xhs/comment', {
method: 'POST',
body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken, content }),
});
export const replyComment = (data: {
feed_id: string;
xsec_token: string;
content: string;
comment_id?: string;
user_id?: string;
}) =>
apiFetch<ApiResponse<CommentResult>>('/api/xhs/comment/reply', {
method: 'POST',
body: JSON.stringify(data),
});
export const toggleLike = (feedId: string, xsecToken: string, unlike = false) =>
apiFetch<ApiResponse<InteractionResult>>('/api/xhs/like', {
method: 'POST',
body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken, unlike }),
});
export const toggleFavorite = (feedId: string, xsecToken: string, unfavorite = false) =>
apiFetch<ApiResponse<InteractionResult>>('/api/xhs/favorite', {
method: 'POST',
body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken, unfavorite }),
});
+119
View File
@@ -0,0 +1,119 @@
// Mirror of backend types from src/platforms/xiaohongshu/types.ts
export interface LoginStatus {
loggedIn: boolean;
username?: string;
}
export interface QRCodeResult {
qrcodeData: string;
alreadyLoggedIn: boolean;
timeout: string;
}
export interface FeedUser {
id: string;
nickname: string;
avatar: string;
}
export interface Feed {
id: string;
xsecToken: string;
title: string;
description: string;
type: 'normal' | 'video';
coverUrl: string;
likeCount: number;
user: FeedUser;
}
export interface FeedDetail {
id: string;
xsecToken: string;
title: string;
description: string;
type: 'normal' | 'video';
images: string[];
videoUrl?: string;
tags: string[];
likeCount: number;
collectCount: number;
commentCount: number;
shareCount: number;
createTime: string;
lastUpdateTime: string;
ipLocation: string;
user: FeedUser;
comments: Comment[];
}
export interface Comment {
id: string;
userId: string;
nickname: string;
avatar: string;
content: string;
likeCount: number;
createTime: string;
ipLocation: string;
subComments: Comment[];
}
export interface UserProfile {
id: string;
nickname: string;
avatar: string;
description: string;
gender: string;
ipLocation: string;
follows: number;
fans: number;
interaction: number;
feedCount: number;
feeds: Feed[];
}
export interface SearchFilters {
sort?: 'general' | 'time_descending' | 'popularity_descending';
type?: 'all' | 'note' | 'video';
time?: 'all' | 'day' | 'week' | 'half_year';
}
export interface HealthResponse {
healthy: boolean;
version: string;
uptime: number;
shuttingDown: boolean;
activeSessions: number;
plugins: Record<string, { healthy: boolean; message?: string }>;
memory: {
rss: number;
heapUsed: number;
heapTotal: number;
external: number;
};
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: { code: string; message: string };
}
export interface PublishResult {
success: boolean;
noteId?: string;
message?: string;
}
export interface InteractionResult {
success: boolean;
action: string;
message?: string;
}
export interface CommentResult {
success: boolean;
message?: string;
}
+39
View File
@@ -0,0 +1,39 @@
import type { Comment } from '@/api/types';
import { formatTime } from '@/lib/formatters';
interface Props {
comments: Comment[];
depth?: number;
}
export function CommentTree({ comments, depth = 0 }: Props) {
return (
<div className={depth > 0 ? 'ml-6 border-l border-dark-border pl-4' : ''}>
{comments.map((comment) => (
<div key={comment.id} className="py-3 border-b border-dark-border/50 last:border-0">
<div className="flex items-start gap-2">
{comment.avatar && (
<img src={comment.avatar} alt="" className="w-6 h-6 rounded-full shrink-0 mt-0.5" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-dark-accent">{comment.nickname}</span>
<span className="text-xs text-dark-muted">{formatTime(comment.createTime)}</span>
{comment.ipLocation && (
<span className="text-xs text-dark-muted">{comment.ipLocation}</span>
)}
</div>
<p className="text-sm text-dark-text/90">{comment.content}</p>
{comment.likeCount > 0 && (
<span className="text-xs text-dark-muted mt-1 block">{comment.likeCount} likes</span>
)}
</div>
</div>
{comment.subComments.length > 0 && (
<CommentTree comments={comment.subComments} depth={depth + 1} />
)}
</div>
))}
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import type { Feed } from '@/api/types';
import { Badge } from '@/components/ui/Badge';
import { formatNumber } from '@/lib/formatters';
interface Props {
feed: Feed;
onClick?: () => void;
}
export function FeedCard({ feed, onClick }: Props) {
return (
<div
onClick={onClick}
className="bg-dark-card border border-dark-border rounded-xl overflow-hidden cursor-pointer hover:border-dark-accent/40 transition-colors group"
>
<div className="aspect-[4/3] relative overflow-hidden bg-dark-bg">
{feed.coverUrl ? (
<img
src={feed.coverUrl}
alt={feed.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-dark-muted text-sm">
No Cover
</div>
)}
{feed.type === 'video' && (
<Badge variant="info" className="absolute top-2 right-2">Video</Badge>
)}
</div>
<div className="p-3">
<h3 className="text-sm font-medium line-clamp-2 mb-2">{feed.title || feed.description || 'Untitled'}</h3>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
{feed.user.avatar && (
<img src={feed.user.avatar} alt="" className="w-5 h-5 rounded-full shrink-0" />
)}
<span className="text-xs text-dark-muted truncate">{feed.user.nickname}</span>
</div>
<span className="text-xs text-dark-muted shrink-0">{formatNumber(feed.likeCount)} likes</span>
</div>
</div>
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
import { useState, useEffect } from 'react';
import type { FeedDetail as FeedDetailType } from '@/api/types';
import { getFeedDetail } from '@/api/endpoints';
import { Badge } from '@/components/ui/Badge';
import { Spinner } from '@/components/ui/Spinner';
import { Button } from '@/components/ui/Button';
import { CommentTree } from './CommentTree';
import { formatNumber, formatTime } from '@/lib/formatters';
interface Props {
feedId: string;
xsecToken: string;
onClose: () => void;
onUserClick?: (userId: string, xsecToken: string) => void;
}
export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
const [detail, setDetail] = useState<FeedDetailType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentImage, setCurrentImage] = useState(0);
useEffect(() => {
setLoading(true);
setError(null);
void getFeedDetail(feedId, xsecToken)
.then((res) => {
if (res.success && res.data) {
setDetail(res.data);
} else {
setError(res.error?.message || 'Failed to load detail');
}
})
.catch((err) => setError(err instanceof Error ? err.message : 'Error'))
.finally(() => setLoading(false));
}, [feedId, xsecToken]);
return (
<div className="fixed inset-0 z-50 flex">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative ml-auto w-full max-w-2xl bg-dark-card border-l border-dark-border overflow-y-auto">
<div className="sticky top-0 bg-dark-card border-b border-dark-border px-5 py-3 flex items-center justify-between z-10">
<h3 className="font-semibold truncate">Feed Detail</h3>
<button onClick={onClose} className="text-dark-muted hover:text-dark-text text-xl">&times;</button>
</div>
{loading && (
<div className="flex justify-center py-20"><Spinner size="lg" /></div>
)}
{error && (
<div className="p-5 text-dark-danger">{error}</div>
)}
{detail && (
<div className="p-5 space-y-5">
{/* Images */}
{detail.images.length > 0 && (
<div>
<div className="rounded-xl overflow-hidden bg-dark-bg">
<img
src={detail.images[currentImage]}
alt=""
className="w-full max-h-96 object-contain"
/>
</div>
{detail.images.length > 1 && (
<div className="flex gap-2 mt-2 overflow-x-auto pb-1">
{detail.images.map((img, i) => (
<button
key={i}
onClick={() => setCurrentImage(i)}
className={`w-14 h-14 rounded-lg overflow-hidden shrink-0 border-2 ${
i === currentImage ? 'border-dark-accent' : 'border-transparent'
}`}
>
<img src={img} alt="" className="w-full h-full object-cover" />
</button>
))}
</div>
)}
</div>
)}
{/* Video */}
{detail.videoUrl && (
<div className="rounded-xl overflow-hidden bg-dark-bg p-4">
<Badge variant="info">Video Note</Badge>
<p className="text-xs text-dark-muted mt-2 break-all">{detail.videoUrl}</p>
</div>
)}
{/* Title & Content */}
<div>
<h2 className="text-xl font-bold mb-2">{detail.title}</h2>
<p className="text-sm text-dark-text/80 whitespace-pre-wrap">{detail.description}</p>
</div>
{/* Tags */}
{detail.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{detail.tags.map((tag) => (
<Badge key={tag} variant="info">#{tag}</Badge>
))}
</div>
)}
{/* Stats */}
<div className="grid grid-cols-4 gap-3">
{[
{ label: 'Likes', value: detail.likeCount },
{ label: 'Collects', value: detail.collectCount },
{ label: 'Comments', value: detail.commentCount },
{ label: 'Shares', value: detail.shareCount },
].map((s) => (
<div key={s.label} className="bg-dark-bg rounded-lg p-3 text-center">
<p className="text-lg font-bold">{formatNumber(s.value)}</p>
<p className="text-xs text-dark-muted">{s.label}</p>
</div>
))}
</div>
{/* Author */}
<div
className="flex items-center gap-3 p-3 bg-dark-bg rounded-lg cursor-pointer hover:bg-dark-hover"
onClick={() => onUserClick?.(detail.user.id, detail.xsecToken)}
>
{detail.user.avatar && (
<img src={detail.user.avatar} alt="" className="w-10 h-10 rounded-full" />
)}
<div>
<p className="text-sm font-medium">{detail.user.nickname}</p>
<p className="text-xs text-dark-muted">{detail.ipLocation} · {formatTime(detail.createTime)}</p>
</div>
</div>
{/* IDs for interaction */}
<div className="bg-dark-bg rounded-lg p-3 text-xs space-y-1">
<p><span className="text-dark-muted">Feed ID:</span> <code className="text-dark-accent">{detail.id}</code></p>
<p><span className="text-dark-muted">xsec_token:</span> <code className="text-dark-accent">{detail.xsecToken}</code></p>
<p><span className="text-dark-muted">User ID:</span> <code className="text-dark-accent">{detail.user.id}</code></p>
<div className="flex gap-2 mt-2">
<Button
size="sm"
variant="ghost"
onClick={() => void navigator.clipboard.writeText(detail.id)}
>
Copy Feed ID
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => void navigator.clipboard.writeText(detail.xsecToken)}
>
Copy Token
</Button>
</div>
</div>
{/* Comments */}
{detail.comments.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">
Comments ({detail.comments.length})
</h3>
<CommentTree comments={detail.comments} />
</div>
)}
</div>
)}
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { Feed } from '@/api/types';
import { FeedCard } from './FeedCard';
import { Spinner } from '@/components/ui/Spinner';
interface Props {
feeds: Feed[];
loading: boolean;
onSelect: (feed: Feed) => void;
emptyText?: string;
}
export function FeedGrid({ feeds, loading, onSelect, emptyText = 'No feeds found' }: Props) {
if (loading) {
return (
<div className="flex justify-center py-12">
<Spinner size="lg" />
</div>
);
}
if (feeds.length === 0) {
return (
<div className="text-center py-12 text-dark-muted">{emptyText}</div>
);
}
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{feeds.map((feed) => (
<FeedCard key={feed.id} feed={feed} onClick={() => onSelect(feed)} />
))}
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { useState, useEffect } from 'react';
import type { UserProfile, Feed } from '@/api/types';
import { getUserProfile } from '@/api/endpoints';
import { Spinner } from '@/components/ui/Spinner';
import { Badge } from '@/components/ui/Badge';
import { FeedGrid } from './FeedGrid';
import { formatNumber } from '@/lib/formatters';
interface Props {
userId: string;
xsecToken: string;
onFeedSelect: (feed: Feed) => void;
}
export function UserCard({ userId, xsecToken, onFeedSelect }: Props) {
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
setError(null);
void getUserProfile(userId, xsecToken)
.then((res) => {
if (res.success && res.data) {
setProfile(res.data);
} else {
setError(res.error?.message || 'Failed to load profile');
}
})
.catch((err) => setError(err instanceof Error ? err.message : 'Error'))
.finally(() => setLoading(false));
}, [userId, xsecToken]);
if (loading) return <div className="flex justify-center py-12"><Spinner size="lg" /></div>;
if (error) return <div className="text-dark-danger py-8 text-center">{error}</div>;
if (!profile) return null;
return (
<div className="space-y-6">
{/* Profile header */}
<div className="flex items-start gap-4">
{profile.avatar && (
<img src={profile.avatar} alt="" className="w-16 h-16 rounded-full" />
)}
<div>
<h2 className="text-xl font-bold">{profile.nickname}</h2>
<p className="text-sm text-dark-muted mt-1">{profile.description}</p>
<div className="flex items-center gap-2 mt-2">
{profile.gender && <Badge>{profile.gender}</Badge>}
{profile.ipLocation && <Badge>{profile.ipLocation}</Badge>}
</div>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-3">
{[
{ label: 'Follows', value: profile.follows },
{ label: 'Fans', value: profile.fans },
{ label: 'Interactions', value: profile.interaction },
{ label: 'Notes', value: profile.feedCount },
].map((s) => (
<div key={s.label} className="bg-dark-bg rounded-lg p-3 text-center">
<p className="text-lg font-bold">{formatNumber(s.value)}</p>
<p className="text-xs text-dark-muted">{s.label}</p>
</div>
))}
</div>
{/* User's feeds */}
{profile.feeds.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">
Recent Notes ({profile.feeds.length})
</h3>
<FeedGrid feeds={profile.feeds} loading={false} onSelect={onFeedSelect} />
</div>
)}
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { useHealth } from '@/hooks/useHealth';
import { Badge } from '@/components/ui/Badge';
export function Header() {
const { health } = useHealth(15_000);
return (
<header className="h-14 bg-dark-card border-b border-dark-border flex items-center justify-between px-6 shrink-0">
<div />
<div className="flex items-center gap-3">
{health && (
<Badge variant={health.healthy ? 'success' : 'danger'}>
{health.healthy ? 'Healthy' : 'Unhealthy'}
</Badge>
)}
{!health && <Badge variant="warning">Connecting...</Badge>}
</div>
</header>
);
}
+40
View File
@@ -0,0 +1,40 @@
// Simple inline SVG icons for the sidebar navigation
const s = { width: 20, height: 20, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const };
export const DashboardIcon = () => (
<svg {...s}><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></svg>
);
export const LoginIcon = () => (
<svg {...s}><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><polyline points="10 17 15 12 10 7" /><line x1="15" y1="12" x2="3" y2="12" /></svg>
);
export const BrowserIcon = () => (
<svg {...s}><circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg>
);
export const PublishIcon = () => (
<svg {...s}><path d="M12 5v14" /><path d="M5 12h14" /></svg>
);
export const InteractionsIcon = () => (
<svg {...s}><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /></svg>
);
export const ApiIcon = () => (
<svg {...s}><polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" /></svg>
);
export const SettingsIcon = () => (
<svg {...s}><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></svg>
);
export const iconMap: Record<string, React.FC> = {
dashboard: DashboardIcon,
login: LoginIcon,
browser: BrowserIcon,
publish: PublishIcon,
interactions: InteractionsIcon,
api: ApiIcon,
settings: SettingsIcon,
};
+17
View File
@@ -0,0 +1,17 @@
import { Outlet } from 'react-router-dom';
import { Sidebar } from './Sidebar';
import { Header } from './Header';
export function Layout() {
return (
<div className="flex h-screen overflow-hidden bg-dark-bg">
<Sidebar />
<div className="flex flex-col flex-1 overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { NavLink } from 'react-router-dom';
import { cn } from '@/lib/cn';
import { NAV_ITEMS } from '@/lib/constants';
import { iconMap } from './Icons';
export function Sidebar() {
return (
<aside className="w-56 h-screen bg-dark-card border-r border-dark-border flex flex-col shrink-0">
<div className="h-14 flex items-center px-5 border-b border-dark-border">
<span className="text-lg font-bold text-dark-accent">Social MCP</span>
</div>
<nav className="flex-1 py-3 px-3 flex flex-col gap-0.5 overflow-y-auto">
{NAV_ITEMS.map((item) => {
const Icon = iconMap[item.icon];
return (
<NavLink
key={item.path}
to={item.path}
end={item.path === '/'}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-dark-accent/10 text-dark-accent'
: 'text-dark-muted hover:text-dark-text hover:bg-dark-hover',
)
}
>
{Icon && <Icon />}
{item.label}
</NavLink>
);
})}
</nav>
<div className="px-5 py-3 border-t border-dark-border">
<p className="text-xs text-dark-muted">v0.1.0</p>
</div>
</aside>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { cn } from '@/lib/cn';
interface Props {
variant?: 'default' | 'success' | 'danger' | 'warning' | 'info';
children: React.ReactNode;
className?: string;
}
export function Badge({ variant = 'default', children, className }: Props) {
return (
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
variant === 'default' && 'bg-dark-border text-dark-muted',
variant === 'success' && 'bg-dark-success/20 text-dark-success',
variant === 'danger' && 'bg-dark-danger/20 text-dark-danger',
variant === 'warning' && 'bg-dark-warning/20 text-dark-warning',
variant === 'info' && 'bg-dark-accent/20 text-dark-accent',
className,
)}
>
{children}
</span>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { cn } from '@/lib/cn';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
children: ReactNode;
}
export function Button({ variant = 'primary', size = 'md', loading, className, children, disabled, ...rest }: Props) {
return (
<button
disabled={disabled || loading}
className={cn(
'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-dark-accent/50 disabled:opacity-50 disabled:cursor-not-allowed',
size === 'sm' && 'px-3 py-1.5 text-xs',
size === 'md' && 'px-4 py-2 text-sm',
size === 'lg' && 'px-6 py-3 text-base',
variant === 'primary' && 'bg-dark-accent text-white hover:bg-dark-accent/80',
variant === 'secondary' && 'bg-dark-card text-dark-text border border-dark-border hover:bg-dark-hover',
variant === 'danger' && 'bg-dark-danger/20 text-dark-danger border border-dark-danger/30 hover:bg-dark-danger/30',
variant === 'ghost' && 'text-dark-muted hover:text-dark-text hover:bg-dark-hover',
className,
)}
{...rest}
>
{loading && (
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{children}
</button>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { cn } from '@/lib/cn';
import type { HTMLAttributes, ReactNode } from 'react';
interface Props extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
padding?: boolean;
}
export function Card({ children, padding = true, className, ...rest }: Props) {
return (
<div
className={cn(
'bg-dark-card border border-dark-border rounded-xl',
padding && 'p-5',
className,
)}
{...rest}
>
{children}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { cn } from '@/lib/cn';
import { forwardRef, type InputHTMLAttributes } from 'react';
interface Props extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export const Input = forwardRef<HTMLInputElement, Props>(
({ label, error, className, id, ...rest }, ref) => {
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-');
return (
<div className="flex flex-col gap-1.5">
{label && (
<label htmlFor={inputId} className="text-sm text-dark-muted">
{label}
</label>
)}
<input
ref={ref}
id={inputId}
className={cn(
'bg-dark-bg border border-dark-border rounded-lg px-3 py-2 text-sm text-dark-text placeholder-dark-muted/50 focus:outline-none focus:border-dark-accent transition-colors',
error && 'border-dark-danger',
className,
)}
{...rest}
/>
{error && <span className="text-xs text-dark-danger">{error}</span>}
</div>
);
},
);
Input.displayName = 'Input';
+39
View File
@@ -0,0 +1,39 @@
import { useState } from 'react';
interface Props {
data: unknown;
collapsed?: boolean;
maxHeight?: string;
}
export function JsonViewer({ data, collapsed = false, maxHeight = '400px' }: Props) {
const [isCollapsed, setIsCollapsed] = useState(collapsed);
const json = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
return (
<div className="relative">
<div className="flex items-center justify-between mb-1">
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="text-xs text-dark-muted hover:text-dark-text"
>
{isCollapsed ? 'Expand' : 'Collapse'}
</button>
<button
onClick={() => void navigator.clipboard.writeText(json)}
className="text-xs text-dark-muted hover:text-dark-accent"
>
Copy
</button>
</div>
{!isCollapsed && (
<pre
className="bg-dark-bg border border-dark-border rounded-lg p-4 text-xs text-dark-text overflow-auto font-mono"
style={{ maxHeight }}
>
{json}
</pre>
)}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, type ReactNode } from 'react';
interface Props {
open: boolean;
onClose: () => void;
title?: string;
children: ReactNode;
wide?: boolean;
}
export function Modal({ open, onClose, title, children, wide }: Props) {
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div
className={`relative bg-dark-card border border-dark-border rounded-xl shadow-2xl max-h-[85vh] flex flex-col ${wide ? 'w-[700px]' : 'w-[480px]'} max-w-[95vw]`}
>
{title && (
<div className="flex items-center justify-between px-5 py-4 border-b border-dark-border">
<h3 className="text-lg font-semibold">{title}</h3>
<button onClick={onClose} className="text-dark-muted hover:text-dark-text text-xl leading-none">
&times;
</button>
</div>
)}
<div className="p-5 overflow-y-auto">{children}</div>
</div>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { cn } from '@/lib/cn';
import type { SelectHTMLAttributes } from 'react';
interface Option {
value: string;
label: string;
}
interface Props extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
options: Option[];
}
export function Select({ label, options, className, id, ...rest }: Props) {
const selectId = id || label?.toLowerCase().replace(/\s+/g, '-');
return (
<div className="flex flex-col gap-1.5">
{label && (
<label htmlFor={selectId} className="text-sm text-dark-muted">
{label}
</label>
)}
<select
id={selectId}
className={cn(
'bg-dark-bg border border-dark-border rounded-lg px-3 py-2 text-sm text-dark-text focus:outline-none focus:border-dark-accent transition-colors',
className,
)}
{...rest}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { cn } from '@/lib/cn';
interface Props {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function Spinner({ size = 'md', className }: Props) {
return (
<svg
className={cn(
'animate-spin text-dark-accent',
size === 'sm' && 'h-4 w-4',
size === 'md' && 'h-6 w-6',
size === 'lg' && 'h-10 w-10',
className,
)}
viewBox="0 0 24 24"
fill="none"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { cn } from '@/lib/cn';
interface Tab {
key: string;
label: string;
}
interface Props {
tabs: Tab[];
active: string;
onChange: (key: string) => void;
}
export function Tabs({ tabs, active, onChange }: Props) {
return (
<div className="flex gap-1 border-b border-dark-border mb-4">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => onChange(tab.key)}
className={cn(
'px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px',
active === tab.key
? 'border-dark-accent text-dark-accent'
: 'border-transparent text-dark-muted hover:text-dark-text',
)}
>
{tab.label}
</button>
))}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { cn } from '@/lib/cn';
import { forwardRef, type TextareaHTMLAttributes } from 'react';
interface Props extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
}
export const Textarea = forwardRef<HTMLTextAreaElement, Props>(
({ label, error, className, id, ...rest }, ref) => {
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-');
return (
<div className="flex flex-col gap-1.5">
{label && (
<label htmlFor={inputId} className="text-sm text-dark-muted">
{label}
</label>
)}
<textarea
ref={ref}
id={inputId}
className={cn(
'bg-dark-bg border border-dark-border rounded-lg px-3 py-2 text-sm text-dark-text placeholder-dark-muted/50 focus:outline-none focus:border-dark-accent transition-colors resize-y min-h-[80px]',
error && 'border-dark-danger',
className,
)}
{...rest}
/>
{error && <span className="text-xs text-dark-danger">{error}</span>}
</div>
);
},
);
Textarea.displayName = 'Textarea';
+37
View File
@@ -0,0 +1,37 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
interface AuthState {
token: string;
serverUrl: string;
setToken: (t: string) => void;
setServerUrl: (u: string) => void;
}
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setTokenState] = useState(() => localStorage.getItem('smcp_token') || '');
const [serverUrl, setServerUrlState] = useState(() => localStorage.getItem('smcp_server_url') || '');
const setToken = useCallback((t: string) => {
localStorage.setItem('smcp_token', t);
setTokenState(t);
}, []);
const setServerUrl = useCallback((u: string) => {
localStorage.setItem('smcp_server_url', u);
setServerUrlState(u);
}, []);
return (
<AuthContext.Provider value={{ token, serverUrl, setToken, setServerUrl }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
+67
View File
@@ -0,0 +1,67 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
type ToastType = 'success' | 'error' | 'info' | 'warning';
interface Toast {
id: number;
type: ToastType;
message: string;
}
interface ToastState {
toasts: Toast[];
toast: (type: ToastType, message: string) => void;
dismiss: (id: number) => void;
}
const ToastContext = createContext<ToastState | null>(null);
let nextId = 0;
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const dismiss = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const toast = useCallback(
(type: ToastType, message: string) => {
const id = nextId++;
setToasts((prev) => [...prev, { id, type, message }]);
setTimeout(() => dismiss(id), 4000);
},
[dismiss],
);
return (
<ToastContext.Provider value={{ toasts, toast, dismiss }}>
{children}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
{toasts.map((t) => (
<div
key={t.id}
onClick={() => dismiss(t.id)}
className={`px-4 py-3 rounded-lg shadow-lg cursor-pointer text-sm font-medium transition-all ${
t.type === 'success'
? 'bg-dark-success/20 text-dark-success border border-dark-success/30'
: t.type === 'error'
? 'bg-dark-danger/20 text-dark-danger border border-dark-danger/30'
: t.type === 'warning'
? 'bg-dark-warning/20 text-dark-warning border border-dark-warning/30'
: 'bg-dark-accent/20 text-dark-accent border border-dark-accent/30'
}`}
>
{t.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastState {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within ToastProvider');
return ctx;
}
+30
View File
@@ -0,0 +1,30 @@
import { useState, useEffect, useCallback } from 'react';
import { getHealth } from '@/api/endpoints';
import type { HealthResponse } from '@/api/types';
export function useHealth(intervalMs = 10_000) {
const [health, setHealth] = useState<HealthResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const data = await getHealth();
setHealth(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch health');
setHealth(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
const id = setInterval(() => void refresh(), intervalMs);
return () => clearInterval(id);
}, [refresh, intervalMs]);
return { health, error, loading, refresh };
}
+25
View File
@@ -0,0 +1,25 @@
import { useState, useCallback } from 'react';
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
setStoredValue((prev) => {
const next = value instanceof Function ? value(prev) : value;
localStorage.setItem(key, JSON.stringify(next));
return next;
});
},
[key],
);
return [storedValue, setValue];
}
+35
View File
@@ -0,0 +1,35 @@
import { useState, useEffect, useCallback } from 'react';
import { getLoginStatus } from '@/api/endpoints';
import type { LoginStatus } from '@/api/types';
export function useLoginStatus(intervalMs = 0) {
const [status, setStatus] = useState<LoginStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const res = await getLoginStatus();
if (res.success && res.data) {
setStatus(res.data);
setError(null);
} else {
setError(res.error?.message || 'Unknown error');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to check login status');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
if (intervalMs > 0) {
const id = setInterval(() => void refresh(), intervalMs);
return () => clearInterval(id);
}
}, [refresh, intervalMs]);
return { status, error, loading, refresh };
}
+21
View File
@@ -0,0 +1,21 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
scrollbar-width: thin;
scrollbar-color: #30363d #0d1117;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: #0d1117;
}
*::-webkit-scrollbar-thumb {
background-color: #30363d;
border-radius: 3px;
}
}
+3
View File
@@ -0,0 +1,3 @@
export function cn(...classes: (string | false | null | undefined)[]): string {
return classes.filter(Boolean).join(' ');
}
+25
View File
@@ -0,0 +1,25 @@
export const NAV_ITEMS = [
{ path: '/', label: 'Dashboard', icon: 'dashboard' },
{ path: '/login', label: 'Login', icon: 'login' },
{ path: '/browser', label: 'Browser', icon: 'browser' },
{ path: '/publish', label: 'Publish', icon: 'publish' },
{ path: '/interactions', label: 'Interactions', icon: 'interactions' },
{ path: '/api-tester', label: 'API Tester', icon: 'api' },
{ path: '/settings', label: 'Settings', icon: 'settings' },
] as const;
export const API_ENDPOINTS = [
{ key: 'login_status', method: 'GET', path: '/api/xhs/login/status', label: 'Check Login Status', category: 'Login' },
{ key: 'login_qrcode', method: 'GET', path: '/api/xhs/login/qrcode', label: 'Get Login QR Code', category: 'Login' },
{ key: 'login_delete', method: 'DELETE', path: '/api/xhs/login/cookies', label: 'Delete Cookies (Logout)', category: 'Login' },
{ key: 'feeds', method: 'GET', path: '/api/xhs/feeds', label: 'List Feeds', category: 'Content' },
{ key: 'search', method: 'POST', path: '/api/xhs/search', label: 'Search', category: 'Content', body: { keyword: '', filters: { sort: 'general', type: 'all', time: 'all' } } },
{ key: 'feed_detail', method: 'POST', path: '/api/xhs/feeds/detail', label: 'Feed Detail', category: 'Content', body: { feed_id: '', xsec_token: '', load_all_comments: false } },
{ key: 'user_profile', method: 'POST', path: '/api/xhs/user/profile', label: 'User Profile', category: 'Content', body: { user_id: '', xsec_token: '' } },
{ key: 'publish_image', method: 'POST', path: '/api/xhs/publish/image', label: 'Publish Image Note', category: 'Publish', body: { title: '', content: '', images: [], tags: [], is_original: false, visibility: 'public' } },
{ key: 'publish_video', method: 'POST', path: '/api/xhs/publish/video', label: 'Publish Video Note', category: 'Publish', body: { title: '', content: '', video: '', tags: [], visibility: 'public' } },
{ key: 'comment', method: 'POST', path: '/api/xhs/comment', label: 'Post Comment', category: 'Interaction', body: { feed_id: '', xsec_token: '', content: '' } },
{ key: 'comment_reply', method: 'POST', path: '/api/xhs/comment/reply', label: 'Reply Comment', category: 'Interaction', body: { feed_id: '', xsec_token: '', content: '', comment_id: '', user_id: '' } },
{ key: 'like', method: 'POST', path: '/api/xhs/like', label: 'Like/Unlike', category: 'Interaction', body: { feed_id: '', xsec_token: '', unlike: false } },
{ key: 'favorite', method: 'POST', path: '/api/xhs/favorite', label: 'Favorite/Unfavorite', category: 'Interaction', body: { feed_id: '', xsec_token: '', unfavorite: false } },
] as const;
+26
View File
@@ -0,0 +1,26 @@
export function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
const parts: string[] = [];
if (d > 0) parts.push(`${d}d`);
if (h > 0) parts.push(`${h}h`);
if (m > 0) parts.push(`${m}m`);
parts.push(`${s}s`);
return parts.join(' ');
}
export function formatNumber(n: number): string {
if (n >= 10000) return `${(n / 10000).toFixed(1)}w`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
}
export function formatTime(iso: string): string {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+220
View File
@@ -0,0 +1,220 @@
import { useState, useCallback } from 'react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
import { Badge } from '@/components/ui/Badge';
import { JsonViewer } from '@/components/ui/JsonViewer';
import { API_ENDPOINTS } from '@/lib/constants';
import { apiFetch, generateCurl } from '@/api/client';
interface HistoryEntry {
id: number;
method: string;
path: string;
status: 'success' | 'error';
time: string;
duration: number;
response: unknown;
}
let historyId = 0;
export function ApiTesterPage() {
const [selectedKey, setSelectedKey] = useState<string>(API_ENDPOINTS[0]!.key);
const [bodyText, setBodyText] = useState('');
const [loading, setLoading] = useState(false);
const [response, setResponse] = useState<unknown>(null);
const [responseStatus, setResponseStatus] = useState<number | null>(null);
const [duration, setDuration] = useState<number | null>(null);
const [history, setHistory] = useState<HistoryEntry[]>([]);
const endpoint = API_ENDPOINTS.find((e) => e.key === selectedKey)!;
const handleEndpointChange = useCallback((key: string) => {
setSelectedKey(key);
setResponse(null);
setResponseStatus(null);
setDuration(null);
const ep = API_ENDPOINTS.find((e) => e.key === key);
if (ep && 'body' in ep && ep.body) {
setBodyText(JSON.stringify(ep.body, null, 2));
} else {
setBodyText('');
}
}, []);
const handleSend = useCallback(async () => {
setLoading(true);
setResponse(null);
const start = Date.now();
try {
let body: unknown = undefined;
if (bodyText.trim() && endpoint.method !== 'GET') {
body = JSON.parse(bodyText);
}
const res = await apiFetch<unknown>(endpoint.path, {
method: endpoint.method,
...(body ? { body: JSON.stringify(body) } : {}),
});
const dur = Date.now() - start;
setResponse(res);
setResponseStatus(200);
setDuration(dur);
const entry: HistoryEntry = { id: historyId++, method: endpoint.method, path: endpoint.path, status: 'success', time: new Date().toLocaleTimeString(), duration: dur, response: res };
setHistory((prev) => [entry, ...prev].slice(0, 20));
} catch (err) {
const dur = Date.now() - start;
const errData = err instanceof Error ? { error: err.message } : { error: String(err) };
setResponse(errData);
setResponseStatus((err as { status?: number }).status || 500);
setDuration(dur);
const entry: HistoryEntry = { id: historyId++, method: endpoint.method, path: endpoint.path, status: 'error', time: new Date().toLocaleTimeString(), duration: dur, response: errData };
setHistory((prev) => [entry, ...prev].slice(0, 20));
} finally {
setLoading(false);
}
}, [endpoint, bodyText]);
const curl = generateCurl(
endpoint.method,
endpoint.path,
bodyText.trim() && endpoint.method !== 'GET' ? (() => { try { return JSON.parse(bodyText); } catch { return undefined; } })() : undefined,
);
// Group endpoints by category
const categories = [...new Set(API_ENDPOINTS.map((e) => e.category))];
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold">API Tester</h1>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Left: Request */}
<div className="lg:col-span-2 space-y-4">
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">Request</h2>
{/* Endpoint selector */}
<div className="space-y-3">
<Select
label="Endpoint"
options={categories.flatMap((cat) => [
{ value: `__cat_${cat}`, label: `── ${cat} ──` },
...API_ENDPOINTS.filter((e) => e.category === cat).map((e) => ({
value: e.key,
label: `${e.method} ${e.path}`,
})),
])}
value={selectedKey}
onChange={(e) => {
if (!e.target.value.startsWith('__cat_')) {
handleEndpointChange(e.target.value);
}
}}
/>
<div className="flex items-center gap-2">
<Badge variant={endpoint.method === 'GET' ? 'success' : endpoint.method === 'DELETE' ? 'danger' : 'info'}>
{endpoint.method}
</Badge>
<code className="text-sm text-dark-text font-mono">{endpoint.path}</code>
</div>
{/* Body editor */}
{endpoint.method !== 'GET' && (
<div className="flex flex-col gap-1.5">
<label className="text-sm text-dark-muted">Request Body (JSON)</label>
<textarea
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
className="bg-dark-bg border border-dark-border rounded-lg px-3 py-2 text-sm text-dark-text font-mono focus:outline-none focus:border-dark-accent transition-colors resize-y min-h-[120px]"
spellCheck={false}
/>
</div>
)}
<div className="flex gap-2">
<Button onClick={() => void handleSend()} loading={loading}>
Send Request
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => void navigator.clipboard.writeText(curl)}
>
Copy cURL
</Button>
</div>
</div>
</Card>
{/* cURL preview */}
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-2">cURL</h2>
<pre className="bg-dark-bg border border-dark-border rounded-lg p-3 text-xs font-mono text-dark-text overflow-x-auto whitespace-pre-wrap">
{curl}
</pre>
</Card>
{/* Response */}
{response !== null && (
<Card>
<div className="flex items-center justify-between mb-2">
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider">Response</h2>
<div className="flex items-center gap-2">
{responseStatus && (
<Badge variant={responseStatus < 300 ? 'success' : 'danger'}>
{responseStatus}
</Badge>
)}
{duration !== null && (
<span className="text-xs text-dark-muted">{duration}ms</span>
)}
</div>
</div>
<JsonViewer data={response} maxHeight="500px" />
</Card>
)}
</div>
{/* Right: History */}
<div>
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider">History</h2>
<Button variant="ghost" size="sm" onClick={() => setHistory([])}>Clear</Button>
</div>
{history.length === 0 ? (
<p className="text-sm text-dark-muted">No requests yet</p>
) : (
<div className="space-y-2 max-h-[600px] overflow-y-auto">
{history.map((entry) => (
<div
key={entry.id}
className="p-2 rounded-lg border border-dark-border/50 hover:bg-dark-hover cursor-pointer text-xs"
onClick={() => {
setResponse(entry.response);
setResponseStatus(entry.status === 'success' ? 200 : 500);
setDuration(entry.duration);
}}
>
<div className="flex items-center gap-2">
<Badge variant={entry.status === 'success' ? 'success' : 'danger'} className="text-[10px]">
{entry.method}
</Badge>
<span className="font-mono truncate">{entry.path}</span>
</div>
<div className="flex items-center gap-2 mt-1 text-dark-muted">
<span>{entry.time}</span>
<span>{entry.duration}ms</span>
</div>
</div>
))}
</div>
)}
</Card>
</div>
</div>
</div>
);
}
+233
View File
@@ -0,0 +1,233 @@
import { useState, useCallback } from 'react';
import { Tabs } from '@/components/ui/Tabs';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { FeedGrid } from '@/components/feed/FeedGrid';
import { FeedDetail } from '@/components/feed/FeedDetail';
import { UserCard } from '@/components/feed/UserCard';
import { useToast } from '@/context/ToastContext';
import { listFeeds, searchFeeds } from '@/api/endpoints';
import type { Feed, SearchFilters } from '@/api/types';
type TabKey = 'explore' | 'search' | 'user';
export function BrowserPage() {
const { toast } = useToast();
const [tab, setTab] = useState<TabKey>('explore');
// Explore state
const [feeds, setFeeds] = useState<Feed[]>([]);
const [feedsLoading, setFeedsLoading] = useState(false);
// Search state
const [keyword, setKeyword] = useState('');
const [sortFilter, setSortFilter] = useState('general');
const [typeFilter, setTypeFilter] = useState('all');
const [timeFilter, setTimeFilter] = useState('all');
const [searchResults, setSearchResults] = useState<Feed[]>([]);
const [searchLoading, setSearchLoading] = useState(false);
// Detail panel
const [selectedFeed, setSelectedFeed] = useState<{ id: string; xsecToken: string } | null>(null);
// User panel
const [userView, setUserView] = useState<{ userId: string; xsecToken: string } | null>(null);
const [manualUserId, setManualUserId] = useState('');
const [manualUserToken, setManualUserToken] = useState('');
const handleExplore = useCallback(async () => {
setFeedsLoading(true);
try {
const res = await listFeeds();
if (res.success && res.data) {
setFeeds(res.data);
} else {
toast('error', res.error?.message || 'Failed to load feeds');
}
} catch (err) {
toast('error', err instanceof Error ? err.message : 'Failed to load feeds');
} finally {
setFeedsLoading(false);
}
}, [toast]);
const handleSearch = useCallback(async () => {
if (!keyword.trim()) {
toast('warning', 'Enter a keyword');
return;
}
setSearchLoading(true);
try {
const filters: SearchFilters = {};
if (sortFilter !== 'general') filters.sort = sortFilter as SearchFilters['sort'];
if (typeFilter !== 'all') filters.type = typeFilter as SearchFilters['type'];
if (timeFilter !== 'all') filters.time = timeFilter as SearchFilters['time'];
const res = await searchFeeds(keyword, Object.keys(filters).length > 0 ? filters : undefined);
if (res.success && res.data) {
setSearchResults(res.data);
} else {
toast('error', res.error?.message || 'Search failed');
}
} catch (err) {
toast('error', err instanceof Error ? err.message : 'Search failed');
} finally {
setSearchLoading(false);
}
}, [keyword, sortFilter, typeFilter, timeFilter, toast]);
const handleFeedSelect = useCallback((feed: Feed) => {
setSelectedFeed({ id: feed.id, xsecToken: feed.xsecToken });
}, []);
const handleUserClick = useCallback((userId: string, xsecToken: string) => {
setUserView({ userId, xsecToken });
setTab('user');
setSelectedFeed(null);
}, []);
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold">Content Browser</h1>
<Tabs
tabs={[
{ key: 'explore', label: 'Explore' },
{ key: 'search', label: 'Search' },
{ key: 'user', label: 'User Profile' },
]}
active={tab}
onChange={(k) => setTab(k as TabKey)}
/>
{/* Explore Tab */}
{tab === 'explore' && (
<div className="space-y-4">
<Button onClick={() => void handleExplore()} loading={feedsLoading}>
Load Feed
</Button>
<FeedGrid
feeds={feeds}
loading={feedsLoading}
onSelect={handleFeedSelect}
emptyText="Click 'Load Feed' to get recommended content"
/>
</div>
)}
{/* Search Tab */}
{tab === 'search' && (
<div className="space-y-4">
<div className="flex gap-3 items-end flex-wrap">
<div className="flex-1 min-w-[200px]">
<Input
label="Keyword"
placeholder="Search xiaohongshu..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && void handleSearch()}
/>
</div>
<Select
label="Sort"
options={[
{ value: 'general', label: 'Default' },
{ value: 'time_descending', label: 'Latest' },
{ value: 'popularity_descending', label: 'Popular' },
]}
value={sortFilter}
onChange={(e) => setSortFilter(e.target.value)}
/>
<Select
label="Type"
options={[
{ value: 'all', label: 'All' },
{ value: 'note', label: 'Notes' },
{ value: 'video', label: 'Videos' },
]}
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
/>
<Select
label="Time"
options={[
{ value: 'all', label: 'Any time' },
{ value: 'day', label: 'Past day' },
{ value: 'week', label: 'Past week' },
{ value: 'half_year', label: 'Past 6 months' },
]}
value={timeFilter}
onChange={(e) => setTimeFilter(e.target.value)}
/>
<Button onClick={() => void handleSearch()} loading={searchLoading}>
Search
</Button>
</div>
<FeedGrid
feeds={searchResults}
loading={searchLoading}
onSelect={handleFeedSelect}
emptyText="Enter a keyword and search"
/>
</div>
)}
{/* User Tab */}
{tab === 'user' && (
<div className="space-y-4">
{!userView && (
<div className="text-center py-12 text-dark-muted">
<p>Click on a user in a feed detail to view their profile</p>
<p className="text-xs mt-2">Or enter user details manually:</p>
<div className="flex gap-3 items-end justify-center mt-4">
<Input
placeholder="User ID"
value={manualUserId}
onChange={(e) => setManualUserId(e.target.value)}
/>
<Input
placeholder="xsec_token"
value={manualUserToken}
onChange={(e) => setManualUserToken(e.target.value)}
/>
<Button
size="sm"
onClick={() => {
if (manualUserId && manualUserToken) {
setUserView({ userId: manualUserId, xsecToken: manualUserToken });
}
}}
>
Load
</Button>
</div>
</div>
)}
{userView && userView.userId && userView.xsecToken && (
<div>
<Button variant="ghost" size="sm" onClick={() => setUserView(null)} className="mb-4">
&larr; Back
</Button>
<UserCard
userId={userView.userId}
xsecToken={userView.xsecToken}
onFeedSelect={handleFeedSelect}
/>
</div>
)}
</div>
)}
{/* Feed Detail Slide-over */}
{selectedFeed && (
<FeedDetail
feedId={selectedFeed.id}
xsecToken={selectedFeed.xsecToken}
onClose={() => setSelectedFeed(null)}
onUserClick={handleUserClick}
/>
)}
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
import { useHealth } from '@/hooks/useHealth';
import { useLoginStatus } from '@/hooks/useLoginStatus';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Spinner } from '@/components/ui/Spinner';
import { Button } from '@/components/ui/Button';
import { formatUptime } from '@/lib/formatters';
import { useNavigate } from 'react-router-dom';
export function DashboardPage() {
const { health, loading: healthLoading, refresh: refreshHealth } = useHealth(10_000);
const { status: loginStatus, loading: loginLoading } = useLoginStatus();
const navigate = useNavigate();
return (
<div className="space-y-6 max-w-5xl">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Dashboard</h1>
<Button variant="ghost" size="sm" onClick={() => void refreshHealth()}>
Refresh
</Button>
</div>
{/* Status cards row */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Server Status */}
<Card>
<div className="text-xs text-dark-muted uppercase tracking-wider mb-2">Server</div>
{healthLoading ? (
<Spinner size="sm" />
) : health ? (
<div className="space-y-1">
<Badge variant={health.healthy ? 'success' : 'danger'}>
{health.healthy ? 'Healthy' : 'Unhealthy'}
</Badge>
<p className="text-sm text-dark-muted">v{health.version}</p>
</div>
) : (
<Badge variant="danger">Offline</Badge>
)}
</Card>
{/* Uptime */}
<Card>
<div className="text-xs text-dark-muted uppercase tracking-wider mb-2">Uptime</div>
{health ? (
<p className="text-xl font-mono font-bold text-dark-text">{formatUptime(health.uptime)}</p>
) : (
<p className="text-dark-muted">-</p>
)}
</Card>
{/* Login Status */}
<Card>
<div className="text-xs text-dark-muted uppercase tracking-wider mb-2">Xiaohongshu Login</div>
{loginLoading ? (
<Spinner size="sm" />
) : loginStatus ? (
<div className="space-y-1">
<Badge variant={loginStatus.loggedIn ? 'success' : 'warning'}>
{loginStatus.loggedIn ? 'Logged In' : 'Not Logged In'}
</Badge>
{loginStatus.username && (
<p className="text-sm text-dark-muted">{loginStatus.username}</p>
)}
</div>
) : (
<Badge variant="warning">Unknown</Badge>
)}
</Card>
{/* Memory */}
<Card>
<div className="text-xs text-dark-muted uppercase tracking-wider mb-2">Memory</div>
{health ? (
<div className="space-y-1">
<p className="text-xl font-mono font-bold text-dark-text">{health.memory.heapUsed} MB</p>
<p className="text-xs text-dark-muted">of {health.memory.heapTotal} MB heap</p>
</div>
) : (
<p className="text-dark-muted">-</p>
)}
</Card>
</div>
{/* Plugin Health */}
{health && Object.keys(health.plugins).length > 0 && (
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">Plugins</h2>
<div className="space-y-2">
{Object.entries(health.plugins).map(([name, info]) => (
<div key={name} className="flex items-center justify-between py-1">
<span className="text-sm">{name}</span>
<Badge variant={info.healthy ? 'success' : 'danger'}>
{info.healthy ? 'Healthy' : info.message || 'Unhealthy'}
</Badge>
</div>
))}
</div>
</Card>
)}
{/* Quick actions */}
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">Quick Actions</h2>
<div className="flex flex-wrap gap-3">
<Button variant="secondary" size="sm" onClick={() => navigate('/login')}>
Manage Login
</Button>
<Button variant="secondary" size="sm" onClick={() => navigate('/browser')}>
Browse Content
</Button>
<Button variant="secondary" size="sm" onClick={() => navigate('/publish')}>
Publish Note
</Button>
<Button variant="secondary" size="sm" onClick={() => navigate('/api-tester')}>
API Tester
</Button>
</div>
</Card>
{/* Raw health data */}
{health && (
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">
Raw Health Data
</h2>
<pre className="bg-dark-bg border border-dark-border rounded-lg p-4 text-xs text-dark-text overflow-auto font-mono max-h-64">
{JSON.stringify(health, null, 2)}
</pre>
</Card>
)}
</div>
);
}
+204
View File
@@ -0,0 +1,204 @@
import { useState, useCallback } from 'react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Textarea } from '@/components/ui/Textarea';
import { JsonViewer } from '@/components/ui/JsonViewer';
import { useToast } from '@/context/ToastContext';
import { toggleLike, toggleFavorite, postComment, replyComment } from '@/api/endpoints';
interface LogEntry {
id: number;
action: string;
time: string;
result: unknown;
}
let logId = 0;
export function InteractionsPage() {
const { toast } = useToast();
const [feedId, setFeedId] = useState('');
const [xsecToken, setXsecToken] = useState('');
const [loading, setLoading] = useState<string | null>(null);
const [log, setLog] = useState<LogEntry[]>([]);
const addLog = (action: string, result: unknown) => {
setLog((prev) => [{ id: logId++, action, time: new Date().toLocaleTimeString(), result }, ...prev].slice(0, 50));
};
// Comment state
const [commentText, setCommentText] = useState('');
// Reply state
const [replyText, setReplyText] = useState('');
const [replyCommentId, setReplyCommentId] = useState('');
const [replyUserId, setReplyUserId] = useState('');
const checkIds = () => {
if (!feedId.trim() || !xsecToken.trim()) {
toast('warning', 'Feed ID and xsec_token are required');
return false;
}
return true;
};
const handleLike = useCallback(async (unlike: boolean) => {
if (!checkIds()) return;
setLoading(unlike ? 'unlike' : 'like');
try {
const res = await toggleLike(feedId, xsecToken, unlike);
addLog(unlike ? 'Unlike' : 'Like', res);
toast('success', unlike ? 'Unliked' : 'Liked');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed';
addLog(unlike ? 'Unlike' : 'Like', { error: msg });
toast('error', msg);
} finally {
setLoading(null);
}
}, [feedId, xsecToken, toast]);
const handleFavorite = useCallback(async (unfavorite: boolean) => {
if (!checkIds()) return;
setLoading(unfavorite ? 'unfavorite' : 'favorite');
try {
const res = await toggleFavorite(feedId, xsecToken, unfavorite);
addLog(unfavorite ? 'Unfavorite' : 'Favorite', res);
toast('success', unfavorite ? 'Unfavorited' : 'Favorited');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed';
addLog(unfavorite ? 'Unfavorite' : 'Favorite', { error: msg });
toast('error', msg);
} finally {
setLoading(null);
}
}, [feedId, xsecToken, toast]);
const handleComment = useCallback(async () => {
if (!checkIds() || !commentText.trim()) {
toast('warning', 'Comment text is required');
return;
}
setLoading('comment');
try {
const res = await postComment(feedId, xsecToken, commentText);
addLog('Comment', res);
toast('success', 'Comment posted');
setCommentText('');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed';
addLog('Comment', { error: msg });
toast('error', msg);
} finally {
setLoading(null);
}
}, [feedId, xsecToken, commentText, toast]);
const handleReply = useCallback(async () => {
if (!checkIds() || !replyText.trim()) {
toast('warning', 'Reply text is required');
return;
}
setLoading('reply');
try {
const res = await replyComment({
feed_id: feedId,
xsec_token: xsecToken,
content: replyText,
comment_id: replyCommentId || undefined,
user_id: replyUserId || undefined,
});
addLog('Reply', res);
toast('success', 'Reply posted');
setReplyText('');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed';
addLog('Reply', { error: msg });
toast('error', msg);
} finally {
setLoading(null);
}
}, [feedId, xsecToken, replyText, replyCommentId, replyUserId, toast]);
return (
<div className="max-w-3xl space-y-6">
<h1 className="text-2xl font-bold">Interactions</h1>
{/* Target */}
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">Target Note</h2>
<div className="grid grid-cols-2 gap-4">
<Input label="Feed ID" value={feedId} onChange={(e) => setFeedId(e.target.value)} placeholder="Feed ID" />
<Input label="xsec_token" value={xsecToken} onChange={(e) => setXsecToken(e.target.value)} placeholder="xsec_token" />
</div>
</Card>
{/* Like / Favorite */}
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">Quick Actions</h2>
<div className="flex flex-wrap gap-3">
<Button onClick={() => void handleLike(false)} loading={loading === 'like'} size="sm">
Like
</Button>
<Button onClick={() => void handleLike(true)} loading={loading === 'unlike'} variant="secondary" size="sm">
Unlike
</Button>
<Button onClick={() => void handleFavorite(false)} loading={loading === 'favorite'} size="sm">
Favorite
</Button>
<Button onClick={() => void handleFavorite(true)} loading={loading === 'unfavorite'} variant="secondary" size="sm">
Unfavorite
</Button>
</div>
</Card>
{/* Comment */}
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">Post Comment</h2>
<div className="space-y-3">
<Textarea value={commentText} onChange={(e) => setCommentText(e.target.value)} placeholder="Write a comment..." />
<Button onClick={() => void handleComment()} loading={loading === 'comment'} size="sm">
Post Comment
</Button>
</div>
</Card>
{/* Reply */}
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">Reply to Comment</h2>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<Input label="Comment ID" value={replyCommentId} onChange={(e) => setReplyCommentId(e.target.value)} placeholder="Optional" />
<Input label="User ID" value={replyUserId} onChange={(e) => setReplyUserId(e.target.value)} placeholder="Optional" />
</div>
<Textarea value={replyText} onChange={(e) => setReplyText(e.target.value)} placeholder="Write a reply..." />
<Button onClick={() => void handleReply()} loading={loading === 'reply'} size="sm">
Post Reply
</Button>
</div>
</Card>
{/* Log */}
{log.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider">Action Log</h2>
<Button variant="ghost" size="sm" onClick={() => setLog([])}>Clear</Button>
</div>
<div className="space-y-3 max-h-96 overflow-y-auto">
{log.map((entry) => (
<div key={entry.id} className="border-b border-dark-border/50 pb-2 last:border-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-dark-muted">{entry.time}</span>
<span className="text-sm font-medium text-dark-accent">{entry.action}</span>
</div>
<JsonViewer data={entry.result} collapsed maxHeight="120px" />
</div>
))}
</div>
</Card>
)}
</div>
);
}
+192
View File
@@ -0,0 +1,192 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Spinner } from '@/components/ui/Spinner';
import { useLoginStatus } from '@/hooks/useLoginStatus';
import { useToast } from '@/context/ToastContext';
import { getLoginQRCode, deleteCookies, getLoginStatus } from '@/api/endpoints';
export function LoginPage() {
const { status, loading: statusLoading, refresh: refreshStatus } = useLoginStatus();
const { toast } = useToast();
const [qrData, setQrData] = useState<string | null>(null);
const [qrLoading, setQrLoading] = useState(false);
const [polling, setPolling] = useState(false);
const [countdown, setCountdown] = useState(0);
const [logoutLoading, setLogoutLoading] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
const countdownRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
const stopPolling = useCallback(() => {
setPolling(false);
if (pollRef.current) clearInterval(pollRef.current);
if (countdownRef.current) clearInterval(countdownRef.current);
}, []);
const handleGetQR = useCallback(async () => {
stopPolling();
setQrLoading(true);
setQrData(null);
try {
const res = await getLoginQRCode();
if (res.success && res.data) {
if (res.data.alreadyLoggedIn) {
toast('info', 'Already logged in!');
void refreshStatus();
return;
}
setQrData(res.data.qrcodeData);
// Start polling
setPolling(true);
setCountdown(240); // 4 min
pollRef.current = setInterval(async () => {
try {
const statusRes = await getLoginStatus();
if (statusRes.success && statusRes.data?.loggedIn) {
stopPolling();
setQrData(null);
toast('success', `Logged in as ${statusRes.data.username || 'user'}`);
void refreshStatus();
}
} catch {
// ignore poll errors
}
}, 3000);
countdownRef.current = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
stopPolling();
setQrData(null);
toast('warning', 'QR code expired');
return 0;
}
return prev - 1;
});
}, 1000);
} else {
toast('error', res.error?.message || 'Failed to get QR code');
}
} catch (err) {
toast('error', err instanceof Error ? err.message : 'Failed to get QR code');
} finally {
setQrLoading(false);
}
}, [stopPolling, toast, refreshStatus]);
const handleLogout = useCallback(async () => {
setLogoutLoading(true);
try {
const res = await deleteCookies();
if (res.success) {
toast('success', 'Logged out successfully');
void refreshStatus();
} else {
toast('error', res.error?.message || 'Failed to logout');
}
} catch (err) {
toast('error', err instanceof Error ? err.message : 'Failed to logout');
} finally {
setLogoutLoading(false);
}
}, [toast, refreshStatus]);
useEffect(() => {
return () => stopPolling();
}, [stopPolling]);
const formatCountdown = (secs: number) => {
const m = Math.floor(secs / 60);
const s = secs % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
};
return (
<div className="max-w-2xl space-y-6">
<h1 className="text-2xl font-bold">Xiaohongshu Login</h1>
{/* Current Status */}
<Card>
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-2">
Current Status
</h2>
{statusLoading ? (
<Spinner size="sm" />
) : status ? (
<div className="flex items-center gap-3">
<Badge variant={status.loggedIn ? 'success' : 'warning'}>
{status.loggedIn ? 'Logged In' : 'Not Logged In'}
</Badge>
{status.username && <span className="text-sm">{status.username}</span>}
</div>
) : (
<Badge variant="danger">Unable to check</Badge>
)}
</div>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => void refreshStatus()}>
Refresh
</Button>
{status?.loggedIn && (
<Button variant="danger" size="sm" onClick={() => void handleLogout()} loading={logoutLoading}>
Logout
</Button>
)}
</div>
</div>
</Card>
{/* QR Code Login */}
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-4">
QR Code Login
</h2>
{!qrData && !qrLoading && (
<div className="text-center py-8">
<p className="text-dark-muted mb-4">Click the button to generate a QR code for login</p>
<Button onClick={() => void handleGetQR()} disabled={status?.loggedIn}>
Get QR Code
</Button>
</div>
)}
{qrLoading && (
<div className="flex flex-col items-center py-8 gap-3">
<Spinner size="lg" />
<p className="text-sm text-dark-muted">Generating QR code...</p>
</div>
)}
{qrData && (
<div className="flex flex-col items-center gap-4">
<div className="bg-white rounded-xl p-4">
<img src={qrData} alt="Login QR Code" className="w-64 h-64" />
</div>
<p className="text-sm text-dark-muted">Scan with Xiaohongshu app to login</p>
{polling && (
<div className="flex items-center gap-3">
<Spinner size="sm" />
<span className="text-sm text-dark-accent">
Waiting for scan... {formatCountdown(countdown)}
</span>
</div>
)}
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => void handleGetQR()}>
Refresh QR
</Button>
<Button variant="ghost" size="sm" onClick={stopPolling}>
Cancel
</Button>
</div>
</div>
)}
</Card>
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
import { useState, useCallback } from 'react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Textarea } from '@/components/ui/Textarea';
import { Select } from '@/components/ui/Select';
import { Tabs } from '@/components/ui/Tabs';
import { JsonViewer } from '@/components/ui/JsonViewer';
import { useToast } from '@/context/ToastContext';
import { publishImage, publishVideo } from '@/api/endpoints';
export function PublishPage() {
const { toast } = useToast();
const [tab, setTab] = useState('image');
// Image form
const [imgTitle, setImgTitle] = useState('');
const [imgContent, setImgContent] = useState('');
const [imgPaths, setImgPaths] = useState('');
const [imgTags, setImgTags] = useState('');
const [imgVisibility, setImgVisibility] = useState('public');
const [imgOriginal, setImgOriginal] = useState(false);
const [imgLoading, setImgLoading] = useState(false);
const [imgResult, setImgResult] = useState<unknown>(null);
// Video form
const [vidTitle, setVidTitle] = useState('');
const [vidContent, setVidContent] = useState('');
const [vidPath, setVidPath] = useState('');
const [vidTags, setVidTags] = useState('');
const [vidVisibility, setVidVisibility] = useState('public');
const [vidLoading, setVidLoading] = useState(false);
const [vidResult, setVidResult] = useState<unknown>(null);
const handlePublishImage = useCallback(async () => {
if (!imgTitle.trim() || !imgPaths.trim()) {
toast('warning', 'Title and images are required');
return;
}
setImgLoading(true);
setImgResult(null);
try {
const images = imgPaths.split('\n').map((s) => s.trim()).filter(Boolean);
const tags = imgTags.split(',').map((s) => s.trim()).filter(Boolean);
const res = await publishImage({
title: imgTitle,
content: imgContent,
images,
tags: tags.length > 0 ? tags : undefined,
is_original: imgOriginal,
visibility: imgVisibility as 'public' | 'private' | 'friends',
});
setImgResult(res);
if (res.success) {
toast('success', 'Image note published!');
} else {
toast('error', res.error?.message || 'Publish failed');
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Publish failed';
toast('error', msg);
setImgResult({ error: msg });
} finally {
setImgLoading(false);
}
}, [imgTitle, imgContent, imgPaths, imgTags, imgVisibility, imgOriginal, toast]);
const handlePublishVideo = useCallback(async () => {
if (!vidTitle.trim() || !vidPath.trim()) {
toast('warning', 'Title and video path are required');
return;
}
setVidLoading(true);
setVidResult(null);
try {
const tags = vidTags.split(',').map((s) => s.trim()).filter(Boolean);
const res = await publishVideo({
title: vidTitle,
content: vidContent,
video: vidPath,
tags: tags.length > 0 ? tags : undefined,
visibility: vidVisibility as 'public' | 'private' | 'friends',
});
setVidResult(res);
if (res.success) {
toast('success', 'Video note published!');
} else {
toast('error', res.error?.message || 'Publish failed');
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Publish failed';
toast('error', msg);
setVidResult({ error: msg });
} finally {
setVidLoading(false);
}
}, [vidTitle, vidContent, vidPath, vidTags, vidVisibility, toast]);
return (
<div className="max-w-3xl space-y-4">
<h1 className="text-2xl font-bold">Publish Note</h1>
<Tabs
tabs={[
{ key: 'image', label: 'Image Note' },
{ key: 'video', label: 'Video Note' },
]}
active={tab}
onChange={setTab}
/>
{tab === 'image' && (
<Card>
<div className="space-y-4">
<Input label="Title" value={imgTitle} onChange={(e) => setImgTitle(e.target.value)} placeholder="Note title" />
<Textarea label="Content" value={imgContent} onChange={(e) => setImgContent(e.target.value)} placeholder="Note body text" />
<Textarea label="Image Paths (one per line)" value={imgPaths} onChange={(e) => setImgPaths(e.target.value)} placeholder="/path/to/image1.jpg&#10;/path/to/image2.jpg" />
<Input label="Tags (comma separated)" value={imgTags} onChange={(e) => setImgTags(e.target.value)} placeholder="travel, food" />
<div className="flex gap-4 items-end">
<Select
label="Visibility"
options={[
{ value: 'public', label: 'Public' },
{ value: 'private', label: 'Private' },
{ value: 'friends', label: 'Friends' },
]}
value={imgVisibility}
onChange={(e) => setImgVisibility(e.target.value)}
/>
<label className="flex items-center gap-2 pb-2 cursor-pointer">
<input type="checkbox" checked={imgOriginal} onChange={(e) => setImgOriginal(e.target.checked)} className="rounded" />
<span className="text-sm text-dark-muted">Original content</span>
</label>
</div>
<Button onClick={() => void handlePublishImage()} loading={imgLoading}>
Publish Image Note
</Button>
{imgResult !== null && <JsonViewer data={imgResult} />}
</div>
</Card>
)}
{tab === 'video' && (
<Card>
<div className="space-y-4">
<Input label="Title" value={vidTitle} onChange={(e) => setVidTitle(e.target.value)} placeholder="Note title" />
<Textarea label="Content" value={vidContent} onChange={(e) => setVidContent(e.target.value)} placeholder="Note body text" />
<Input label="Video Path" value={vidPath} onChange={(e) => setVidPath(e.target.value)} placeholder="/path/to/video.mp4" />
<Input label="Tags (comma separated)" value={vidTags} onChange={(e) => setVidTags(e.target.value)} placeholder="travel, vlog" />
<Select
label="Visibility"
options={[
{ value: 'public', label: 'Public' },
{ value: 'private', label: 'Private' },
{ value: 'friends', label: 'Friends' },
]}
value={vidVisibility}
onChange={(e) => setVidVisibility(e.target.value)}
/>
<Button onClick={() => void handlePublishVideo()} loading={vidLoading}>
Publish Video Note
</Button>
{vidResult !== null && <JsonViewer data={vidResult} />}
</div>
</Card>
)}
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { Card } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { useAuth } from '@/context/AuthContext';
import { useToast } from '@/context/ToastContext';
export function SettingsPage() {
const { token, serverUrl, setToken, setServerUrl } = useAuth();
const { toast } = useToast();
return (
<div className="max-w-2xl space-y-6">
<h1 className="text-2xl font-bold">Settings</h1>
{/* Server Connection */}
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-4">Server Connection</h2>
<div className="space-y-4">
<Input
label="Server URL"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
placeholder="Leave empty for same-origin (default)"
/>
<p className="text-xs text-dark-muted">
Leave empty when the dashboard is served by the same Express server.
Set to e.g. <code className="text-dark-accent">http://192.168.1.100:3000</code> for remote servers.
</p>
</div>
</Card>
{/* Authentication */}
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-4">Authentication</h2>
<div className="space-y-4">
<Input
label="Bearer Token"
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Enter your API token"
/>
<p className="text-xs text-dark-muted">
Token from <code className="text-dark-accent">BEARER_TOKEN</code> environment variable or{' '}
<code className="text-dark-accent">.social-mcp/bearer-token</code> file.
</p>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => {
toast('success', 'Settings saved');
}}
>
Save
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setToken('');
setServerUrl('');
toast('info', 'Settings cleared');
}}
>
Clear All
</Button>
</div>
</div>
</Card>
{/* About */}
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-4">About</h2>
<div className="space-y-2 text-sm text-dark-muted">
<p><span className="text-dark-text">Social MCP</span> Multi-platform social media automation</p>
<p>Version: 0.1.0</p>
<p>Stack: React 19 + TypeScript + Tailwind CSS</p>
</div>
</Card>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { Config } from 'tailwindcss';
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
darkMode: 'class',
theme: {
extend: {
colors: {
dark: {
bg: '#0d1117',
card: '#161b22',
border: '#30363d',
hover: '#1c2128',
text: '#e6edf3',
muted: '#8b949e',
accent: '#58a6ff',
success: '#3fb950',
warning: '#d29922',
danger: '#f85149',
},
},
},
},
plugins: [],
} satisfies Config;
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:3000',
changeOrigin: true,
},
'/health': {
target: 'http://127.0.0.1:3000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
},
});