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:
@@ -1,5 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
|
|||||||
+5
-1
@@ -28,9 +28,13 @@ RUN npx rebrowser-playwright install chromium
|
|||||||
COPY tsconfig.json tsup.config.ts ./
|
COPY tsconfig.json tsup.config.ts ./
|
||||||
COPY src/ src/
|
COPY src/ src/
|
||||||
|
|
||||||
# Build the project
|
# Build the backend
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# Build the web dashboard
|
||||||
|
COPY web/ web/
|
||||||
|
RUN cd web && npm ci && npm run build && mkdir -p ../dist/web && cp -r dist/* ../dist/web/
|
||||||
|
|
||||||
# Remove devDependencies to slim down node_modules for production
|
# Remove devDependencies to slim down node_modules for production
|
||||||
RUN npm prune --omit=dev
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,10 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
|
"build:web": "cd web && npm run build && mkdir -p ../dist/web && cp -r dist/* ../dist/web/",
|
||||||
|
"build:all": "npm run build && npm run build:web",
|
||||||
"dev": "tsup --watch",
|
"dev": "tsup --watch",
|
||||||
|
"dev:web": "cd web && npm run dev",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
@@ -147,6 +150,9 @@ export class AppServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Serve the web dashboard (static SPA) in production.
|
||||||
|
this.setupWebDashboard();
|
||||||
|
|
||||||
// Re-register the error handler so it sits after any plugin routes.
|
// Re-register the error handler so it sits after any plugin routes.
|
||||||
this.app.use(errorHandler);
|
this.app.use(errorHandler);
|
||||||
|
|
||||||
@@ -290,6 +296,46 @@ export class AppServer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Private: Web Dashboard (SPA static files) ----------------------------
|
||||||
|
|
||||||
|
private setupWebDashboard(): void {
|
||||||
|
// Resolve the web dashboard dist directory relative to this file.
|
||||||
|
// In the built output: dist/server/app.js → dist/web/ is at ../web
|
||||||
|
const thisDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const webDir = path.resolve(thisDir, '..', 'web');
|
||||||
|
|
||||||
|
if (!fs.existsSync(webDir)) {
|
||||||
|
logger.debug({ webDir }, 'Web dashboard dist not found, skipping static mount');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ webDir }, 'Mounting web dashboard');
|
||||||
|
|
||||||
|
// Serve static assets
|
||||||
|
this.app.use(express.static(webDir, { index: false }));
|
||||||
|
|
||||||
|
// SPA fallback: any GET that doesn't match /api, /sse, /messages, /health
|
||||||
|
// returns index.html so client-side routing works.
|
||||||
|
this.app.get('*', (req, res, next) => {
|
||||||
|
// Skip API / MCP / health routes
|
||||||
|
if (
|
||||||
|
req.path.startsWith('/api') ||
|
||||||
|
req.path.startsWith('/sse') ||
|
||||||
|
req.path.startsWith('/messages') ||
|
||||||
|
req.path === '/health'
|
||||||
|
) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const indexPath = path.join(webDir, 'index.html');
|
||||||
|
if (fs.existsSync(indexPath)) {
|
||||||
|
res.sendFile(indexPath);
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async buildHealthResponse(): Promise<Record<string, unknown>> {
|
private async buildHealthResponse(): Promise<Record<string, unknown>> {
|
||||||
// Memory usage
|
// Memory usage
|
||||||
const mem = process.memoryUsage();
|
const mem = process.memoryUsage();
|
||||||
|
|||||||
@@ -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