Compare commits
5 Commits
@esengine/
...
feat/githu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
566e1977fd | ||
|
|
17f6259f43 | ||
|
|
5d3483fc65 | ||
|
|
d07a5d81fc | ||
|
|
6a4e6fbc04 |
Binary file not shown.
|
Before Width: | Height: | Size: 207 KiB |
@@ -18,30 +18,30 @@
|
||||
"dependencies": {
|
||||
"@esengine/asset-system": "workspace:*",
|
||||
"@esengine/asset-system-editor": "workspace:*",
|
||||
"@esengine/audio": "workspace:*",
|
||||
"@esengine/behavior-tree": "workspace:*",
|
||||
"@esengine/material-system": "workspace:*",
|
||||
"@esengine/material-editor": "workspace:*",
|
||||
"@esengine/behavior-tree-editor": "workspace:*",
|
||||
"@esengine/blueprint": "workspace:*",
|
||||
"@esengine/blueprint-editor": "workspace:*",
|
||||
"@esengine/editor-runtime": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/sprite": "workspace:*",
|
||||
"@esengine/sprite-editor": "workspace:*",
|
||||
"@esengine/shader-editor": "workspace:*",
|
||||
"@esengine/camera": "workspace:*",
|
||||
"@esengine/audio": "workspace:*",
|
||||
"@esengine/ecs-engine-bindgen": "workspace:*",
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/editor-core": "workspace:*",
|
||||
"@esengine/editor-runtime": "workspace:*",
|
||||
"@esengine/engine": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/material-editor": "workspace:*",
|
||||
"@esengine/material-system": "workspace:*",
|
||||
"@esengine/physics-rapier2d": "workspace:*",
|
||||
"@esengine/physics-rapier2d-editor": "workspace:*",
|
||||
"@esengine/runtime-core": "workspace:*",
|
||||
"@esengine/shader-editor": "workspace:*",
|
||||
"@esengine/sprite": "workspace:*",
|
||||
"@esengine/sprite-editor": "workspace:*",
|
||||
"@esengine/tilemap": "workspace:*",
|
||||
"@esengine/tilemap-editor": "workspace:*",
|
||||
"@esengine/ui": "workspace:*",
|
||||
"@esengine/ui-editor": "workspace:*",
|
||||
"@esengine/ecs-engine-bindgen": "workspace:*",
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/editor-core": "workspace:*",
|
||||
"@esengine/engine": "workspace:*",
|
||||
"@esengine/runtime-core": "workspace:*",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tauri-apps/api": "^2.2.0",
|
||||
"@tauri-apps/plugin-cli": "^2.4.1",
|
||||
|
||||
@@ -44,6 +44,7 @@ import { ErrorDialog } from './components/ErrorDialog';
|
||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||
import { PluginGeneratorWindow } from './components/PluginGeneratorWindow';
|
||||
import { BuildSettingsWindow } from './components/BuildSettingsWindow';
|
||||
import { ForumPanel } from './components/forum';
|
||||
import { ToastProvider, useToast } from './components/Toast';
|
||||
import { TitleBar } from './components/TitleBar';
|
||||
import { MainToolbar } from './components/MainToolbar';
|
||||
@@ -733,6 +734,12 @@ function App() {
|
||||
title: locale === 'zh' ? '检视器' : 'Inspector',
|
||||
content: <Inspector entityStore={entityStore} messageHub={messageHub} inspectorRegistry={inspectorRegistry!} projectPath={currentProjectPath} commandManager={commandManager} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'forum',
|
||||
title: locale === 'zh' ? '社区论坛' : 'Forum',
|
||||
content: <ForumPanel />,
|
||||
closable: true
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
255
packages/editor-app/src/components/forum/ForumAuth.css
Normal file
255
packages/editor-app/src/components/forum/ForumAuth.css
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* 论坛认证样式 - GitHub Device Flow
|
||||
* Forum auth styles - GitHub Device Flow
|
||||
*/
|
||||
|
||||
.forum-auth {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.forum-auth-card {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 28px;
|
||||
background: #333;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #444;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.forum-auth-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.forum-auth-icon {
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.forum-auth-header h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.forum-auth-header p {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 初始状态 | Idle state */
|
||||
.forum-auth-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.forum-auth-instructions {
|
||||
background: #2a2a2a;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #4a9eff;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.forum-auth-instructions p {
|
||||
margin: 6px 0;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.forum-auth-instructions p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.forum-auth-instructions p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.forum-auth-github-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
background: #24292e;
|
||||
border: 1px solid #444;
|
||||
color: #e0e0e0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-auth-github-btn:hover {
|
||||
background: #2f363d;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
/* 等待授权 | Pending state */
|
||||
.forum-auth-pending {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.forum-auth-pending-text {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.forum-auth-code-section {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.forum-auth-code-section label {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.forum-auth-code-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #2a2a2a;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
.forum-auth-code {
|
||||
flex: 1;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #4a9eff;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.forum-auth-copy-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 4px 8px;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.forum-auth-copy-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.forum-auth-copy-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.forum-auth-link-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #4a9eff;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-auth-link-btn:hover {
|
||||
color: #3a8eef;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 授权成功 | Success state */
|
||||
.forum-auth-success {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.forum-auth-success-icon {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.forum-auth-success p {
|
||||
font-size: 14px;
|
||||
color: #4ade80;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 授权失败 | Error state */
|
||||
.forum-auth-error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.forum-auth-error-icon {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.forum-auth-error-state > p {
|
||||
font-size: 14px;
|
||||
color: #f87171;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.forum-auth-error-detail {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
background: #2a2a2a;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
max-width: 100%;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.forum-auth-retry-btn {
|
||||
padding: 8px 20px;
|
||||
font-size: 12px;
|
||||
background: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
color: #ccc;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.forum-auth-retry-btn:hover {
|
||||
background: #444;
|
||||
border-color: #555;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 加载动画 | Loading animation */
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
161
packages/editor-app/src/components/forum/ForumAuth.tsx
Normal file
161
packages/editor-app/src/components/forum/ForumAuth.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 论坛登录组件 - 使用 GitHub Device Flow
|
||||
* Forum auth component - using GitHub Device Flow
|
||||
*/
|
||||
import { AlertCircle, CheckCircle, ExternalLink, Github, Loader } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { useForumAuth } from '../../hooks/useForum';
|
||||
import './ForumAuth.css';
|
||||
|
||||
type AuthStatus = 'idle' | 'pending' | 'authorized' | 'error';
|
||||
|
||||
export function ForumAuth() {
|
||||
const { i18n } = useTranslation();
|
||||
const { requestDeviceCode, authenticateWithDeviceFlow, signInWithGitHubToken } = useForumAuth();
|
||||
|
||||
const [authStatus, setAuthStatus] = useState<AuthStatus>('idle');
|
||||
const [userCode, setUserCode] = useState('');
|
||||
const [verificationUri, setVerificationUri] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isEnglish = i18n.language === 'en';
|
||||
|
||||
const handleGitHubLogin = async () => {
|
||||
setAuthStatus('pending');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 请求 Device Code | Request Device Code
|
||||
const deviceCodeResp = await requestDeviceCode();
|
||||
|
||||
setUserCode(deviceCodeResp.user_code);
|
||||
setVerificationUri(deviceCodeResp.verification_uri);
|
||||
|
||||
// 打开浏览器 | Open browser
|
||||
await open(deviceCodeResp.verification_uri);
|
||||
|
||||
// 轮询等待授权 | Poll for authorization
|
||||
const accessToken = await authenticateWithDeviceFlow(
|
||||
deviceCodeResp.device_code,
|
||||
deviceCodeResp.interval,
|
||||
(status) => {
|
||||
if (status === 'authorized') {
|
||||
setAuthStatus('authorized');
|
||||
} else if (status === 'error') {
|
||||
setAuthStatus('error');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 使用 token 登录 Supabase | Sign in to Supabase with token
|
||||
const { error: signInError } = await signInWithGitHubToken(accessToken);
|
||||
|
||||
if (signInError) {
|
||||
throw signInError;
|
||||
}
|
||||
|
||||
setAuthStatus('authorized');
|
||||
} catch (err) {
|
||||
console.error('[ForumAuth] GitHub login failed:', err);
|
||||
setAuthStatus('error');
|
||||
setError(err instanceof Error ? err.message : (isEnglish ? 'Authorization failed' : '授权失败'));
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setAuthStatus('idle');
|
||||
setUserCode('');
|
||||
setVerificationUri('');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="forum-auth">
|
||||
<div className="forum-auth-card">
|
||||
<div className="forum-auth-header">
|
||||
<Github size={32} className="forum-auth-icon" />
|
||||
<h2>{isEnglish ? 'ESEngine Community' : 'ESEngine 社区'}</h2>
|
||||
<p>{isEnglish ? 'Sign in with GitHub to join the discussion' : '使用 GitHub 登录参与讨论'}</p>
|
||||
</div>
|
||||
|
||||
{/* 初始状态 | Idle state */}
|
||||
{authStatus === 'idle' && (
|
||||
<div className="forum-auth-content">
|
||||
<div className="forum-auth-instructions">
|
||||
<p>{isEnglish ? '1. Click the button below' : '1. 点击下方按钮'}</p>
|
||||
<p>{isEnglish ? '2. Enter the code on GitHub' : '2. 在 GitHub 页面输入验证码'}</p>
|
||||
<p>{isEnglish ? '3. Authorize the application' : '3. 授权应用'}</p>
|
||||
</div>
|
||||
<button className="forum-auth-github-btn" onClick={handleGitHubLogin}>
|
||||
<Github size={16} />
|
||||
<span>{isEnglish ? 'Continue with GitHub' : '使用 GitHub 登录'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 等待授权 | Pending state */}
|
||||
{authStatus === 'pending' && (
|
||||
<div className="forum-auth-pending">
|
||||
<Loader size={24} className="spinning" />
|
||||
<p className="forum-auth-pending-text">
|
||||
{isEnglish ? 'Waiting for authorization...' : '等待授权中...'}
|
||||
</p>
|
||||
|
||||
{userCode && (
|
||||
<div className="forum-auth-code-section">
|
||||
<label>{isEnglish ? 'Enter this code on GitHub:' : '在 GitHub 输入此验证码:'}</label>
|
||||
<div className="forum-auth-code-box">
|
||||
<span className="forum-auth-code">{userCode}</span>
|
||||
<button
|
||||
className="forum-auth-copy-btn"
|
||||
onClick={() => copyToClipboard(userCode)}
|
||||
title={isEnglish ? 'Copy code' : '复制验证码'}
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="forum-auth-link-btn"
|
||||
onClick={() => open(verificationUri)}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
<span>{isEnglish ? 'Open GitHub' : '打开 GitHub'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 授权成功 | Success state */}
|
||||
{authStatus === 'authorized' && (
|
||||
<div className="forum-auth-success">
|
||||
<CheckCircle size={32} className="forum-auth-success-icon" />
|
||||
<p>{isEnglish ? 'Authorization successful!' : '授权成功!'}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 授权失败 | Error state */}
|
||||
{authStatus === 'error' && (
|
||||
<div className="forum-auth-error-state">
|
||||
<AlertCircle size={32} className="forum-auth-error-icon" />
|
||||
<p>{isEnglish ? 'Authorization failed' : '授权失败'}</p>
|
||||
{error && <p className="forum-auth-error-detail">{error}</p>}
|
||||
<button className="forum-auth-retry-btn" onClick={handleRetry}>
|
||||
{isEnglish ? 'Try Again' : '重试'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
603
packages/editor-app/src/components/forum/ForumCreatePost.css
Normal file
603
packages/editor-app/src/components/forum/ForumCreatePost.css
Normal file
@@ -0,0 +1,603 @@
|
||||
/**
|
||||
* 论坛创建帖子样式
|
||||
* Forum create post styles
|
||||
*/
|
||||
|
||||
.forum-create-post {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* 容器布局 | Container layout */
|
||||
.forum-create-container {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 主编辑区 | Main editor area */
|
||||
.forum-create-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background: #2d2d2d;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #3a3a3a;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.forum-create-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.forum-create-header h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.forum-create-selected-category {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
/* 表单 | Form */
|
||||
.forum-create-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.forum-create-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.forum-create-field label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #999;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* 分类选择 | Category selection */
|
||||
.forum-create-categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.forum-create-category {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 14px;
|
||||
min-width: 80px;
|
||||
background: #363636;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-create-category:hover {
|
||||
background: #404040;
|
||||
border-color: #555;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.forum-create-category.selected {
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-create-category-emoji {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.forum-create-category-name {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: #ccc;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.forum-create-category.selected .forum-create-category-name {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-create-category-desc {
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 标题输入 | Title input */
|
||||
.forum-create-title-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
padding-right: 10px;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-create-title-input:focus-within {
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-create-title-input input {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #e0e0e0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.forum-create-title-input input::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.forum-create-count {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 编辑器字段 | Editor field */
|
||||
.forum-create-editor-field {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
/* 编辑器头部 | Editor header */
|
||||
.forum-editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px;
|
||||
background: #363636;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
/* 编辑器选项卡 | Editor tabs */
|
||||
.forum-editor-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.forum-editor-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-editor-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.forum-editor-tab.active {
|
||||
background: #4a9eff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 编辑器工具栏 | Editor toolbar */
|
||||
.forum-editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.forum-editor-tool {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-editor-tool:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.forum-editor-help {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: #666;
|
||||
border-radius: 3px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-editor-help:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
/* 编辑器内容区 | Editor content */
|
||||
.forum-editor-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.forum-editor-textarea {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
|
||||
line-height: 1.6;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #e0e0e0;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.forum-editor-textarea::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* 编辑器内容拖拽状态 | Editor content drag state */
|
||||
.forum-editor-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.forum-editor-content.dragging {
|
||||
border-color: #4a9eff;
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
}
|
||||
|
||||
/* 上传覆盖层 | Upload overlay */
|
||||
.forum-editor-upload-overlay,
|
||||
.forum-editor-drag-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
background: rgba(26, 26, 26, 0.95);
|
||||
z-index: 10;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
|
||||
.forum-editor-upload-overlay span,
|
||||
.forum-editor-drag-overlay span {
|
||||
font-size: 13px;
|
||||
color: #4a9eff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.forum-editor-upload-overlay svg,
|
||||
.forum-editor-drag-overlay svg {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-editor-drag-overlay {
|
||||
border: 2px dashed #4a9eff;
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 旋转动画 | Spin animation */
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* 预览区 | Preview area */
|
||||
.forum-editor-preview {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
color: #ddd;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.forum-editor-preview-empty {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Markdown 渲染样式 | Markdown render styles */
|
||||
.forum-editor-preview h1,
|
||||
.forum-editor-preview h2,
|
||||
.forum-editor-preview h3,
|
||||
.forum-editor-preview h4 {
|
||||
color: #e0e0e0;
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
.forum-editor-preview h1 { font-size: 20px; }
|
||||
.forum-editor-preview h2 { font-size: 17px; }
|
||||
.forum-editor-preview h3 { font-size: 14px; }
|
||||
|
||||
.forum-editor-preview p {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.forum-editor-preview a {
|
||||
color: #4a9eff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.forum-editor-preview a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.forum-editor-preview code {
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
|
||||
background: #2a2a2a;
|
||||
border-radius: 3px;
|
||||
color: #f8d97c;
|
||||
}
|
||||
|
||||
.forum-editor-preview pre {
|
||||
margin: 12px 0;
|
||||
padding: 12px;
|
||||
background: #1e1e1e;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.forum-editor-preview pre code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.forum-editor-preview blockquote {
|
||||
margin: 12px 0;
|
||||
padding: 8px 12px;
|
||||
border-left: 3px solid #4a9eff;
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.forum-editor-preview ul,
|
||||
.forum-editor-preview ol {
|
||||
margin: 8px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.forum-editor-preview li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.forum-editor-preview img {
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.forum-editor-preview hr {
|
||||
border: none;
|
||||
border-top: 1px solid #3a3a3a;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.forum-editor-preview table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.forum-editor-preview th,
|
||||
.forum-editor-preview td {
|
||||
padding: 8px;
|
||||
border: 1px solid #3a3a3a;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.forum-editor-preview th {
|
||||
background: #2a2a2a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 错误提示 | Error message */
|
||||
.forum-create-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 11px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 4px;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* 操作按钮 | Action buttons */
|
||||
.forum-create-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.forum-btn-submit {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
/* 侧边栏 | Sidebar */
|
||||
.forum-create-sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.forum-create-tips,
|
||||
.forum-create-markdown-guide {
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.forum-create-tips h3,
|
||||
.forum-create-markdown-guide h3 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #999;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.forum-create-tips ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.forum-create-tips li {
|
||||
position: relative;
|
||||
padding: 4px 0 4px 14px;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.forum-create-tips li::before {
|
||||
content: '•';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
/* Markdown 指南 | Markdown guide */
|
||||
.forum-create-markdown-examples {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.markdown-example {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.markdown-example code {
|
||||
padding: 2px 6px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 3px;
|
||||
color: #f8d97c;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.markdown-example code.inline {
|
||||
background: #2a2a2a;
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.markdown-example span {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.markdown-example strong {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.markdown-example em {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.markdown-example a {
|
||||
color: #4a9eff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 响应式 | Responsive */
|
||||
@media (max-width: 800px) {
|
||||
.forum-create-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.forum-create-sidebar {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.forum-create-tips,
|
||||
.forum-create-markdown-guide {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
459
packages/editor-app/src/components/forum/ForumCreatePost.tsx
Normal file
459
packages/editor-app/src/components/forum/ForumCreatePost.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* 论坛创建帖子组件 - GitHub Discussions
|
||||
* Forum create post component - GitHub Discussions
|
||||
*/
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import {
|
||||
ArrowLeft, Send, AlertCircle, Eye, Edit3,
|
||||
Bold, Italic, Code, Link, List, Image, Quote, HelpCircle,
|
||||
Upload, Loader2
|
||||
} from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { getForumService } from '../../services/forum';
|
||||
import type { Category } from '../../services/forum';
|
||||
import { parseEmoji } from './utils';
|
||||
import './ForumCreatePost.css';
|
||||
|
||||
interface ForumCreatePostProps {
|
||||
categories: Category[];
|
||||
isEnglish: boolean;
|
||||
onBack: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
type EditorTab = 'write' | 'preview';
|
||||
|
||||
export function ForumCreatePost({ categories, isEnglish, onBack, onCreated }: ForumCreatePostProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [categoryId, setCategoryId] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<EditorTab>('write');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const forumService = getForumService();
|
||||
|
||||
/**
|
||||
* 处理图片上传
|
||||
* Handle image upload
|
||||
*/
|
||||
const handleImageUpload = useCallback(async (file: File) => {
|
||||
if (uploading) return;
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const imageUrl = await forumService.uploadImage(file, (progress) => {
|
||||
setUploadProgress(progress);
|
||||
});
|
||||
|
||||
// 插入 Markdown 图片语法 | Insert Markdown image syntax
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const imageMarkdown = ``;
|
||||
const newBody = body.substring(0, start) + imageMarkdown + body.substring(end);
|
||||
setBody(newBody);
|
||||
|
||||
// 恢复光标位置 | Restore cursor position
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
const newPos = start + imageMarkdown.length;
|
||||
textarea.setSelectionRange(newPos, newPos);
|
||||
}, 0);
|
||||
} else {
|
||||
// 如果没有 textarea,直接追加到末尾 | Append to end if no textarea
|
||||
setBody(prev => prev + `\n`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ForumCreatePost] Upload failed:', err);
|
||||
setError(err instanceof Error ? err.message : (isEnglish ? 'Failed to upload image' : '图片上传失败'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
}, [body, forumService, isEnglish, uploading]);
|
||||
|
||||
/**
|
||||
* 处理拖拽事件
|
||||
* Handle drag events
|
||||
*/
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const imageFile = files.find(f => f.type.startsWith('image/'));
|
||||
if (imageFile) {
|
||||
handleImageUpload(imageFile);
|
||||
}
|
||||
}, [handleImageUpload]);
|
||||
|
||||
/**
|
||||
* 处理粘贴事件
|
||||
* Handle paste event
|
||||
*/
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
const items = Array.from(e.clipboardData.items);
|
||||
const imageItem = items.find(item => item.type.startsWith('image/'));
|
||||
|
||||
if (imageItem) {
|
||||
e.preventDefault();
|
||||
const file = imageItem.getAsFile();
|
||||
if (file) {
|
||||
handleImageUpload(file);
|
||||
}
|
||||
}
|
||||
}, [handleImageUpload]);
|
||||
|
||||
/**
|
||||
* 处理文件选择
|
||||
* Handle file selection
|
||||
*/
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
handleImageUpload(file);
|
||||
}
|
||||
// 清空 input 以便重复选择同一文件 | Clear input to allow selecting same file again
|
||||
e.target.value = '';
|
||||
}, [handleImageUpload]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
// 验证 | Validation
|
||||
if (!title.trim()) {
|
||||
setError(isEnglish ? 'Please enter a title' : '请输入标题');
|
||||
return;
|
||||
}
|
||||
if (!body.trim()) {
|
||||
setError(isEnglish ? 'Please enter content' : '请输入内容');
|
||||
return;
|
||||
}
|
||||
if (!categoryId) {
|
||||
setError(isEnglish ? 'Please select a category' : '请选择分类');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const post = await forumService.createPost({
|
||||
title: title.trim(),
|
||||
body: body.trim(),
|
||||
categoryId
|
||||
});
|
||||
|
||||
if (post) {
|
||||
onCreated();
|
||||
} else {
|
||||
setError(isEnglish ? 'Failed to create discussion' : '创建讨论失败,请稍后重试');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ForumCreatePost] Error:', err);
|
||||
setError(err instanceof Error ? err.message : (isEnglish ? 'An error occurred' : '发生错误,请稍后重试'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 插入 Markdown 语法 | Insert Markdown syntax
|
||||
const insertMarkdown = (prefix: string, suffix: string = '', placeholder: string = '') => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selectedText = body.substring(start, end) || placeholder;
|
||||
|
||||
const newBody = body.substring(0, start) + prefix + selectedText + suffix + body.substring(end);
|
||||
setBody(newBody);
|
||||
|
||||
// 恢复光标位置 | Restore cursor position
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
const newCursorPos = start + prefix.length + selectedText.length;
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const toolbarButtons = [
|
||||
{ icon: <Bold size={14} />, action: () => insertMarkdown('**', '**', 'bold'), title: isEnglish ? 'Bold' : '粗体' },
|
||||
{ icon: <Italic size={14} />, action: () => insertMarkdown('*', '*', 'italic'), title: isEnglish ? 'Italic' : '斜体' },
|
||||
{ icon: <Code size={14} />, action: () => insertMarkdown('`', '`', 'code'), title: isEnglish ? 'Inline code' : '行内代码' },
|
||||
{ icon: <Link size={14} />, action: () => insertMarkdown('[', '](url)', 'link text'), title: isEnglish ? 'Link' : '链接' },
|
||||
{ icon: <List size={14} />, action: () => insertMarkdown('\n- ', '', 'list item'), title: isEnglish ? 'List' : '列表' },
|
||||
{ icon: <Quote size={14} />, action: () => insertMarkdown('\n> ', '', 'quote'), title: isEnglish ? 'Quote' : '引用' },
|
||||
{ icon: <Upload size={14} />, action: () => fileInputRef.current?.click(), title: isEnglish ? 'Upload image' : '上传图片' },
|
||||
];
|
||||
|
||||
const selectedCategory = categories.find(c => c.id === categoryId);
|
||||
|
||||
return (
|
||||
<div className="forum-create-post">
|
||||
{/* 返回按钮 | Back button */}
|
||||
<button className="forum-back-btn" onClick={onBack}>
|
||||
<ArrowLeft size={18} />
|
||||
<span>{isEnglish ? 'Back to list' : '返回列表'}</span>
|
||||
</button>
|
||||
|
||||
<div className="forum-create-container">
|
||||
{/* 左侧:编辑区 | Left: Editor */}
|
||||
<div className="forum-create-main">
|
||||
<div className="forum-create-header">
|
||||
<h2>{isEnglish ? 'Start a Discussion' : '发起讨论'}</h2>
|
||||
{selectedCategory && (
|
||||
<span className="forum-create-selected-category">
|
||||
{parseEmoji(selectedCategory.emoji)} {selectedCategory.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form className="forum-create-form" onSubmit={handleSubmit}>
|
||||
{/* 分类选择 | Category selection */}
|
||||
<div className="forum-create-field">
|
||||
<label>{isEnglish ? 'Select Category' : '选择分类'}</label>
|
||||
<div className="forum-create-categories">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
className={`forum-create-category ${categoryId === cat.id ? 'selected' : ''}`}
|
||||
onClick={() => setCategoryId(cat.id)}
|
||||
>
|
||||
<span className="forum-create-category-emoji">{parseEmoji(cat.emoji)}</span>
|
||||
<span className="forum-create-category-name">{cat.name}</span>
|
||||
{cat.description && (
|
||||
<span className="forum-create-category-desc">{cat.description}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标题 | Title */}
|
||||
<div className="forum-create-field">
|
||||
<label>{isEnglish ? 'Title' : '标题'}</label>
|
||||
<div className="forum-create-title-input">
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={isEnglish ? 'Enter a descriptive title...' : '输入一个描述性的标题...'}
|
||||
maxLength={200}
|
||||
/>
|
||||
<span className="forum-create-count">{title.length}/200</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 编辑器 | Editor */}
|
||||
<div className="forum-create-field forum-create-editor-field">
|
||||
<div className="forum-editor-header">
|
||||
<div className="forum-editor-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`forum-editor-tab ${activeTab === 'write' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('write')}
|
||||
>
|
||||
<Edit3 size={14} />
|
||||
<span>{isEnglish ? 'Write' : '编辑'}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`forum-editor-tab ${activeTab === 'preview' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('preview')}
|
||||
>
|
||||
<Eye size={14} />
|
||||
<span>{isEnglish ? 'Preview' : '预览'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'write' && (
|
||||
<div className="forum-editor-toolbar">
|
||||
{toolbarButtons.map((btn, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
className="forum-editor-tool"
|
||||
onClick={btn.action}
|
||||
title={btn.title}
|
||||
>
|
||||
{btn.icon}
|
||||
</button>
|
||||
))}
|
||||
<a
|
||||
href="https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="forum-editor-help"
|
||||
title={isEnglish ? 'Markdown Help' : 'Markdown 帮助'}
|
||||
>
|
||||
<HelpCircle size={14} />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`forum-editor-content ${isDragging ? 'dragging' : ''}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* 隐藏的文件输入 | Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
|
||||
{/* 上传进度提示 | Upload progress indicator */}
|
||||
{uploading && (
|
||||
<div className="forum-editor-upload-overlay">
|
||||
<Loader2 size={24} className="spin" />
|
||||
<span>{isEnglish ? 'Uploading...' : '上传中...'} {uploadProgress}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 拖拽提示 | Drag hint */}
|
||||
{isDragging && !uploading && (
|
||||
<div className="forum-editor-drag-overlay">
|
||||
<Upload size={32} />
|
||||
<span>{isEnglish ? 'Drop image here' : '拖放图片到这里'}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'write' ? (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="forum-editor-textarea"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onPaste={handlePaste}
|
||||
placeholder={isEnglish
|
||||
? 'Write your content here...\n\nYou can use Markdown:\n- **bold** and *italic*\n- `code` and ```code blocks```\n- [links](url) and \n- > quotes and - lists\n\nDrag & drop or paste images to upload'
|
||||
: '在这里写下你的内容...\n\n支持 Markdown 语法:\n- **粗体** 和 *斜体*\n- `代码` 和 ```代码块```\n- [链接](url) 和 \n- > 引用 和 - 列表\n\n拖拽或粘贴图片即可上传'}
|
||||
/>
|
||||
) : (
|
||||
<div className="forum-editor-preview">
|
||||
{body ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{body}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<p className="forum-editor-preview-empty">
|
||||
{isEnglish ? 'Nothing to preview' : '暂无内容可预览'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 | Error message */}
|
||||
{error && (
|
||||
<div className="forum-create-error">
|
||||
<AlertCircle size={16} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提交按钮 | Submit button */}
|
||||
<div className="forum-create-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="forum-btn"
|
||||
onClick={onBack}
|
||||
disabled={submitting}
|
||||
>
|
||||
{isEnglish ? 'Cancel' : '取消'}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="forum-btn forum-btn-primary forum-btn-submit"
|
||||
disabled={submitting || !title.trim() || !body.trim() || !categoryId}
|
||||
>
|
||||
<Send size={16} />
|
||||
<span>
|
||||
{submitting
|
||||
? (isEnglish ? 'Creating...' : '创建中...')
|
||||
: (isEnglish ? 'Create Discussion' : '创建讨论')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* 右侧:提示 | Right: Tips */}
|
||||
<div className="forum-create-sidebar">
|
||||
<div className="forum-create-tips">
|
||||
<h3>{isEnglish ? 'Tips' : '小贴士'}</h3>
|
||||
<ul>
|
||||
<li>{isEnglish ? 'Use a clear, descriptive title' : '使用清晰、描述性的标题'}</li>
|
||||
<li>{isEnglish ? 'Select the right category for your topic' : '为你的话题选择合适的分类'}</li>
|
||||
<li>{isEnglish ? 'Provide enough context and details' : '提供足够的背景和细节'}</li>
|
||||
<li>{isEnglish ? 'Use code blocks for code snippets' : '使用代码块展示代码'}</li>
|
||||
<li>{isEnglish ? 'Be respectful and constructive' : '保持尊重和建设性'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="forum-create-markdown-guide">
|
||||
<h3>{isEnglish ? 'Markdown Guide' : 'Markdown 指南'}</h3>
|
||||
<div className="forum-create-markdown-examples">
|
||||
<div className="markdown-example">
|
||||
<code>**bold**</code>
|
||||
<span>→</span>
|
||||
<strong>bold</strong>
|
||||
</div>
|
||||
<div className="markdown-example">
|
||||
<code>*italic*</code>
|
||||
<span>→</span>
|
||||
<em>italic</em>
|
||||
</div>
|
||||
<div className="markdown-example">
|
||||
<code>`code`</code>
|
||||
<span>→</span>
|
||||
<code className="inline">code</code>
|
||||
</div>
|
||||
<div className="markdown-example">
|
||||
<code>[link](url)</code>
|
||||
<span>→</span>
|
||||
<a href="#">link</a>
|
||||
</div>
|
||||
<div className="markdown-example">
|
||||
<code>- item</code>
|
||||
<span>→</span>
|
||||
<span>• item</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
170
packages/editor-app/src/components/forum/ForumPanel.css
Normal file
170
packages/editor-app/src/components/forum/ForumPanel.css
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 论坛面板样式
|
||||
* Forum panel styles
|
||||
*/
|
||||
|
||||
.forum-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #2a2a2a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.forum-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.forum-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.forum-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.forum-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.forum-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-user:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.forum-user-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.forum-user-avatar-placeholder {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #4a9eff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.forum-user-name {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 个人资料下拉面板 | Profile dropdown panel */
|
||||
.forum-profile-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 8px;
|
||||
z-index: 1000;
|
||||
min-width: 280px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.forum-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.forum-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* 通用按钮样式 | Common button styles */
|
||||
.forum-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
background: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
color: #ccc;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-btn:hover:not(:disabled) {
|
||||
background: #444;
|
||||
border-color: #555;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.forum-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.forum-btn-primary {
|
||||
background: #4a9eff;
|
||||
border-color: #4a9eff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.forum-btn-primary:hover:not(:disabled) {
|
||||
background: #3a8eef;
|
||||
border-color: #3a8eef;
|
||||
}
|
||||
|
||||
/* 返回按钮 | Back button */
|
||||
.forum-back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
margin: 10px 12px;
|
||||
font-size: 11px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-back-btn:hover {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
/* 旋转动画 | Spin animation */
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
184
packages/editor-app/src/components/forum/ForumPanel.tsx
Normal file
184
packages/editor-app/src/components/forum/ForumPanel.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 论坛面板主组件 - GitHub Discussions
|
||||
* Forum panel main component - GitHub Discussions
|
||||
*/
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MessageSquare, RefreshCw } from 'lucide-react';
|
||||
import { useForumAuth, useCategories, usePosts } from '../../hooks/useForum';
|
||||
import { ForumAuth } from './ForumAuth';
|
||||
import { ForumPostList } from './ForumPostList';
|
||||
import { ForumPostDetail } from './ForumPostDetail';
|
||||
import { ForumCreatePost } from './ForumCreatePost';
|
||||
import { ForumProfile } from './ForumProfile';
|
||||
import type { PostListParams, ForumUser } from '../../services/forum';
|
||||
import './ForumPanel.css';
|
||||
|
||||
type ForumView = 'list' | 'detail' | 'create';
|
||||
|
||||
/**
|
||||
* 认证后的论坛内容组件 | Authenticated forum content component
|
||||
* 只有在用户认证后才会渲染,确保 hooks 能正常工作
|
||||
*/
|
||||
function ForumContent({ user, isEnglish }: { user: ForumUser; isEnglish: boolean }) {
|
||||
const { categories, refetch: refetchCategories } = useCategories();
|
||||
const [view, setView] = useState<ForumView>('list');
|
||||
const [selectedPostNumber, setSelectedPostNumber] = useState<number | null>(null);
|
||||
const [listParams, setListParams] = useState<PostListParams>({ first: 20 });
|
||||
const [showProfile, setShowProfile] = useState(false);
|
||||
const profileRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: posts, loading, totalCount, pageInfo, refetch, loadMore } = usePosts(listParams);
|
||||
|
||||
// 点击外部关闭个人资料面板 | Close profile panel when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (profileRef.current && !profileRef.current.contains(e.target as Node)) {
|
||||
setShowProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showProfile) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [showProfile]);
|
||||
|
||||
const handleViewPost = useCallback((postNumber: number) => {
|
||||
setSelectedPostNumber(postNumber);
|
||||
setView('detail');
|
||||
}, []);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setView('list');
|
||||
setSelectedPostNumber(null);
|
||||
}, []);
|
||||
|
||||
const handleCreatePost = useCallback(() => {
|
||||
setView('create');
|
||||
}, []);
|
||||
|
||||
const handlePostCreated = useCallback(() => {
|
||||
setView('list');
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
const handleCategoryChange = useCallback((categoryId: string | undefined) => {
|
||||
setListParams(prev => ({ ...prev, categoryId }));
|
||||
}, []);
|
||||
|
||||
const handleSearch = useCallback((search: string) => {
|
||||
setListParams(prev => ({ ...prev, search }));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 顶部栏 | Header */}
|
||||
<div className="forum-header">
|
||||
<div className="forum-header-left">
|
||||
<MessageSquare size={18} />
|
||||
<span className="forum-title">
|
||||
{isEnglish ? 'Community' : '社区'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="forum-header-right">
|
||||
<div
|
||||
className="forum-user"
|
||||
onClick={() => setShowProfile(!showProfile)}
|
||||
title={isEnglish ? 'Click to view profile' : '点击查看资料'}
|
||||
>
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.login}
|
||||
className="forum-user-avatar"
|
||||
/>
|
||||
<span className="forum-user-name">
|
||||
{user.login}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 个人资料下拉面板 | Profile dropdown panel */}
|
||||
{showProfile && (
|
||||
<div className="forum-profile-dropdown" ref={profileRef}>
|
||||
<ForumProfile onClose={() => setShowProfile(false)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区 | Content */}
|
||||
<div className="forum-content">
|
||||
{view === 'list' && (
|
||||
<ForumPostList
|
||||
posts={posts}
|
||||
categories={categories}
|
||||
loading={loading}
|
||||
totalCount={totalCount}
|
||||
hasNextPage={pageInfo.hasNextPage}
|
||||
params={listParams}
|
||||
isEnglish={isEnglish}
|
||||
onViewPost={handleViewPost}
|
||||
onCreatePost={handleCreatePost}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
onSearch={handleSearch}
|
||||
onRefresh={refetch}
|
||||
onLoadMore={loadMore}
|
||||
/>
|
||||
)}
|
||||
{view === 'detail' && selectedPostNumber && (
|
||||
<ForumPostDetail
|
||||
postNumber={selectedPostNumber}
|
||||
isEnglish={isEnglish}
|
||||
currentUserId={user.id}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
)}
|
||||
{view === 'create' && (
|
||||
<ForumCreatePost
|
||||
categories={categories}
|
||||
isEnglish={isEnglish}
|
||||
onBack={handleBack}
|
||||
onCreated={handlePostCreated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForumPanel() {
|
||||
const { i18n } = useTranslation();
|
||||
const { authState } = useForumAuth();
|
||||
|
||||
const isEnglish = i18n.language === 'en';
|
||||
|
||||
// 加载状态 | Loading state
|
||||
if (authState.status === 'loading') {
|
||||
return (
|
||||
<div className="forum-panel">
|
||||
<div className="forum-loading">
|
||||
<RefreshCw className="spin" size={24} />
|
||||
<span>{isEnglish ? 'Loading...' : '加载中...'}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 未登录状态 | Unauthenticated state
|
||||
if (authState.status === 'unauthenticated') {
|
||||
return (
|
||||
<div className="forum-panel">
|
||||
<ForumAuth />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 已登录状态 - 渲染内容组件 | Authenticated state - render content component
|
||||
return (
|
||||
<div className="forum-panel">
|
||||
<ForumContent user={authState.user} isEnglish={isEnglish} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
560
packages/editor-app/src/components/forum/ForumPostDetail.css
Normal file
560
packages/editor-app/src/components/forum/ForumPostDetail.css
Normal file
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* 论坛帖子详情样式
|
||||
* Forum post detail styles
|
||||
*/
|
||||
|
||||
.forum-post-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.forum-detail-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* 文章区 | Article section */
|
||||
.forum-detail-article {
|
||||
padding: 0 12px 20px;
|
||||
}
|
||||
|
||||
.forum-detail-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.forum-detail-category-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.forum-detail-category {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-detail-answered {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.forum-detail-external {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
background: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
color: #888;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.forum-detail-external:hover {
|
||||
background: #444;
|
||||
color: #e0e0e0;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.forum-detail-title {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.forum-detail-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.forum-detail-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.forum-detail-author img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.forum-detail-author-placeholder {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #4a9eff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.forum-detail-author span {
|
||||
font-size: 12px;
|
||||
color: #e0e0e0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.forum-detail-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 内容区 | Content section */
|
||||
.forum-detail-content {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.forum-detail-content p {
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.forum-detail-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Markdown 样式 | Markdown styles */
|
||||
.forum-detail-content h1,
|
||||
.forum-detail-content h2,
|
||||
.forum-detail-content h3,
|
||||
.forum-detail-content h4 {
|
||||
color: #e0e0e0;
|
||||
margin: 20px 0 10px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.forum-detail-content h1 { font-size: 20px; }
|
||||
.forum-detail-content h2 { font-size: 17px; }
|
||||
.forum-detail-content h3 { font-size: 15px; }
|
||||
.forum-detail-content h4 { font-size: 14px; }
|
||||
|
||||
.forum-detail-content a {
|
||||
color: #4a9eff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.forum-detail-content a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.forum-detail-content code {
|
||||
background: #1a1a1a;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
color: #e06c75;
|
||||
}
|
||||
|
||||
.forum-detail-content pre {
|
||||
background: #1a1a1a;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 12px 0;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.forum-detail-content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: #abb2bf;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.forum-detail-content blockquote {
|
||||
margin: 12px 0;
|
||||
padding: 10px 16px;
|
||||
border-left: 3px solid #4a9eff;
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.forum-detail-content ul,
|
||||
.forum-detail-content ol {
|
||||
margin: 12px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.forum-detail-content li {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.forum-detail-content img {
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.forum-detail-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid #3a3a3a;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.forum-detail-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.forum-detail-content th,
|
||||
.forum-detail-content td {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #3a3a3a;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.forum-detail-content th {
|
||||
background: #333;
|
||||
color: #e0e0e0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.forum-detail-content tr:nth-child(even) {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
/* 底部统计 | Footer stats */
|
||||
.forum-detail-footer {
|
||||
margin-top: 20px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.forum-detail-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.forum-detail-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.forum-detail-stat.interactive {
|
||||
padding: 5px 10px;
|
||||
margin: -5px -10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.forum-detail-stat.interactive:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.forum-detail-stat.interactive.liked {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.forum-detail-stat.interactive.liked svg {
|
||||
fill: #ef4444;
|
||||
}
|
||||
|
||||
/* 回复区 | Replies section */
|
||||
.forum-replies-section {
|
||||
padding: 0 12px 20px;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.forum-replies-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 16px 0 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* 回复表单 | Reply form */
|
||||
.forum-reply-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.forum-reply-form.nested {
|
||||
margin-top: 10px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.forum-reply-form textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 3px;
|
||||
color: #ddd;
|
||||
resize: vertical;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.forum-reply-form textarea:hover {
|
||||
border-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.forum-reply-form textarea:focus {
|
||||
outline: none;
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-reply-form textarea::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.forum-reply-form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 回复列表 | Reply list */
|
||||
.forum-replies-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.forum-replies-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.forum-replies-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.forum-replies-empty p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 单条回复 | Single reply */
|
||||
.forum-reply {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.forum-reply:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.forum-reply-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.forum-reply-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.forum-reply-author img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.forum-reply-author-placeholder {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #4a9eff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.forum-reply-author-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.forum-reply-answer-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #10b981;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.forum-reply-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.forum-reply-content {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
/* 回复内容 Markdown 样式 | Reply content Markdown styles */
|
||||
.forum-reply-content p {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.forum-reply-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.forum-reply-content a {
|
||||
color: #4a9eff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.forum-reply-content a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.forum-reply-content code {
|
||||
background: #1a1a1a;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 11px;
|
||||
color: #e06c75;
|
||||
}
|
||||
|
||||
.forum-reply-content pre {
|
||||
background: #1a1a1a;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.forum-reply-content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: #abb2bf;
|
||||
}
|
||||
|
||||
.forum-reply-content blockquote {
|
||||
margin: 8px 0;
|
||||
padding: 8px 12px;
|
||||
border-left: 2px solid #4a9eff;
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.forum-reply-content ul,
|
||||
.forum-reply-content ol {
|
||||
margin: 8px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.forum-reply-content li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.forum-reply-content img {
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.forum-reply-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.forum-reply-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 6px;
|
||||
font-size: 11px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-reply-action:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.forum-reply-action.liked {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.forum-reply-action.liked svg {
|
||||
fill: #ef4444;
|
||||
}
|
||||
|
||||
.forum-reply-action.delete:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
270
packages/editor-app/src/components/forum/ForumPostDetail.tsx
Normal file
270
packages/editor-app/src/components/forum/ForumPostDetail.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 论坛帖子详情组件 - GitHub Discussions
|
||||
* Forum post detail component - GitHub Discussions
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ArrowLeft, ThumbsUp, MessageCircle, Clock,
|
||||
Send, RefreshCw, CornerDownRight, ExternalLink, CheckCircle
|
||||
} from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePost, useReplies } from '../../hooks/useForum';
|
||||
import { getForumService } from '../../services/forum';
|
||||
import type { Reply } from '../../services/forum';
|
||||
import { parseEmoji } from './utils';
|
||||
import './ForumPostDetail.css';
|
||||
|
||||
interface ForumPostDetailProps {
|
||||
postNumber: number;
|
||||
isEnglish: boolean;
|
||||
currentUserId: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function ForumPostDetail({ postNumber, isEnglish, currentUserId, onBack }: ForumPostDetailProps) {
|
||||
const { post, loading: postLoading, toggleUpvote, refetch: refetchPost } = usePost(postNumber);
|
||||
const { replies, loading: repliesLoading, createReply, refetch: refetchReplies } = useReplies(postNumber);
|
||||
const [replyContent, setReplyContent] = useState('');
|
||||
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const forumService = getForumService();
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const handleSubmitReply = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!replyContent.trim() || submitting || !post) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createReply(post.id, replyContent, replyingTo || undefined);
|
||||
setReplyContent('');
|
||||
setReplyingTo(null);
|
||||
refetchPost();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleReplyUpvote = async (replyId: string, hasUpvoted: boolean) => {
|
||||
await forumService.toggleReplyUpvote(replyId, hasUpvoted);
|
||||
refetchReplies();
|
||||
};
|
||||
|
||||
const openInGitHub = async (url: string) => {
|
||||
await open(url);
|
||||
};
|
||||
|
||||
const renderReply = (reply: Reply, depth: number = 0) => {
|
||||
return (
|
||||
<div key={reply.id} className="forum-reply" style={{ marginLeft: depth * 24 }}>
|
||||
<div className="forum-reply-header">
|
||||
<div className="forum-reply-author">
|
||||
<img src={reply.author.avatarUrl} alt={reply.author.login} />
|
||||
<span className="forum-reply-author-name">
|
||||
@{reply.author.login}
|
||||
</span>
|
||||
{reply.isAnswer && (
|
||||
<span className="forum-reply-answer-badge">
|
||||
<CheckCircle size={12} />
|
||||
{isEnglish ? 'Answer' : '已采纳'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="forum-reply-time">
|
||||
<Clock size={12} />
|
||||
{formatDate(reply.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="forum-reply-content"
|
||||
dangerouslySetInnerHTML={{ __html: reply.bodyHTML }}
|
||||
/>
|
||||
|
||||
<div className="forum-reply-actions">
|
||||
<button
|
||||
className={`forum-reply-action ${reply.viewerHasUpvoted ? 'liked' : ''}`}
|
||||
onClick={() => handleToggleReplyUpvote(reply.id, reply.viewerHasUpvoted)}
|
||||
>
|
||||
<ThumbsUp size={14} />
|
||||
<span>{reply.upvoteCount}</span>
|
||||
</button>
|
||||
<button
|
||||
className="forum-reply-action"
|
||||
onClick={() => setReplyingTo(replyingTo === reply.id ? null : reply.id)}
|
||||
>
|
||||
<CornerDownRight size={14} />
|
||||
<span>{isEnglish ? 'Reply' : '回复'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{replyingTo === reply.id && post && (
|
||||
<form className="forum-reply-form nested" onSubmit={handleSubmitReply}>
|
||||
<textarea
|
||||
value={replyContent}
|
||||
onChange={(e) => setReplyContent(e.target.value)}
|
||||
placeholder={isEnglish
|
||||
? `Reply to @${reply.author.login}...`
|
||||
: `回复 @${reply.author.login}...`}
|
||||
rows={2}
|
||||
/>
|
||||
<div className="forum-reply-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="forum-btn"
|
||||
onClick={() => { setReplyingTo(null); setReplyContent(''); }}
|
||||
>
|
||||
{isEnglish ? 'Cancel' : '取消'}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="forum-btn forum-btn-primary"
|
||||
disabled={!replyContent.trim() || submitting}
|
||||
>
|
||||
<Send size={14} />
|
||||
<span>{isEnglish ? 'Reply' : '回复'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* 嵌套回复 | Nested replies */}
|
||||
{reply.replies?.nodes.map(child => renderReply(child, depth + 1))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (postLoading || !post) {
|
||||
return (
|
||||
<div className="forum-post-detail">
|
||||
<div className="forum-detail-loading">
|
||||
<RefreshCw className="spin" size={24} />
|
||||
<span>{isEnglish ? 'Loading...' : '加载中...'}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="forum-post-detail">
|
||||
{/* 返回按钮 | Back button */}
|
||||
<button className="forum-back-btn" onClick={onBack}>
|
||||
<ArrowLeft size={18} />
|
||||
<span>{isEnglish ? 'Back to list' : '返回列表'}</span>
|
||||
</button>
|
||||
|
||||
{/* 帖子内容 | Post content */}
|
||||
<article className="forum-detail-article">
|
||||
<header className="forum-detail-header">
|
||||
<div className="forum-detail-category-row">
|
||||
<span className="forum-detail-category">
|
||||
{parseEmoji(post.category.emoji)} {post.category.name}
|
||||
</span>
|
||||
{post.answerChosenAt && (
|
||||
<span className="forum-detail-answered">
|
||||
<CheckCircle size={14} />
|
||||
{isEnglish ? 'Answered' : '已解决'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="forum-detail-external"
|
||||
onClick={() => openInGitHub(post.url)}
|
||||
title={isEnglish ? 'Open in GitHub' : '在 GitHub 中打开'}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
<span>GitHub</span>
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="forum-detail-title">{post.title}</h1>
|
||||
|
||||
<div className="forum-detail-meta">
|
||||
<div className="forum-detail-author">
|
||||
<img src={post.author.avatarUrl} alt={post.author.login} />
|
||||
<span>@{post.author.login}</span>
|
||||
</div>
|
||||
<span className="forum-detail-time">
|
||||
<Clock size={14} />
|
||||
{formatDate(post.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="forum-detail-content"
|
||||
dangerouslySetInnerHTML={{ __html: post.bodyHTML }}
|
||||
/>
|
||||
|
||||
<footer className="forum-detail-footer">
|
||||
<div className="forum-detail-stats">
|
||||
<button
|
||||
className={`forum-detail-stat interactive ${post.viewerHasUpvoted ? 'liked' : ''}`}
|
||||
onClick={toggleUpvote}
|
||||
>
|
||||
<ThumbsUp size={16} />
|
||||
<span>{post.upvoteCount}</span>
|
||||
</button>
|
||||
<div className="forum-detail-stat">
|
||||
<MessageCircle size={16} />
|
||||
<span>{post.comments.totalCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
{/* 回复区 | Replies section */}
|
||||
<section className="forum-replies-section">
|
||||
<h2 className="forum-replies-title">
|
||||
<MessageCircle size={18} />
|
||||
<span>
|
||||
{isEnglish ? 'Comments' : '评论'}
|
||||
{post.comments.totalCount > 0 && ` (${post.comments.totalCount})`}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
{/* 回复输入框 | Reply input */}
|
||||
{replyingTo === null && (
|
||||
<form className="forum-reply-form" onSubmit={handleSubmitReply}>
|
||||
<textarea
|
||||
value={replyContent}
|
||||
onChange={(e) => setReplyContent(e.target.value)}
|
||||
placeholder={isEnglish ? 'Write a comment... (Markdown supported)' : '写下你的评论...(支持 Markdown)'}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="forum-reply-form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="forum-btn forum-btn-primary"
|
||||
disabled={!replyContent.trim() || submitting}
|
||||
>
|
||||
<Send size={14} />
|
||||
<span>{submitting
|
||||
? (isEnglish ? 'Posting...' : '发送中...')
|
||||
: (isEnglish ? 'Post Comment' : '发表评论')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* 回复列表 | Reply list */}
|
||||
<div className="forum-replies-list">
|
||||
{repliesLoading ? (
|
||||
<div className="forum-replies-loading">
|
||||
<RefreshCw className="spin" size={20} />
|
||||
</div>
|
||||
) : replies.length === 0 ? (
|
||||
<div className="forum-replies-empty">
|
||||
<p>{isEnglish ? 'No comments yet. Be the first to comment!' : '暂无评论,来发表第一条评论吧!'}</p>
|
||||
</div>
|
||||
) : (
|
||||
replies.map(reply => renderReply(reply))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
590
packages/editor-app/src/components/forum/ForumPostList.css
Normal file
590
packages/editor-app/src/components/forum/ForumPostList.css
Normal file
@@ -0,0 +1,590 @@
|
||||
/**
|
||||
* 论坛帖子列表样式
|
||||
* Forum post list styles
|
||||
*/
|
||||
|
||||
.forum-post-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 欢迎横幅 | Welcome banner */
|
||||
.forum-welcome-banner {
|
||||
background: linear-gradient(135deg, #1a365d 0%, #2d3748 100%);
|
||||
border-bottom: 1px solid #3a4a5a;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.forum-welcome-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.forum-welcome-text h2 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.forum-welcome-text p {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.forum-welcome-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.forum-btn-github {
|
||||
background: #24292e;
|
||||
border-color: #444;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.forum-btn-github:hover:not(:disabled) {
|
||||
background: #2f363d;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
/* 分类卡片 | Category cards */
|
||||
.forum-category-cards {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.forum-category-cards::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.forum-category-cards::-webkit-scrollbar-track {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.forum-category-cards::-webkit-scrollbar-thumb {
|
||||
background: #4a4a4a;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.forum-category-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 12px 16px;
|
||||
min-width: 80px;
|
||||
background: #363636;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-category-card:hover {
|
||||
background: #404040;
|
||||
border-color: #4a9eff;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.forum-category-card-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
border-radius: 50%;
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-category-card-emoji {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.forum-category-card-name {
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 工具栏 | Toolbar */
|
||||
.forum-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.forum-toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.forum-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 3px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.forum-search svg {
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.forum-search input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 11px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.forum-search input::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 过滤器 | Filters */
|
||||
.forum-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
.forum-filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.forum-filter-group svg {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.forum-filter-group select {
|
||||
padding: 3px 6px;
|
||||
font-size: 11px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 2px;
|
||||
color: #ddd;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.forum-filter-group select:focus {
|
||||
outline: none;
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-post-count {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 帖子统计栏 | Stats bar */
|
||||
.forum-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: #2a2a2a;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.forum-stats-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.forum-stats-left svg {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-stats-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 11px;
|
||||
color: #4a9eff;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-stats-clear:hover {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 下拉选择框 | Select dropdown */
|
||||
.forum-select {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
background: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 3px;
|
||||
color: #ddd;
|
||||
cursor: pointer;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.forum-select:focus {
|
||||
outline: none;
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
|
||||
/* 帖子列表 | Post list */
|
||||
.forum-posts {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.forum-posts.loading {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.forum-posts.loading > *:not(.forum-posts-overlay) {
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
/* 加载覆盖层 | Loading overlay */
|
||||
.forum-posts-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(42, 42, 42, 0.7);
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.forum-posts-overlay svg {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-posts-loading,
|
||||
.forum-posts-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
gap: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.forum-posts-empty svg {
|
||||
opacity: 0.3;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.forum-posts-empty p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 帖子项 | Post item */
|
||||
.forum-post-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 14px 12px;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.forum-post-item:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.forum-post-item.hot {
|
||||
background: linear-gradient(90deg, rgba(239, 68, 68, 0.05) 0%, transparent 50%);
|
||||
border-left: 2px solid #ef4444;
|
||||
}
|
||||
|
||||
.forum-post-item.hot:hover {
|
||||
background: linear-gradient(90deg, rgba(239, 68, 68, 0.08) 0%, rgba(255, 255, 255, 0.03) 50%);
|
||||
}
|
||||
|
||||
.forum-post-avatar {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.forum-post-avatar img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.forum-post-avatar-badge {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.forum-post-avatar-badge.hot {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.forum-post-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.forum-post-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.forum-post-badges {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.forum-post-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.forum-post-badge.new {
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-post-badge.hot {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.forum-post-badge.pinned {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.forum-post-badge.locked {
|
||||
background: rgba(107, 114, 128, 0.15);
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.forum-post-external {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.forum-post-external:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-post-category {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-post-title {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.forum-post-item:hover .forum-post-title {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-post-excerpt {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.forum-post-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.forum-post-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.forum-post-author-avatar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.forum-post-author-placeholder {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #4a9eff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.forum-post-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 帖子统计 | Post stats */
|
||||
.forum-post-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.forum-post-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.forum-post-stat.active {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.forum-post-stat.active svg {
|
||||
fill: #ef4444;
|
||||
}
|
||||
|
||||
.forum-post-answered {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
color: #10b981;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 加载更多 | Load more */
|
||||
.forum-load-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.forum-load-more .forum-btn {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* 分页 | Pagination */
|
||||
.forum-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
.forum-pagination-info {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
341
packages/editor-app/src/components/forum/ForumPostList.tsx
Normal file
341
packages/editor-app/src/components/forum/ForumPostList.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* 帖子列表组件 - GitHub Discussions
|
||||
* Post list component - GitHub Discussions
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Plus, RefreshCw, Search, MessageCircle, ThumbsUp,
|
||||
ExternalLink, CheckCircle, Flame, Clock, TrendingUp,
|
||||
Lightbulb, HelpCircle, Megaphone, BarChart3, Github
|
||||
} from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import type { Post, Category, PostListParams } from '../../services/forum';
|
||||
import { parseEmoji } from './utils';
|
||||
import './ForumPostList.css';
|
||||
|
||||
interface ForumPostListProps {
|
||||
posts: Post[];
|
||||
categories: Category[];
|
||||
loading: boolean;
|
||||
totalCount: number;
|
||||
hasNextPage: boolean;
|
||||
params: PostListParams;
|
||||
isEnglish: boolean;
|
||||
onViewPost: (postNumber: number) => void;
|
||||
onCreatePost: () => void;
|
||||
onCategoryChange: (categoryId: string | undefined) => void;
|
||||
onSearch: (search: string) => void;
|
||||
onRefresh: () => void;
|
||||
onLoadMore: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分类图标 | Get category icon
|
||||
*/
|
||||
function getCategoryIcon(name: string) {
|
||||
const lowerName = name.toLowerCase();
|
||||
if (lowerName.includes('idea') || lowerName.includes('建议')) return <Lightbulb size={14} />;
|
||||
if (lowerName.includes('q&a') || lowerName.includes('问答')) return <HelpCircle size={14} />;
|
||||
if (lowerName.includes('show') || lowerName.includes('展示')) return <Megaphone size={14} />;
|
||||
if (lowerName.includes('poll') || lowerName.includes('投票')) return <BarChart3 size={14} />;
|
||||
return <MessageCircle size={14} />;
|
||||
}
|
||||
|
||||
export function ForumPostList({
|
||||
posts,
|
||||
categories,
|
||||
loading,
|
||||
totalCount,
|
||||
hasNextPage,
|
||||
params,
|
||||
isEnglish,
|
||||
onViewPost,
|
||||
onCreatePost,
|
||||
onCategoryChange,
|
||||
onSearch,
|
||||
onRefresh,
|
||||
onLoadMore
|
||||
}: ForumPostListProps) {
|
||||
const [searchInput, setSearchInput] = useState(params.search || '');
|
||||
|
||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSearch(searchInput);
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) {
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
if (hours === 0) {
|
||||
const mins = Math.floor(diff / (1000 * 60));
|
||||
if (mins < 1) return isEnglish ? 'Just now' : '刚刚';
|
||||
return isEnglish ? `${mins}m ago` : `${mins}分钟前`;
|
||||
}
|
||||
return isEnglish ? `${hours}h ago` : `${hours}小时前`;
|
||||
}
|
||||
if (days === 1) return isEnglish ? 'Yesterday' : '昨天';
|
||||
if (days < 7) return isEnglish ? `${days}d ago` : `${days}天前`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const openInGitHub = async (url: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await open(url);
|
||||
};
|
||||
|
||||
// 检查帖子是否是热门(高点赞或评论)| Check if post is hot
|
||||
const isHotPost = (post: Post) => post.upvoteCount >= 5 || post.comments.totalCount >= 3;
|
||||
|
||||
// 检查帖子是否是新帖(24小时内)| Check if post is recent
|
||||
const isRecentPost = (post: Post) => {
|
||||
const diff = Date.now() - new Date(post.createdAt).getTime();
|
||||
return diff < 24 * 60 * 60 * 1000;
|
||||
};
|
||||
|
||||
const openGitHubDiscussions = async () => {
|
||||
await open('https://github.com/esengine/ecs-framework/discussions');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="forum-post-list">
|
||||
{/* 欢迎横幅 | Welcome banner */}
|
||||
{!params.categoryId && !params.search && (
|
||||
<div className="forum-welcome-banner">
|
||||
<div className="forum-welcome-content">
|
||||
<div className="forum-welcome-text">
|
||||
<h2>{isEnglish ? 'ESEngine Community' : 'ESEngine 社区'}</h2>
|
||||
<p>
|
||||
{isEnglish
|
||||
? 'Ask questions, share ideas, and connect with other developers'
|
||||
: '提出问题、分享想法,与其他开发者交流'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="forum-welcome-actions">
|
||||
<button className="forum-btn forum-btn-primary" onClick={onCreatePost}>
|
||||
<Plus size={14} />
|
||||
<span>{isEnglish ? 'New Discussion' : '发起讨论'}</span>
|
||||
</button>
|
||||
<button className="forum-btn forum-btn-github" onClick={openGitHubDiscussions}>
|
||||
<Github size={14} />
|
||||
<span>{isEnglish ? 'View on GitHub' : '在 GitHub 查看'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分类卡片 | Category cards */}
|
||||
{!params.categoryId && !params.search && categories.length > 0 && (
|
||||
<div className="forum-category-cards">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className="forum-category-card"
|
||||
onClick={() => onCategoryChange(cat.id)}
|
||||
>
|
||||
<span className="forum-category-card-icon">
|
||||
{getCategoryIcon(cat.name)}
|
||||
</span>
|
||||
<span className="forum-category-card-emoji">{parseEmoji(cat.emoji)}</span>
|
||||
<span className="forum-category-card-name">{cat.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 工具栏 | Toolbar */}
|
||||
<div className="forum-toolbar">
|
||||
<div className="forum-toolbar-left">
|
||||
<select
|
||||
className="forum-select"
|
||||
value={params.categoryId || ''}
|
||||
onChange={(e) => onCategoryChange(e.target.value || undefined)}
|
||||
>
|
||||
<option value="">{isEnglish ? 'All Categories' : '全部分类'}</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{parseEmoji(cat.emoji)} {cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<form onSubmit={handleSearchSubmit} className="forum-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={isEnglish ? 'Search discussions...' : '搜索讨论...'}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="forum-toolbar-right">
|
||||
<button
|
||||
className="forum-btn"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
title={isEnglish ? 'Refresh' : '刷新'}
|
||||
>
|
||||
<RefreshCw size={14} className={loading ? 'spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
className="forum-btn forum-btn-primary"
|
||||
onClick={onCreatePost}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>{isEnglish ? 'New' : '发帖'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 帖子统计 | Post stats */}
|
||||
<div className="forum-stats">
|
||||
<div className="forum-stats-left">
|
||||
<TrendingUp size={14} />
|
||||
<span>{totalCount} {isEnglish ? 'discussions' : '条讨论'}</span>
|
||||
</div>
|
||||
{params.categoryId && (
|
||||
<button
|
||||
className="forum-stats-clear"
|
||||
onClick={() => onCategoryChange(undefined)}
|
||||
>
|
||||
{isEnglish ? 'Clear filter' : '清除筛选'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 帖子列表 | Post list */}
|
||||
<div className={`forum-posts ${loading ? 'loading' : ''}`}>
|
||||
{/* 加载覆盖层 | Loading overlay */}
|
||||
{loading && posts.length > 0 && (
|
||||
<div className="forum-posts-overlay">
|
||||
<RefreshCw size={20} className="spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && posts.length === 0 ? (
|
||||
<div className="forum-posts-loading">
|
||||
<RefreshCw size={16} className="spin" />
|
||||
<span>{isEnglish ? 'Loading...' : '加载中...'}</span>
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<div className="forum-posts-empty">
|
||||
<MessageCircle size={32} />
|
||||
<p>{isEnglish ? 'No discussions yet' : '暂无讨论'}</p>
|
||||
<button className="forum-btn forum-btn-primary" onClick={onCreatePost}>
|
||||
<Plus size={14} />
|
||||
<span>{isEnglish ? 'Start a discussion' : '发起讨论'}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{posts.map(post => (
|
||||
<div
|
||||
key={post.id}
|
||||
className={`forum-post-item ${isHotPost(post) ? 'hot' : ''}`}
|
||||
onClick={() => onViewPost(post.number)}
|
||||
>
|
||||
<div className="forum-post-avatar">
|
||||
<img
|
||||
src={post.author.avatarUrl}
|
||||
alt={post.author.login}
|
||||
/>
|
||||
{isHotPost(post) && (
|
||||
<span className="forum-post-avatar-badge hot">
|
||||
<Flame size={10} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="forum-post-content">
|
||||
<div className="forum-post-header">
|
||||
<div className="forum-post-badges">
|
||||
{isRecentPost(post) && (
|
||||
<span className="forum-post-badge new">
|
||||
<Clock size={10} />
|
||||
{isEnglish ? 'New' : '新'}
|
||||
</span>
|
||||
)}
|
||||
{isHotPost(post) && (
|
||||
<span className="forum-post-badge hot">
|
||||
<Flame size={10} />
|
||||
{isEnglish ? 'Hot' : '热门'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="forum-post-title">{post.title}</h3>
|
||||
<button
|
||||
className="forum-post-external"
|
||||
onClick={(e) => openInGitHub(post.url, e)}
|
||||
title={isEnglish ? 'Open in GitHub' : '在 GitHub 中打开'}
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="forum-post-meta">
|
||||
<span className="forum-post-category">
|
||||
{parseEmoji(post.category.emoji)} {post.category.name}
|
||||
</span>
|
||||
<span className="forum-post-author">
|
||||
<img
|
||||
src={post.author.avatarUrl}
|
||||
alt={post.author.login}
|
||||
className="forum-post-author-avatar"
|
||||
/>
|
||||
@{post.author.login}
|
||||
</span>
|
||||
<span className="forum-post-time">
|
||||
<Clock size={11} />
|
||||
{formatDate(post.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="forum-post-stats">
|
||||
<span className={`forum-post-stat ${post.viewerHasUpvoted ? 'active' : ''}`}>
|
||||
<ThumbsUp size={12} />
|
||||
{post.upvoteCount}
|
||||
</span>
|
||||
<span className="forum-post-stat">
|
||||
<MessageCircle size={12} />
|
||||
{post.comments.totalCount}
|
||||
</span>
|
||||
{post.answerChosenAt && (
|
||||
<span className="forum-post-answered">
|
||||
<CheckCircle size={12} />
|
||||
{isEnglish ? 'Answered' : '已解决'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 加载更多 | Load more */}
|
||||
{hasNextPage && (
|
||||
<div className="forum-load-more">
|
||||
<button
|
||||
className="forum-btn"
|
||||
onClick={onLoadMore}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<RefreshCw size={14} className="spin" />
|
||||
<span>{isEnglish ? 'Loading...' : '加载中...'}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{isEnglish ? 'Load More' : '加载更多'}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
packages/editor-app/src/components/forum/ForumProfile.css
Normal file
97
packages/editor-app/src/components/forum/ForumProfile.css
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 用户资料样式 - GitHub
|
||||
* User profile styles - GitHub
|
||||
*/
|
||||
|
||||
.forum-profile {
|
||||
padding: 16px;
|
||||
background: #2a2a2a;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.forum-profile-header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.forum-profile-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.forum-profile-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.forum-profile-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.forum-profile-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.forum-profile-github-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-profile-github-link:hover {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-profile-divider {
|
||||
height: 1px;
|
||||
background: #3a3a3a;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.forum-profile-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.forum-profile-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.forum-profile-btn.logout {
|
||||
background: transparent;
|
||||
border-color: #4a4a4a;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.forum-profile-btn.logout:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: #f87171;
|
||||
color: #f87171;
|
||||
}
|
||||
66
packages/editor-app/src/components/forum/ForumProfile.tsx
Normal file
66
packages/editor-app/src/components/forum/ForumProfile.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 用户资料组件 - GitHub
|
||||
* User profile component - GitHub
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Github, LogOut, ExternalLink } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { useForumAuth } from '../../hooks/useForum';
|
||||
import './ForumProfile.css';
|
||||
|
||||
interface ForumProfileProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function ForumProfile({ onClose }: ForumProfileProps) {
|
||||
const { i18n } = useTranslation();
|
||||
const { authState, signOut } = useForumAuth();
|
||||
|
||||
const isEnglish = i18n.language === 'en';
|
||||
const user = authState.status === 'authenticated' ? authState.user : null;
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut();
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const openGitHubProfile = async () => {
|
||||
if (user) {
|
||||
await open(`https://github.com/${user.login}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="forum-profile">
|
||||
<div className="forum-profile-header">
|
||||
<div className="forum-profile-avatar">
|
||||
<img src={user.avatarUrl} alt={user.login} />
|
||||
</div>
|
||||
<div className="forum-profile-info">
|
||||
<h3 className="forum-profile-name">@{user.login}</h3>
|
||||
<button
|
||||
className="forum-profile-github-link"
|
||||
onClick={openGitHubProfile}
|
||||
>
|
||||
<Github size={12} />
|
||||
<span>{isEnglish ? 'View GitHub Profile' : '查看 GitHub 主页'}</span>
|
||||
<ExternalLink size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="forum-profile-divider" />
|
||||
|
||||
<div className="forum-profile-actions">
|
||||
<button className="forum-profile-btn logout" onClick={handleSignOut}>
|
||||
<LogOut size={14} />
|
||||
<span>{isEnglish ? 'Sign Out' : '退出登录'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
packages/editor-app/src/components/forum/index.ts
Normal file
9
packages/editor-app/src/components/forum/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 论坛组件导出
|
||||
* Forum components exports
|
||||
*/
|
||||
export { ForumPanel } from './ForumPanel';
|
||||
export { ForumAuth } from './ForumAuth';
|
||||
export { ForumPostList } from './ForumPostList';
|
||||
export { ForumPostDetail } from './ForumPostDetail';
|
||||
export { ForumCreatePost } from './ForumCreatePost';
|
||||
138
packages/editor-app/src/components/forum/utils.ts
Normal file
138
packages/editor-app/src/components/forum/utils.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 论坛工具函数 | Forum utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* GitHub emoji 短代码映射表 | GitHub emoji shortcode mapping
|
||||
*/
|
||||
const EMOJI_MAP: Record<string, string> = {
|
||||
':speech_balloon:': '💬',
|
||||
':bulb:': '💡',
|
||||
':pray:': '🙏',
|
||||
':raised_hands:': '🙌',
|
||||
':ballot_box:': '🗳️',
|
||||
':rocket:': '🚀',
|
||||
':bug:': '🐛',
|
||||
':sparkles:': '✨',
|
||||
':memo:': '📝',
|
||||
':question:': '❓',
|
||||
':fire:': '🔥',
|
||||
':star:': '⭐',
|
||||
':heart:': '❤️',
|
||||
':thumbsup:': '👍',
|
||||
':warning:': '⚠️',
|
||||
':book:': '📖',
|
||||
':wrench:': '🔧',
|
||||
':gear:': '⚙️',
|
||||
':zap:': '⚡',
|
||||
':art:': '🎨',
|
||||
':package:': '📦',
|
||||
':lock:': '🔒',
|
||||
':tada:': '🎉',
|
||||
':wave:': '👋',
|
||||
':eyes:': '👀',
|
||||
':thinking:': '🤔',
|
||||
':100:': '💯',
|
||||
':clap:': '👏',
|
||||
':hammer_and_wrench:': '🛠️',
|
||||
':world_map:': '🗺️',
|
||||
':video_game:': '🎮',
|
||||
':computer:': '💻',
|
||||
':pencil:': '✏️',
|
||||
':pencil2:': '✏️',
|
||||
':notebook:': '📓',
|
||||
':clipboard:': '📋',
|
||||
':pushpin:': '📌',
|
||||
':loudspeaker:': '📢',
|
||||
':mega:': '📣',
|
||||
':bell:': '🔔',
|
||||
':email:': '📧',
|
||||
':mailbox:': '📫',
|
||||
':inbox_tray:': '📥',
|
||||
':outbox_tray:': '📤',
|
||||
':file_folder:': '📁',
|
||||
':open_file_folder:': '📂',
|
||||
':card_index:': '📇',
|
||||
':chart_with_upwards_trend:': '📈',
|
||||
':chart_with_downwards_trend:': '📉',
|
||||
':bar_chart:': '📊',
|
||||
':date:': '📅',
|
||||
':calendar:': '📆',
|
||||
':card_index_dividers:': '🗂️',
|
||||
':triangular_ruler:': '📐',
|
||||
':straight_ruler:': '📏',
|
||||
':scissors:': '✂️',
|
||||
':link:': '🔗',
|
||||
':paperclip:': '📎',
|
||||
':hourglass:': '⌛',
|
||||
':watch:': '⌚',
|
||||
':alarm_clock:': '⏰',
|
||||
':stopwatch:': '⏱️',
|
||||
':timer_clock:': '⏲️',
|
||||
':telephone:': '☎️',
|
||||
':telephone_receiver:': '📞',
|
||||
':pager:': '📟',
|
||||
':fax:': '📠',
|
||||
':battery:': '🔋',
|
||||
':electric_plug:': '🔌',
|
||||
':desktop_computer:': '🖥️',
|
||||
':printer:': '🖨️',
|
||||
':keyboard:': '⌨️',
|
||||
':computer_mouse:': '🖱️',
|
||||
':trackball:': '🖲️',
|
||||
':minidisc:': '💽',
|
||||
':floppy_disk:': '💾',
|
||||
':cd:': '💿',
|
||||
':dvd:': '📀',
|
||||
':abacus:': '🧮',
|
||||
':movie_camera:': '🎥',
|
||||
':film_strip:': '🎞️',
|
||||
':film_projector:': '📽️',
|
||||
':clapper:': '🎬',
|
||||
':tv:': '📺',
|
||||
':camera:': '📷',
|
||||
':camera_flash:': '📸',
|
||||
':video_camera:': '📹',
|
||||
':mag:': '🔍',
|
||||
':mag_right:': '🔎',
|
||||
':candle:': '🕯️',
|
||||
':bulb_lightbulb:': '💡',
|
||||
':flashlight:': '🔦',
|
||||
':izakaya_lantern:': '🏮',
|
||||
':diya_lamp:': '🪔',
|
||||
':notebook_with_decorative_cover:': '📔',
|
||||
':closed_book:': '📕',
|
||||
':green_book:': '📗',
|
||||
':blue_book:': '📘',
|
||||
':orange_book:': '📙',
|
||||
':books:': '📚',
|
||||
':ledger:': '📒',
|
||||
':page_with_curl:': '📃',
|
||||
':scroll:': '📜',
|
||||
':page_facing_up:': '📄',
|
||||
':newspaper:': '📰',
|
||||
':rolled_up_newspaper:': '🗞️',
|
||||
':bookmark_tabs:': '📑',
|
||||
':bookmark:': '🔖',
|
||||
':label:': '🏷️',
|
||||
':moneybag:': '💰',
|
||||
':coin:': '🪙',
|
||||
':yen:': '💴',
|
||||
':dollar:': '💵',
|
||||
':euro:': '💶',
|
||||
':pound:': '💷',
|
||||
':money_with_wings:': '💸',
|
||||
':credit_card:': '💳',
|
||||
':receipt:': '🧾',
|
||||
':chart:': '💹',
|
||||
};
|
||||
|
||||
/**
|
||||
* 转换 GitHub emoji 短代码为 Unicode | Convert GitHub emoji shortcode to Unicode
|
||||
*/
|
||||
export function parseEmoji(emojiCode: string | undefined | null): string {
|
||||
if (!emojiCode) return '💬';
|
||||
// 如果已经是 emoji,直接返回 | If already emoji, return directly
|
||||
if (!emojiCode.startsWith(':')) return emojiCode;
|
||||
return EMOJI_MAP[emojiCode] || emojiCode.replace(/:/g, '');
|
||||
}
|
||||
254
packages/editor-app/src/hooks/useForum.ts
Normal file
254
packages/editor-app/src/hooks/useForum.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* 论坛 React Hooks - GitHub Discussions
|
||||
* Forum React hooks - GitHub Discussions
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getForumService } from '../services/forum';
|
||||
import type {
|
||||
AuthState,
|
||||
Category,
|
||||
Post,
|
||||
Reply,
|
||||
PostListParams,
|
||||
PaginatedResponse
|
||||
} from '../services/forum';
|
||||
|
||||
/**
|
||||
* 认证状态 hook
|
||||
* Auth state hook
|
||||
*/
|
||||
export function useForumAuth() {
|
||||
const [authState, setAuthState] = useState<AuthState>({ status: 'loading' });
|
||||
const forumService = getForumService();
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = forumService.onAuthStateChange(setAuthState);
|
||||
|
||||
// 超时保护:5秒后如果还在 loading,则设置为未认证
|
||||
// Timeout protection: if still loading after 5s, set to unauthenticated
|
||||
const timeout = setTimeout(() => {
|
||||
setAuthState(prev => {
|
||||
if (prev.status === 'loading') {
|
||||
console.warn('[useForumAuth] Timeout waiting for auth state, setting to unauthenticated');
|
||||
return { status: 'unauthenticated' };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const requestDeviceCode = useCallback(async () => {
|
||||
return forumService.requestDeviceCode();
|
||||
}, []);
|
||||
|
||||
const authenticateWithDeviceFlow = useCallback(async (
|
||||
deviceCode: string,
|
||||
interval: number,
|
||||
onStatusChange?: (status: 'pending' | 'authorized' | 'error') => void
|
||||
) => {
|
||||
return forumService.authenticateWithDeviceFlow(deviceCode, interval, onStatusChange);
|
||||
}, []);
|
||||
|
||||
const signInWithGitHubToken = useCallback(async (accessToken: string) => {
|
||||
return forumService.signInWithGitHubToken(accessToken);
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
return forumService.signOut();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
authState,
|
||||
requestDeviceCode,
|
||||
authenticateWithDeviceFlow,
|
||||
signInWithGitHubToken,
|
||||
signOut
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类列表 hook
|
||||
* Categories hook
|
||||
*/
|
||||
export function useCategories() {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const forumService = getForumService();
|
||||
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await forumService.getCategories();
|
||||
setCategories(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch categories'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
}, [fetchCategories]);
|
||||
|
||||
return { categories, loading, error, refetch: fetchCategories };
|
||||
}
|
||||
|
||||
/**
|
||||
* 帖子列表 hook
|
||||
* Post list hook
|
||||
*/
|
||||
export function usePosts(params: PostListParams = {}) {
|
||||
const [data, setData] = useState<PaginatedResponse<Post>>({
|
||||
data: [],
|
||||
totalCount: 0,
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
startCursor: null,
|
||||
endCursor: null
|
||||
}
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const forumService = getForumService();
|
||||
|
||||
const fetchPosts = useCallback(async (fetchParams: PostListParams = params) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await forumService.getPosts(fetchParams);
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch posts'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [JSON.stringify(params)]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPosts();
|
||||
}, [fetchPosts]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!data.pageInfo.hasNextPage || !data.pageInfo.endCursor) return;
|
||||
|
||||
try {
|
||||
const result = await forumService.getPosts({
|
||||
...params,
|
||||
after: data.pageInfo.endCursor
|
||||
});
|
||||
setData(prev => ({
|
||||
...result,
|
||||
data: [...prev.data, ...result.data]
|
||||
}));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to load more posts'));
|
||||
}
|
||||
}, [data.pageInfo, params]);
|
||||
|
||||
return { ...data, loading, error, refetch: fetchPosts, loadMore };
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个帖子 hook
|
||||
* Single post hook
|
||||
*/
|
||||
export function usePost(postNumber: number | null) {
|
||||
const [post, setPost] = useState<Post | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const forumService = getForumService();
|
||||
|
||||
const fetchPost = useCallback(async () => {
|
||||
if (postNumber === null) {
|
||||
setPost(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await forumService.getPost(postNumber);
|
||||
setPost(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch post'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [postNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPost();
|
||||
}, [fetchPost]);
|
||||
|
||||
const toggleUpvote = useCallback(async () => {
|
||||
if (!post) return;
|
||||
const success = await forumService.togglePostUpvote(post.id, post.viewerHasUpvoted);
|
||||
if (success) {
|
||||
setPost({
|
||||
...post,
|
||||
viewerHasUpvoted: !post.viewerHasUpvoted,
|
||||
upvoteCount: post.viewerHasUpvoted ? post.upvoteCount - 1 : post.upvoteCount + 1
|
||||
});
|
||||
}
|
||||
}, [post]);
|
||||
|
||||
return { post, loading, error, refetch: fetchPost, toggleUpvote };
|
||||
}
|
||||
|
||||
/**
|
||||
* 回复列表 hook
|
||||
* Replies hook
|
||||
*/
|
||||
export function useReplies(postNumber: number | null) {
|
||||
const [replies, setReplies] = useState<Reply[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const forumService = getForumService();
|
||||
|
||||
const fetchReplies = useCallback(async () => {
|
||||
if (postNumber === null) {
|
||||
setReplies([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await forumService.getReplies(postNumber);
|
||||
setReplies(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch replies'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [postNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReplies();
|
||||
}, [fetchReplies]);
|
||||
|
||||
const createReply = useCallback(async (discussionId: string, content: string, replyToId?: string) => {
|
||||
const reply = await forumService.createReply({
|
||||
discussionId,
|
||||
body: content,
|
||||
replyToId
|
||||
});
|
||||
if (reply) {
|
||||
await fetchReplies();
|
||||
}
|
||||
return reply;
|
||||
}, [fetchReplies]);
|
||||
|
||||
return { replies, loading, error, refetch: fetchReplies, createReply };
|
||||
}
|
||||
904
packages/editor-app/src/services/forum/ForumService.ts
Normal file
904
packages/editor-app/src/services/forum/ForumService.ts
Normal file
@@ -0,0 +1,904 @@
|
||||
/**
|
||||
* 论坛服务 - GitHub Discussions
|
||||
* Forum service - GitHub Discussions
|
||||
*/
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import type {
|
||||
Category,
|
||||
Post,
|
||||
Reply,
|
||||
PostListParams,
|
||||
PaginatedResponse,
|
||||
CreatePostParams,
|
||||
CreateReplyParams,
|
||||
ForumUser,
|
||||
AuthState,
|
||||
PageInfo
|
||||
} from './types';
|
||||
|
||||
type AuthStateCallback = (state: AuthState) => void;
|
||||
|
||||
/**
|
||||
* GitHub Device Flow 响应类型
|
||||
* GitHub Device Flow response types
|
||||
*/
|
||||
export interface DeviceCodeResponse {
|
||||
device_code: string;
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
expires_in: number;
|
||||
interval: number;
|
||||
}
|
||||
|
||||
interface OAuthTokenResponse {
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
scope?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
/** GitHub GraphQL API 端点 | GitHub GraphQL API endpoint */
|
||||
const GITHUB_GRAPHQL_API = 'https://api.github.com/graphql';
|
||||
|
||||
/** 仓库信息 | Repository info */
|
||||
const REPO_OWNER = 'esengine';
|
||||
const REPO_NAME = 'ecs-framework';
|
||||
|
||||
export class ForumService {
|
||||
private authCallbacks = new Set<AuthStateCallback>();
|
||||
private currentUser: ForumUser | null = null;
|
||||
private isInitialized = false;
|
||||
private repositoryId: string | null = null;
|
||||
|
||||
/** GitHub OAuth App Client ID for Forum */
|
||||
private readonly GITHUB_CLIENT_ID = 'Ov23liu5on5ud8oloMj2';
|
||||
|
||||
/** localStorage key for token */
|
||||
private readonly TOKEN_STORAGE_KEY = 'esengine_forum_github_token';
|
||||
|
||||
constructor() {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// GraphQL 请求 | GraphQL Request
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 发送 GraphQL 请求
|
||||
* Send GraphQL request
|
||||
*/
|
||||
private async graphql<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
|
||||
const token = this.currentUser?.accessToken;
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(GITHUB_GRAPHQL_API, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ query, variables })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.errors) {
|
||||
console.error('[ForumService] GraphQL errors:', result.errors);
|
||||
throw new Error(result.errors[0]?.message || 'GraphQL request failed');
|
||||
}
|
||||
|
||||
return result.data as T;
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 认证相关 | Authentication
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 初始化服务
|
||||
* Initialize service
|
||||
*/
|
||||
private async initialize(): Promise<void> {
|
||||
try {
|
||||
// 从 localStorage 恢复 token | Restore token from localStorage
|
||||
const savedToken = localStorage.getItem(this.TOKEN_STORAGE_KEY);
|
||||
|
||||
if (savedToken) {
|
||||
// 验证 token 是否有效 | Verify token is valid
|
||||
const user = await this.verifyAndGetUser(savedToken);
|
||||
if (user) {
|
||||
this.currentUser = user;
|
||||
this.notifyAuthChange({ status: 'authenticated', user });
|
||||
} else {
|
||||
localStorage.removeItem(this.TOKEN_STORAGE_KEY);
|
||||
this.notifyAuthChange({ status: 'unauthenticated' });
|
||||
}
|
||||
} else {
|
||||
this.notifyAuthChange({ status: 'unauthenticated' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ForumService] Initialize error:', err);
|
||||
this.notifyAuthChange({ status: 'unauthenticated' });
|
||||
} finally {
|
||||
this.isInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 token 并获取用户信息
|
||||
* Verify token and get user info
|
||||
*/
|
||||
private async verifyAndGetUser(token: string): Promise<ForumUser | null> {
|
||||
try {
|
||||
const response = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
id: data.id.toString(),
|
||||
login: data.login,
|
||||
avatarUrl: data.avatar_url,
|
||||
accessToken: token
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅认证状态变化
|
||||
* Subscribe to auth state changes
|
||||
*/
|
||||
onAuthStateChange(callback: AuthStateCallback): () => void {
|
||||
this.authCallbacks.add(callback);
|
||||
|
||||
if (this.isInitialized) {
|
||||
if (this.currentUser) {
|
||||
callback({ status: 'authenticated', user: this.currentUser });
|
||||
} else {
|
||||
callback({ status: 'unauthenticated' });
|
||||
}
|
||||
} else {
|
||||
callback({ status: 'loading' });
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.authCallbacks.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
private notifyAuthChange(state: AuthState): void {
|
||||
this.authCallbacks.forEach(cb => cb(state));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户
|
||||
* Get current user
|
||||
*/
|
||||
getCurrentUser(): ForumUser | null {
|
||||
return this.currentUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求 GitHub Device Code
|
||||
* Request GitHub Device Code for Device Flow
|
||||
*/
|
||||
async requestDeviceCode(): Promise<DeviceCodeResponse> {
|
||||
const response = await fetch('https://github.com/login/device/code', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: this.GITHUB_CLIENT_ID,
|
||||
scope: 'read:user public_repo write:discussion'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to request device code: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Device Flow 认证 GitHub
|
||||
* Authenticate with GitHub using Device Flow
|
||||
*/
|
||||
async authenticateWithDeviceFlow(
|
||||
deviceCode: string,
|
||||
interval: number,
|
||||
onStatusChange?: (status: 'pending' | 'authorized' | 'error') => void
|
||||
): Promise<string> {
|
||||
const pollInterval = Math.max(interval, 5) * 1000;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = async () => {
|
||||
try {
|
||||
const response = await fetch('https://github.com/login/oauth/access_token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: this.GITHUB_CLIENT_ID,
|
||||
device_code: deviceCode,
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
|
||||
})
|
||||
});
|
||||
|
||||
const data: OAuthTokenResponse = await response.json();
|
||||
|
||||
if (data.access_token) {
|
||||
onStatusChange?.('authorized');
|
||||
resolve(data.access_token);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.error === 'authorization_pending') {
|
||||
onStatusChange?.('pending');
|
||||
setTimeout(poll, pollInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.error === 'slow_down') {
|
||||
setTimeout(poll, pollInterval + 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
onStatusChange?.('error');
|
||||
reject(new Error(data.error_description || data.error || 'Authorization failed'));
|
||||
} catch (err) {
|
||||
onStatusChange?.('error');
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 GitHub Access Token 登录
|
||||
* Sign in with GitHub access token
|
||||
*/
|
||||
async signInWithGitHubToken(accessToken: string): Promise<{ error: Error | null }> {
|
||||
try {
|
||||
const user = await this.verifyAndGetUser(accessToken);
|
||||
if (!user) {
|
||||
return { error: new Error('Failed to verify GitHub token') };
|
||||
}
|
||||
|
||||
// 保存 token | Save token
|
||||
localStorage.setItem(this.TOKEN_STORAGE_KEY, accessToken);
|
||||
|
||||
this.currentUser = user;
|
||||
this.notifyAuthChange({ status: 'authenticated', user });
|
||||
|
||||
return { error: null };
|
||||
} catch (err) {
|
||||
console.error('[ForumService] Sign in failed:', err);
|
||||
return { error: err instanceof Error ? err : new Error('Sign in failed') };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
* Sign out
|
||||
*/
|
||||
async signOut(): Promise<void> {
|
||||
localStorage.removeItem(this.TOKEN_STORAGE_KEY);
|
||||
this.currentUser = null;
|
||||
this.notifyAuthChange({ status: 'unauthenticated' });
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 仓库信息 | Repository Info
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 获取仓库 ID
|
||||
* Get repository ID
|
||||
*/
|
||||
private async getRepositoryId(): Promise<string> {
|
||||
if (this.repositoryId) {
|
||||
return this.repositoryId;
|
||||
}
|
||||
|
||||
const data = await this.graphql<{
|
||||
repository: { id: string }
|
||||
}>(`
|
||||
query {
|
||||
repository(owner: "${REPO_OWNER}", name: "${REPO_NAME}") {
|
||||
id
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
this.repositoryId = data.repository.id;
|
||||
return this.repositoryId;
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 分类 | Categories
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 获取所有分类
|
||||
* Get all categories
|
||||
*/
|
||||
async getCategories(): Promise<Category[]> {
|
||||
const data = await this.graphql<{
|
||||
repository: {
|
||||
discussionCategories: {
|
||||
nodes: Category[]
|
||||
}
|
||||
}
|
||||
}>(`
|
||||
query {
|
||||
repository(owner: "${REPO_OWNER}", name: "${REPO_NAME}") {
|
||||
discussionCategories(first: 20) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
slug
|
||||
emoji
|
||||
description
|
||||
isAnswerable
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
return data.repository.discussionCategories.nodes;
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 帖子 | Posts (Discussions)
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 获取帖子列表
|
||||
* Get post list
|
||||
*/
|
||||
async getPosts(params: PostListParams = {}): Promise<PaginatedResponse<Post>> {
|
||||
const { categoryId, first = 20, after } = params;
|
||||
|
||||
let categoryFilter = '';
|
||||
if (categoryId) {
|
||||
categoryFilter = `, categoryId: "${categoryId}"`;
|
||||
}
|
||||
|
||||
let afterCursor = '';
|
||||
if (after) {
|
||||
afterCursor = `, after: "${after}"`;
|
||||
}
|
||||
|
||||
const data = await this.graphql<{
|
||||
repository: {
|
||||
discussions: {
|
||||
totalCount: number;
|
||||
pageInfo: PageInfo;
|
||||
nodes: Post[];
|
||||
}
|
||||
}
|
||||
}>(`
|
||||
query {
|
||||
repository(owner: "${REPO_OWNER}", name: "${REPO_NAME}") {
|
||||
discussions(first: ${first}${afterCursor}${categoryFilter}, orderBy: {field: CREATED_AT, direction: DESC}) {
|
||||
totalCount
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
id
|
||||
number
|
||||
title
|
||||
body
|
||||
bodyHTML
|
||||
createdAt
|
||||
updatedAt
|
||||
upvoteCount
|
||||
url
|
||||
viewerHasUpvoted
|
||||
viewerCanUpvote
|
||||
answerChosenAt
|
||||
author {
|
||||
... on User {
|
||||
id
|
||||
login
|
||||
avatarUrl
|
||||
url
|
||||
}
|
||||
}
|
||||
category {
|
||||
id
|
||||
name
|
||||
slug
|
||||
emoji
|
||||
description
|
||||
isAnswerable
|
||||
}
|
||||
comments {
|
||||
totalCount
|
||||
}
|
||||
answerChosenBy {
|
||||
... on User {
|
||||
login
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
return {
|
||||
data: data.repository.discussions.nodes,
|
||||
totalCount: data.repository.discussions.totalCount,
|
||||
pageInfo: data.repository.discussions.pageInfo
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个帖子
|
||||
* Get single post
|
||||
*/
|
||||
async getPost(number: number): Promise<Post | null> {
|
||||
const data = await this.graphql<{
|
||||
repository: {
|
||||
discussion: Post | null
|
||||
}
|
||||
}>(`
|
||||
query {
|
||||
repository(owner: "${REPO_OWNER}", name: "${REPO_NAME}") {
|
||||
discussion(number: ${number}) {
|
||||
id
|
||||
number
|
||||
title
|
||||
body
|
||||
bodyHTML
|
||||
createdAt
|
||||
updatedAt
|
||||
upvoteCount
|
||||
url
|
||||
viewerHasUpvoted
|
||||
viewerCanUpvote
|
||||
answerChosenAt
|
||||
author {
|
||||
... on User {
|
||||
id
|
||||
login
|
||||
avatarUrl
|
||||
url
|
||||
}
|
||||
}
|
||||
category {
|
||||
id
|
||||
name
|
||||
slug
|
||||
emoji
|
||||
description
|
||||
isAnswerable
|
||||
}
|
||||
comments {
|
||||
totalCount
|
||||
}
|
||||
answerChosenBy {
|
||||
... on User {
|
||||
login
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
return data.repository.discussion;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建帖子
|
||||
* Create post
|
||||
*/
|
||||
async createPost(params: CreatePostParams): Promise<Post | null> {
|
||||
const repoId = await this.getRepositoryId();
|
||||
|
||||
const data = await this.graphql<{
|
||||
createDiscussion: {
|
||||
discussion: Post
|
||||
}
|
||||
}>(`
|
||||
mutation CreateDiscussion($input: CreateDiscussionInput!) {
|
||||
createDiscussion(input: $input) {
|
||||
discussion {
|
||||
id
|
||||
number
|
||||
title
|
||||
body
|
||||
bodyHTML
|
||||
createdAt
|
||||
updatedAt
|
||||
upvoteCount
|
||||
url
|
||||
viewerHasUpvoted
|
||||
viewerCanUpvote
|
||||
author {
|
||||
... on User {
|
||||
id
|
||||
login
|
||||
avatarUrl
|
||||
url
|
||||
}
|
||||
}
|
||||
category {
|
||||
id
|
||||
name
|
||||
slug
|
||||
emoji
|
||||
description
|
||||
isAnswerable
|
||||
}
|
||||
comments {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`, {
|
||||
input: {
|
||||
repositoryId: repoId,
|
||||
categoryId: params.categoryId,
|
||||
title: params.title,
|
||||
body: params.body
|
||||
}
|
||||
});
|
||||
|
||||
return data.createDiscussion.discussion;
|
||||
}
|
||||
|
||||
/**
|
||||
* 点赞/取消点赞帖子
|
||||
* Upvote/remove upvote from post
|
||||
*/
|
||||
async togglePostUpvote(discussionId: string, hasUpvoted: boolean): Promise<boolean> {
|
||||
try {
|
||||
if (hasUpvoted) {
|
||||
await this.graphql(`
|
||||
mutation {
|
||||
removeUpvote(input: { subjectId: "${discussionId}" }) {
|
||||
subject {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
} else {
|
||||
await this.graphql(`
|
||||
mutation {
|
||||
addUpvote(input: { subjectId: "${discussionId}" }) {
|
||||
subject {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[ForumService] Toggle upvote failed:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 回复 | Replies (Comments)
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 获取帖子的回复列表
|
||||
* Get post replies
|
||||
*/
|
||||
async getReplies(discussionNumber: number): Promise<Reply[]> {
|
||||
const data = await this.graphql<{
|
||||
repository: {
|
||||
discussion: {
|
||||
comments: {
|
||||
nodes: Reply[]
|
||||
}
|
||||
} | null
|
||||
}
|
||||
}>(`
|
||||
query {
|
||||
repository(owner: "${REPO_OWNER}", name: "${REPO_NAME}") {
|
||||
discussion(number: ${discussionNumber}) {
|
||||
comments(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
body
|
||||
bodyHTML
|
||||
createdAt
|
||||
updatedAt
|
||||
upvoteCount
|
||||
isAnswer
|
||||
viewerHasUpvoted
|
||||
viewerCanUpvote
|
||||
author {
|
||||
... on User {
|
||||
id
|
||||
login
|
||||
avatarUrl
|
||||
url
|
||||
}
|
||||
}
|
||||
replies(first: 50) {
|
||||
totalCount
|
||||
nodes {
|
||||
id
|
||||
body
|
||||
bodyHTML
|
||||
createdAt
|
||||
updatedAt
|
||||
upvoteCount
|
||||
isAnswer
|
||||
viewerHasUpvoted
|
||||
viewerCanUpvote
|
||||
author {
|
||||
... on User {
|
||||
id
|
||||
login
|
||||
avatarUrl
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
return data.repository.discussion?.comments.nodes || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建回复
|
||||
* Create reply
|
||||
*/
|
||||
async createReply(params: CreateReplyParams): Promise<Reply | null> {
|
||||
try {
|
||||
if (params.replyToId) {
|
||||
// 回复评论 | Reply to comment
|
||||
const data = await this.graphql<{
|
||||
addDiscussionComment: {
|
||||
comment: Reply
|
||||
}
|
||||
}>(`
|
||||
mutation AddReply($input: AddDiscussionCommentInput!) {
|
||||
addDiscussionComment(input: $input) {
|
||||
comment {
|
||||
id
|
||||
body
|
||||
bodyHTML
|
||||
createdAt
|
||||
updatedAt
|
||||
upvoteCount
|
||||
isAnswer
|
||||
viewerHasUpvoted
|
||||
viewerCanUpvote
|
||||
author {
|
||||
... on User {
|
||||
id
|
||||
login
|
||||
avatarUrl
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`, {
|
||||
input: {
|
||||
discussionId: params.discussionId,
|
||||
replyToId: params.replyToId,
|
||||
body: params.body
|
||||
}
|
||||
});
|
||||
|
||||
return data.addDiscussionComment.comment;
|
||||
} else {
|
||||
// 直接评论帖子 | Direct comment on discussion
|
||||
const data = await this.graphql<{
|
||||
addDiscussionComment: {
|
||||
comment: Reply
|
||||
}
|
||||
}>(`
|
||||
mutation AddComment($input: AddDiscussionCommentInput!) {
|
||||
addDiscussionComment(input: $input) {
|
||||
comment {
|
||||
id
|
||||
body
|
||||
bodyHTML
|
||||
createdAt
|
||||
updatedAt
|
||||
upvoteCount
|
||||
isAnswer
|
||||
viewerHasUpvoted
|
||||
viewerCanUpvote
|
||||
author {
|
||||
... on User {
|
||||
id
|
||||
login
|
||||
avatarUrl
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`, {
|
||||
input: {
|
||||
discussionId: params.discussionId,
|
||||
body: params.body
|
||||
}
|
||||
});
|
||||
|
||||
return data.addDiscussionComment.comment;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ForumService] Create reply failed:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 点赞/取消点赞回复
|
||||
* Upvote/remove upvote from reply
|
||||
*/
|
||||
async toggleReplyUpvote(commentId: string, hasUpvoted: boolean): Promise<boolean> {
|
||||
try {
|
||||
if (hasUpvoted) {
|
||||
await this.graphql(`
|
||||
mutation {
|
||||
removeUpvote(input: { subjectId: "${commentId}" }) {
|
||||
subject {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
} else {
|
||||
await this.graphql(`
|
||||
mutation {
|
||||
addUpvote(input: { subjectId: "${commentId}" }) {
|
||||
subject {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[ForumService] Toggle reply upvote failed:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 图片上传 | Image Upload
|
||||
// =====================================================
|
||||
|
||||
/** Imgur Client ID (匿名上传) | Imgur Client ID (anonymous upload) */
|
||||
private readonly IMGUR_CLIENT_ID = '546c25a59c58ad7';
|
||||
|
||||
/**
|
||||
* 上传图片到 Imgur 图床
|
||||
* Upload image to Imgur
|
||||
* @param file 图片文件 | Image file
|
||||
* @param onProgress 进度回调 | Progress callback
|
||||
* @returns 图片 URL | Image URL
|
||||
*/
|
||||
async uploadImage(
|
||||
file: File,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<string> {
|
||||
// 验证文件类型 | Validate file type
|
||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
throw new Error('Only PNG, JPEG, GIF, and WebP images are allowed');
|
||||
}
|
||||
|
||||
// 限制文件大小 (10MB - Imgur 限制) | Limit file size (10MB - Imgur limit)
|
||||
const maxSize = 10 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
throw new Error('Image size must be less than 10MB');
|
||||
}
|
||||
|
||||
onProgress?.(10);
|
||||
|
||||
// 读取文件为 base64 | Read file as base64
|
||||
const base64Content = await this.fileToBase64(file);
|
||||
|
||||
onProgress?.(30);
|
||||
|
||||
// 使用 Imgur API 上传 | Upload using Imgur API
|
||||
const response = await fetch('https://api.imgur.com/3/image', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Client-ID ${this.IMGUR_CLIENT_ID}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
image: base64Content,
|
||||
type: 'base64'
|
||||
})
|
||||
});
|
||||
|
||||
onProgress?.(80);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
console.error('[ForumService] Imgur upload failed:', errorData);
|
||||
throw new Error(`Failed to upload image: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
onProgress?.(100);
|
||||
|
||||
if (!data.success || !data.data?.link) {
|
||||
throw new Error('Imgur upload failed: invalid response');
|
||||
}
|
||||
|
||||
return data.data.link;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文件转换为 base64
|
||||
* Convert file to base64
|
||||
*/
|
||||
private fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
// 移除 data:image/xxx;base64, 前缀 | Remove data:image/xxx;base64, prefix
|
||||
const base64 = result.split(',')[1] || '';
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 单例实例 | Singleton instance
|
||||
let forumServiceInstance: ForumService | null = null;
|
||||
|
||||
export function getForumService(): ForumService {
|
||||
if (!forumServiceInstance) {
|
||||
forumServiceInstance = new ForumService();
|
||||
}
|
||||
return forumServiceInstance;
|
||||
}
|
||||
7
packages/editor-app/src/services/forum/index.ts
Normal file
7
packages/editor-app/src/services/forum/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 论坛服务导出 - GitHub Discussions
|
||||
* Forum service exports - GitHub Discussions
|
||||
*/
|
||||
export { ForumService, getForumService } from './ForumService';
|
||||
export type { DeviceCodeResponse } from './ForumService';
|
||||
export * from './types';
|
||||
146
packages/editor-app/src/services/forum/types.ts
Normal file
146
packages/editor-app/src/services/forum/types.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 论坛类型定义 - GitHub Discussions
|
||||
* Forum type definitions - GitHub Discussions
|
||||
*/
|
||||
|
||||
/**
|
||||
* GitHub 用户信息
|
||||
* GitHub user info
|
||||
*/
|
||||
export interface GitHubUser {
|
||||
id: string;
|
||||
login: string;
|
||||
avatarUrl: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discussion 分类
|
||||
* Discussion category
|
||||
*/
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
emoji: string;
|
||||
description: string;
|
||||
isAnswerable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discussion 帖子
|
||||
* Discussion post
|
||||
*/
|
||||
export interface Post {
|
||||
id: string;
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
bodyHTML: string;
|
||||
author: GitHubUser;
|
||||
category: Category;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
upvoteCount: number;
|
||||
comments: {
|
||||
totalCount: number;
|
||||
};
|
||||
answerChosenAt?: string;
|
||||
answerChosenBy?: GitHubUser;
|
||||
url: string;
|
||||
viewerHasUpvoted: boolean;
|
||||
viewerCanUpvote: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discussion 评论
|
||||
* Discussion comment
|
||||
*/
|
||||
export interface Reply {
|
||||
id: string;
|
||||
body: string;
|
||||
bodyHTML: string;
|
||||
author: GitHubUser;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
upvoteCount: number;
|
||||
isAnswer: boolean;
|
||||
viewerHasUpvoted: boolean;
|
||||
viewerCanUpvote: boolean;
|
||||
replies?: {
|
||||
totalCount: number;
|
||||
nodes: Reply[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 帖子列表查询参数
|
||||
* Post list query parameters
|
||||
*/
|
||||
export interface PostListParams {
|
||||
categoryId?: string;
|
||||
search?: string;
|
||||
first?: number;
|
||||
after?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页信息
|
||||
* Pagination info
|
||||
*/
|
||||
export interface PageInfo {
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
startCursor: string | null;
|
||||
endCursor: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页响应
|
||||
* Paginated response
|
||||
*/
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
totalCount: number;
|
||||
pageInfo: PageInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建帖子参数
|
||||
* Create post parameters
|
||||
*/
|
||||
export interface CreatePostParams {
|
||||
title: string;
|
||||
body: string;
|
||||
categoryId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建回复参数
|
||||
* Create reply parameters
|
||||
*/
|
||||
export interface CreateReplyParams {
|
||||
discussionId: string;
|
||||
body: string;
|
||||
replyToId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 论坛用户状态
|
||||
* Forum user state
|
||||
*/
|
||||
export interface ForumUser {
|
||||
id: string;
|
||||
login: string;
|
||||
avatarUrl: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证状态
|
||||
* Auth state
|
||||
*/
|
||||
export type AuthState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'authenticated'; user: ForumUser }
|
||||
| { status: 'unauthenticated' };
|
||||
Reference in New Issue
Block a user