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:
@@ -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>
|
||||
Generated
+2808
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ');
|
||||
}
|
||||
@@ -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 }),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">×</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-5 overflow-y-auto">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function cn(...classes: (string | false | null | undefined)[]): string {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 /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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user