From 3b56ed17fe6e7ce74ab4ffbdaf61956cc8eebb04 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Thu, 4 Dec 2025 09:51:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E6=B7=BB=E5=8A=A0=20GitHub=20D?= =?UTF-8?q?iscussions=20=E7=A4=BE=E5=8C=BA=E8=AE=BA=E5=9D=9B=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=20(#266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(editor): 添加 GitHub Discussions 社区论坛功能 * chore: 更新 pnpm-lock.yaml --- packages/editor-app/package.json | 26 +- packages/editor-app/src/App.tsx | 7 + .../src/components/forum/ForumAuth.css | 255 +++++ .../src/components/forum/ForumAuth.tsx | 161 +++ .../src/components/forum/ForumCreatePost.css | 603 ++++++++++++ .../src/components/forum/ForumCreatePost.tsx | 459 +++++++++ .../src/components/forum/ForumPanel.css | 170 ++++ .../src/components/forum/ForumPanel.tsx | 184 ++++ .../src/components/forum/ForumPostDetail.css | 560 +++++++++++ .../src/components/forum/ForumPostDetail.tsx | 270 +++++ .../src/components/forum/ForumPostList.css | 590 +++++++++++ .../src/components/forum/ForumPostList.tsx | 341 +++++++ .../src/components/forum/ForumProfile.css | 97 ++ .../src/components/forum/ForumProfile.tsx | 66 ++ .../editor-app/src/components/forum/index.ts | 9 + .../editor-app/src/components/forum/utils.ts | 138 +++ packages/editor-app/src/hooks/useForum.ts | 254 +++++ .../src/services/forum/ForumService.ts | 919 ++++++++++++++++++ .../editor-app/src/services/forum/index.ts | 7 + .../editor-app/src/services/forum/types.ts | 146 +++ 20 files changed, 5249 insertions(+), 13 deletions(-) create mode 100644 packages/editor-app/src/components/forum/ForumAuth.css create mode 100644 packages/editor-app/src/components/forum/ForumAuth.tsx create mode 100644 packages/editor-app/src/components/forum/ForumCreatePost.css create mode 100644 packages/editor-app/src/components/forum/ForumCreatePost.tsx create mode 100644 packages/editor-app/src/components/forum/ForumPanel.css create mode 100644 packages/editor-app/src/components/forum/ForumPanel.tsx create mode 100644 packages/editor-app/src/components/forum/ForumPostDetail.css create mode 100644 packages/editor-app/src/components/forum/ForumPostDetail.tsx create mode 100644 packages/editor-app/src/components/forum/ForumPostList.css create mode 100644 packages/editor-app/src/components/forum/ForumPostList.tsx create mode 100644 packages/editor-app/src/components/forum/ForumProfile.css create mode 100644 packages/editor-app/src/components/forum/ForumProfile.tsx create mode 100644 packages/editor-app/src/components/forum/index.ts create mode 100644 packages/editor-app/src/components/forum/utils.ts create mode 100644 packages/editor-app/src/hooks/useForum.ts create mode 100644 packages/editor-app/src/services/forum/ForumService.ts create mode 100644 packages/editor-app/src/services/forum/index.ts create mode 100644 packages/editor-app/src/services/forum/types.ts diff --git a/packages/editor-app/package.json b/packages/editor-app/package.json index 30a5d074..69061d4f 100644 --- a/packages/editor-app/package.json +++ b/packages/editor-app/package.json @@ -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", diff --git a/packages/editor-app/src/App.tsx b/packages/editor-app/src/App.tsx index 8e79fa9b..29bf6c0a 100644 --- a/packages/editor-app/src/App.tsx +++ b/packages/editor-app/src/App.tsx @@ -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: , closable: false + }, + { + id: 'forum', + title: locale === 'zh' ? '社区论坛' : 'Forum', + content: , + closable: true } ]; diff --git a/packages/editor-app/src/components/forum/ForumAuth.css b/packages/editor-app/src/components/forum/ForumAuth.css new file mode 100644 index 00000000..4ec6ee17 --- /dev/null +++ b/packages/editor-app/src/components/forum/ForumAuth.css @@ -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); } +} diff --git a/packages/editor-app/src/components/forum/ForumAuth.tsx b/packages/editor-app/src/components/forum/ForumAuth.tsx new file mode 100644 index 00000000..2dc9cfc5 --- /dev/null +++ b/packages/editor-app/src/components/forum/ForumAuth.tsx @@ -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('idle'); + const [userCode, setUserCode] = useState(''); + const [verificationUri, setVerificationUri] = useState(''); + const [error, setError] = useState(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 ( +
+
+
+ +

{isEnglish ? 'ESEngine Community' : 'ESEngine 社区'}

+

{isEnglish ? 'Sign in with GitHub to join the discussion' : '使用 GitHub 登录参与讨论'}

+
+ + {/* 初始状态 | Idle state */} + {authStatus === 'idle' && ( +
+
+

{isEnglish ? '1. Click the button below' : '1. 点击下方按钮'}

+

{isEnglish ? '2. Enter the code on GitHub' : '2. 在 GitHub 页面输入验证码'}

+

{isEnglish ? '3. Authorize the application' : '3. 授权应用'}

+
+ +
+ )} + + {/* 等待授权 | Pending state */} + {authStatus === 'pending' && ( +
+ +

+ {isEnglish ? 'Waiting for authorization...' : '等待授权中...'} +

+ + {userCode && ( +
+ +
+ {userCode} + +
+ +
+ )} +
+ )} + + {/* 授权成功 | Success state */} + {authStatus === 'authorized' && ( +
+ +

{isEnglish ? 'Authorization successful!' : '授权成功!'}

+
+ )} + + {/* 授权失败 | Error state */} + {authStatus === 'error' && ( +
+ +

{isEnglish ? 'Authorization failed' : '授权失败'}

+ {error &&

{error}

} + +
+ )} +
+
+ ); +} diff --git a/packages/editor-app/src/components/forum/ForumCreatePost.css b/packages/editor-app/src/components/forum/ForumCreatePost.css new file mode 100644 index 00000000..018c62a6 --- /dev/null +++ b/packages/editor-app/src/components/forum/ForumCreatePost.css @@ -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; + } +} diff --git a/packages/editor-app/src/components/forum/ForumCreatePost.tsx b/packages/editor-app/src/components/forum/ForumCreatePost.tsx new file mode 100644 index 00000000..4c5b47ca --- /dev/null +++ b/packages/editor-app/src/components/forum/ForumCreatePost.tsx @@ -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(null); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState('write'); + const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [isDragging, setIsDragging] = useState(false); + const textareaRef = useRef(null); + const fileInputRef = useRef(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 = `![${file.name}](${imageUrl})`; + 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![${file.name}](${imageUrl})`); + } + } 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) => { + 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: , action: () => insertMarkdown('**', '**', 'bold'), title: isEnglish ? 'Bold' : '粗体' }, + { icon: , action: () => insertMarkdown('*', '*', 'italic'), title: isEnglish ? 'Italic' : '斜体' }, + { icon: , action: () => insertMarkdown('`', '`', 'code'), title: isEnglish ? 'Inline code' : '行内代码' }, + { icon: , action: () => insertMarkdown('[', '](url)', 'link text'), title: isEnglish ? 'Link' : '链接' }, + { icon: , action: () => insertMarkdown('\n- ', '', 'list item'), title: isEnglish ? 'List' : '列表' }, + { icon: , action: () => insertMarkdown('\n> ', '', 'quote'), title: isEnglish ? 'Quote' : '引用' }, + { icon: , action: () => fileInputRef.current?.click(), title: isEnglish ? 'Upload image' : '上传图片' }, + ]; + + const selectedCategory = categories.find(c => c.id === categoryId); + + return ( +
+ {/* 返回按钮 | Back button */} + + +
+ {/* 左侧:编辑区 | Left: Editor */} +
+
+

{isEnglish ? 'Start a Discussion' : '发起讨论'}

+ {selectedCategory && ( + + {parseEmoji(selectedCategory.emoji)} {selectedCategory.name} + + )} +
+ +
+ {/* 分类选择 | Category selection */} +
+ +
+ {categories.map(cat => ( + + ))} +
+
+ + {/* 标题 | Title */} +
+ +
+ setTitle(e.target.value)} + placeholder={isEnglish ? 'Enter a descriptive title...' : '输入一个描述性的标题...'} + maxLength={200} + /> + {title.length}/200 +
+
+ + {/* 编辑器 | Editor */} +
+
+
+ + +
+ + {activeTab === 'write' && ( +
+ {toolbarButtons.map((btn, idx) => ( + + ))} + + + +
+ )} +
+ +
+ {/* 隐藏的文件输入 | Hidden file input */} + + + {/* 上传进度提示 | Upload progress indicator */} + {uploading && ( +
+ + {isEnglish ? 'Uploading...' : '上传中...'} {uploadProgress}% +
+ )} + + {/* 拖拽提示 | Drag hint */} + {isDragging && !uploading && ( +
+ + {isEnglish ? 'Drop image here' : '拖放图片到这里'} +
+ )} + + {activeTab === 'write' ? ( +