feat: 添加 Admin Dashboard — React 19 SPA,包含 7 个页面
- Dashboard: 健康状态轮询、状态卡片、内存统计、快捷操作 - Login: 二维码展示 + 3 秒自动轮询 + 倒计时 + 登出 - Browser: 探索/搜索/用户三标签页,Feed 网格、详情面板、评论树 - Publish: 图文/视频发布表单,支持标签、可见性、定时发布 - Interactions: 点赞/取消点赞、收藏、评论、回复 + 操作日志 - API Tester: 端点选择器、请求体编辑器、cURL 生成、响应查看、历史记录 - Settings: Token 配置、服务器 URL 设置 后端改动: - app.ts: 生产环境提供 dist/web/ 静态文件服务 + SPA fallback - Dockerfile: 添加 web 构建阶段 - package.json: 添加 build:web、build:all、dev:web 脚本 技术栈: React 19 + TypeScript + Vite 6 + Tailwind CSS(暗色主题) 产物: 85.5 KB gzip JS + 4 KB gzip CSS,零重型依赖
This commit is contained in:
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user