Files
esengine/packages/editor-app/src/components/UserDashboard.tsx

1096 lines
55 KiB
TypeScript
Raw Normal View History

refactor(editor): 提取行为树编辑器为独立包并重构编辑器架构 (#216) * refactor(editor): 提取行为树编辑器为独立包并重构编辑器架构 * feat(editor): 添加插件市场功能 * feat(editor): 重构插件市场以支持版本管理和ZIP打包 * feat(editor): 重构插件发布流程并修复React渲染警告 * fix(plugin): 修复插件发布和市场的路径不一致问题 * feat: 重构插件发布流程并添加插件删除功能 * fix(editor): 完善插件删除功能并修复多个关键问题 * fix(auth): 修复自动登录与手动登录的竞态条件问题 * feat(editor): 重构插件管理流程 * feat(editor): 支持 ZIP 文件直接发布插件 - 新增 PluginSourceParser 解析插件源 - 重构发布流程支持文件夹和 ZIP 两种方式 - 优化发布向导 UI * feat(editor): 插件市场支持多版本安装 - 插件解压到项目 plugins 目录 - 新增 Tauri 后端安装/卸载命令 - 支持选择任意版本安装 - 修复打包逻辑,保留完整 dist 目录结构 * feat(editor): 个人中心支持多版本管理 - 合并同一插件的不同版本 - 添加版本历史展开/折叠功能 - 禁止有待审核 PR 时更新插件 * fix(editor): 修复 InspectorRegistry 服务注册 - InspectorRegistry 实现 IService 接口 - 注册到 Core.services 供插件使用 * feat(behavior-tree-editor): 完善插件注册和文件操作 - 添加文件创建模板和操作处理器 - 实现右键菜单创建行为树功能 - 修复文件读取权限问题(使用 Tauri 命令) - 添加 BehaviorTreeEditorPanel 组件 - 修复 rollup 配置支持动态导入 * feat(plugin): 完善插件构建和发布流程 * fix(behavior-tree-editor): 完整恢复编辑器并修复 Toast 集成 * fix(behavior-tree-editor): 修复节点选中、连线跟随和文件加载问题并优化性能 * fix(behavior-tree-editor): 修复端口连接失败问题并优化连线样式 * refactor(behavior-tree-editor): 移除调试面板功能简化代码结构 * refactor(behavior-tree-editor): 清理冗余代码合并重复逻辑 * feat(behavior-tree-editor): 完善编辑器核心功能增强扩展性 * fix(lint): 修复ESLint错误确保CI通过 * refactor(behavior-tree-editor): 优化编辑器工具栏和编译器功能 * refactor(behavior-tree-editor): 清理技术债务,优化代码质量 * fix(editor-app): 修复字符串替换安全问题
2025-11-18 14:46:51 +08:00
import { useState, useEffect } from 'react';
import { X, Package, GitPullRequest, ExternalLink, RefreshCw, Trash2, CheckCircle, XCircle, AlertCircle, Clock, MessageSquare, User, Upload, Plus } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { open } from '@tauri-apps/plugin-shell';
import type { GitHubService, PublishedPlugin, PendingReview, CheckStatus, PRComment, PRReview } from '../services/GitHubService';
import { PluginUpdateDialog } from './PluginUpdateDialog';
import { PluginPublishWizard } from './PluginPublishWizard';
import '../styles/UserDashboard.css';
interface UserDashboardProps {
githubService: GitHubService;
onClose: () => void;
locale: string;
}
type Tab = 'published' | 'pending';
type PRFilter = 'all' | 'open' | 'merged' | 'closed';
export function UserDashboard({ githubService, onClose, locale }: UserDashboardProps) {
const [activeTab, setActiveTab] = useState<Tab>('published');
const [publishedPlugins, setPublishedPlugins] = useState<PublishedPlugin[]>([]);
const [pendingReviews, setPendingReviews] = useState<PendingReview[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [deletingPR, setDeletingPR] = useState<number | null>(null);
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const [prFilter, setPRFilter] = useState<PRFilter>('open');
const [expandedComments, setExpandedComments] = useState<Set<number>>(new Set());
const [confirmDeletePlugin, setConfirmDeletePlugin] = useState<PublishedPlugin | null>(null);
const [deleteReason, setDeleteReason] = useState('');
const [deletingPlugin, setDeletingPlugin] = useState(false);
const [deleteProgress, setDeleteProgress] = useState({ message: '', progress: 0 });
const [resolvingConflicts, setResolvingConflicts] = useState<number | null>(null);
const [recreatingPR, setRecreatingPR] = useState<number | null>(null);
const [pluginToUpdate, setPluginToUpdate] = useState<PublishedPlugin | null>(null);
const [showPublishWizard, setShowPublishWizard] = useState(false);
const [expandedVersions, setExpandedVersions] = useState<Set<string>>(new Set());
const user = githubService.getUser();
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
zh: {
title: '个人中心',
published: '已发布插件',
pending: '审核中',
refresh: '刷新',
loading: '加载中...',
error: '加载失败',
retry: '重试',
loadError: '无法加载数据',
networkError: '可能是网络连接问题,请检查您的网络设置后重试',
noPublished: '还没有发布任何插件',
noPending: '没有待审核的插件',
version: '版本',
latestVersion: '最新版本',
allVersions: '所有版本',
showVersions: '显示版本历史',
hideVersions: '隐藏版本历史',
versionCount: '{{count}} 个版本',
category: '分类',
publishedAt: '发布于',
createdAt: '提交于',
status: '状态',
viewPR: '查看PR',
viewRepo: '查看仓库',
statusOpen: '审核中',
statusMerged: '已合并',
statusClosed: '已关闭',
deletePR: '删除PR',
confirmDeleteTitle: '确认删除',
confirmDeleteMessage: '确定要关闭并删除这个 Pull Request 吗?此操作无法撤销。',
confirm: '确认',
cancel: '取消',
deleting: '删除中...',
deleteSuccess: '删除成功',
deleteFailed: '删除失败',
filterAll: '全部',
filterOpen: '进行中',
filterMerged: '已合并',
filterClosed: '已关闭',
ciChecks: 'CI检查',
ciPassing: '通过',
ciFailed: '失败',
ciPending: '等待中',
viewDetails: '查看详情',
comments: '评论',
reviews: '审查意见',
reviewApproved: '已批准',
reviewChangesRequested: '需要修改',
reviewCommented: '已评论',
noCommentsYet: '暂无评论',
showComments: '显示评论',
hideComments: '隐藏评论',
updatePlugin: '更新插件',
publishNewPlugin: '发布新插件',
cannotUpdate: '无法更新',
hasPendingPR: '该插件有待审核的 PR',
pleaseDealWithPR: '请先处理现有的 PR #{{number}},然后再提交新的更新。',
deletePlugin: '删除插件',
confirmDeletePluginTitle: '确认删除插件',
confirmDeletePluginMessage: '确定要删除插件 "{{name}}" 吗这将创建一个删除请求PR需要审核后才会从市场移除。',
deleteReasonLabel: '删除原因(必填)',
deleteReasonPlaceholder: '请说明删除此插件的原因...',
confirmDelete: '确认删除',
deletePluginError: '删除插件失败',
deletePluginSuccess: '删除请求已提交!',
openPRQuestion: '是否打开PR查看详情',
deleteConflictError: '删除时发生冲突,可能是因为分支版本不一致。',
retryDeleteQuestion: '是否重试?(将删除旧分支并重新创建)',
deletePending: '删除请求中',
conflicts: '冲突',
conflictsDetected: '检测到冲突',
conflictFilesLabel: '冲突文件',
resolveConflicts: '解决冲突',
resolving: '解决中...',
resolveConflictsSuccess: '冲突已解决!正在刷新...',
resolveConflictsFailed: '解决冲突失败',
resolveConflictsHint: '点击此按钮将主分支的最新代码合并到此PR可能会自动解决冲突',
manualResolveRequired: '此冲突需要手动解决',
manualResolveHint: '自动合并失败,存在代码冲突。',
openInGitHub: '在GitHub上解决',
manualResolveInstructions: '请点击下面的按钮在GitHub上手动解决冲突或者在本地克隆仓库后解决。',
recreatePR: '重新创建PR',
recreatePRHint: '删除当前分支并基于最新主分支重新创建删除请求(推荐)',
recreatePRConfirm: '确定要关闭当前PR并重新创建吗\n\n这将\n1. 关闭当前PR\n2. 删除冲突的分支\n3. 基于最新主分支重新创建删除请求\n\n删除原因会保留。',
recreating: '重新创建中...',
recreatePRSuccess: '已重新创建删除请求!',
recreatePRFailed: '重新创建失败'
},
en: {
title: 'User Dashboard',
published: 'Published Plugins',
pending: 'Pending Reviews',
refresh: 'Refresh',
loading: 'Loading...',
error: 'Failed to load',
retry: 'Retry',
loadError: 'Unable to load data',
networkError: 'This might be a network connection issue. Please check your network settings and try again',
noPublished: 'No published plugins yet',
noPending: 'No pending reviews',
version: 'Version',
latestVersion: 'Latest Version',
allVersions: 'All Versions',
showVersions: 'Show Version History',
hideVersions: 'Hide Version History',
versionCount: '{{count}} versions',
category: 'Category',
publishedAt: 'Published at',
createdAt: 'Submitted at',
status: 'Status',
viewPR: 'View PR',
viewRepo: 'View Repository',
statusOpen: 'Open',
statusMerged: 'Merged',
statusClosed: 'Closed',
deletePR: 'Delete PR',
confirmDeleteTitle: 'Confirm Delete',
confirmDeleteMessage: 'Are you sure you want to close and delete this Pull Request? This action cannot be undone.',
confirm: 'Confirm',
cancel: 'Cancel',
deleting: 'Deleting...',
deleteSuccess: 'Deleted successfully',
deleteFailed: 'Failed to delete',
filterAll: 'All',
filterOpen: 'Open',
filterMerged: 'Merged',
filterClosed: 'Closed',
ciChecks: 'CI Checks',
ciPassing: 'Passing',
ciFailed: 'Failed',
ciPending: 'Pending',
viewDetails: 'View Details',
comments: 'Comments',
reviews: 'Reviews',
reviewApproved: 'Approved',
reviewChangesRequested: 'Changes Requested',
reviewCommented: 'Commented',
noCommentsYet: 'No comments yet',
showComments: 'Show Comments',
hideComments: 'Hide Comments',
updatePlugin: 'Update Plugin',
publishNewPlugin: 'Publish New Plugin',
cannotUpdate: 'Cannot Update',
hasPendingPR: 'This plugin has a pending PR',
pleaseDealWithPR: 'Please handle the existing PR #{{number}} before submitting a new update.',
deletePlugin: 'Delete Plugin',
confirmDeletePluginTitle: 'Confirm Plugin Deletion',
confirmDeletePluginMessage: 'Are you sure you want to delete plugin "{{name}}"? This will create a deletion request PR that requires review before removal from marketplace.',
deleteReasonLabel: 'Reason for Deletion (Required)',
deleteReasonPlaceholder: 'Please explain why you want to delete this plugin...',
confirmDelete: 'Confirm Delete',
deletePluginError: 'Failed to delete plugin',
deletePluginSuccess: 'Deletion request submitted!',
openPRQuestion: 'Open PR to view details?',
deleteConflictError: 'Conflict occurred during deletion, possibly due to branch version mismatch.',
retryDeleteQuestion: 'Retry? (Will delete old branch and recreate)',
deletePending: 'Deletion Pending',
conflicts: 'Conflicts',
conflictsDetected: 'Conflicts Detected',
conflictFilesLabel: 'Conflict Files',
resolveConflicts: 'Resolve Conflicts',
resolving: 'Resolving...',
resolveConflictsSuccess: 'Conflicts resolved! Refreshing...',
resolveConflictsFailed: 'Failed to resolve conflicts',
resolveConflictsHint: 'Click to merge latest changes from main branch into this PR, which may automatically resolve conflicts',
manualResolveRequired: 'Manual resolution required',
manualResolveHint: 'Automatic merge failed due to code conflicts.',
openInGitHub: 'Resolve on GitHub',
manualResolveInstructions: 'Please click the button below to resolve conflicts on GitHub, or clone the repository locally.',
recreatePR: 'Recreate PR',
recreatePRHint: 'Delete current branch and recreate deletion request based on latest main branch (Recommended)',
recreatePRConfirm: 'Are you sure you want to close current PR and recreate?\n\nThis will:\n1. Close current PR\n2. Delete conflicting branch\n3. Recreate deletion request based on latest main branch\n\nDeletion reason will be preserved.',
recreating: 'Recreating...',
recreatePRSuccess: 'Deletion request recreated!',
recreatePRFailed: 'Failed to recreate'
}
};
return translations[locale]?.[key] || translations.en?.[key] || key;
};
const loadData = async () => {
setLoading(true);
setError('');
try {
const [published, pending] = await Promise.all([
githubService.getPublishedPlugins(),
githubService.getPendingReviews()
]);
setPublishedPlugins(published);
setPendingReviews(pending);
} catch (err) {
console.error('[UserDashboard] Failed to load data:', err);
setError(err instanceof Error ? err.message : 'Failed to load data');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString(locale === 'zh' ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const handleUpdatePlugin = (plugin: PublishedPlugin) => {
setPluginToUpdate(plugin);
};
const getStatusBadgeClass = (status: string) => {
switch (status) {
case 'open':
return 'status-badge status-open';
case 'merged':
return 'status-badge status-merged';
case 'closed':
return 'status-badge status-closed';
default:
return 'status-badge';
}
};
const handleDeletePlugin = async () => {
if (!confirmDeletePlugin || !deleteReason.trim()) {
return;
}
setDeletingPlugin(true);
setDeleteProgress({ message: '', progress: 0 });
try {
const { PluginPublishService } = await import('../services/PluginPublishService');
const publishService = new PluginPublishService(githubService);
publishService.setProgressCallback((progress) => {
setDeleteProgress({ message: progress.message, progress: progress.progress });
});
const prUrl = await publishService.deletePlugin(
confirmDeletePlugin.id,
confirmDeletePlugin.name,
confirmDeletePlugin.category_type as 'official' | 'community',
deleteReason
);
console.log(`[UserDashboard] Delete PR created:`, prUrl);
setConfirmDeletePlugin(null);
setDeleteReason('');
await loadData();
if (confirm(t('deletePluginSuccess') + '\n\n' + t('openPRQuestion'))) {
open(prUrl);
}
} catch (err) {
console.error('[UserDashboard] Failed to delete plugin:', err);
const errorMsg = err instanceof Error ? err.message : String(err);
if (errorMsg.includes('conflicts') || errorMsg.includes('does not match')) {
if (confirm(t('deleteConflictError') + '\n\n' + t('retryDeleteQuestion'))) {
setDeletingPlugin(false);
setDeleteProgress({ message: '', progress: 0 });
setTimeout(async () => {
setDeletingPlugin(true);
try {
const { PluginPublishService } = await import('../services/PluginPublishService');
const publishService = new PluginPublishService(githubService);
publishService.setProgressCallback((progress) => {
setDeleteProgress({ message: progress.message, progress: progress.progress });
});
const prUrl = await publishService.deletePlugin(
confirmDeletePlugin.id,
confirmDeletePlugin.name,
confirmDeletePlugin.category_type as 'official' | 'community',
deleteReason,
true
);
setConfirmDeletePlugin(null);
setDeleteReason('');
await loadData();
if (confirm(t('deletePluginSuccess') + '\n\n' + t('openPRQuestion'))) {
open(prUrl);
}
} catch (retryErr) {
console.error('[UserDashboard] Retry failed:', retryErr);
alert(t('deletePluginError') + ': ' + (retryErr instanceof Error ? retryErr.message : String(retryErr)));
} finally {
setDeletingPlugin(false);
}
}, 100);
return;
}
}
alert(t('deletePluginError') + ': ' + errorMsg);
} finally {
setDeletingPlugin(false);
}
};
const handleDeletePR = async (prNumber: number) => {
setDeletingPR(prNumber);
try {
await githubService.closePullRequest('esengine', 'ecs-editor-plugins', prNumber);
console.log(`[UserDashboard] Successfully closed PR #${prNumber}`);
await loadData();
setConfirmDelete(null);
} catch (err) {
console.error('[UserDashboard] Failed to close PR:', err);
setError(t('deleteFailed') + ': ' + (err instanceof Error ? err.message : String(err)));
} finally {
setDeletingPR(null);
}
};
const handleResolveConflicts = async (review: PendingReview) => {
setResolvingConflicts(review.prNumber);
try {
await githubService.updatePRBranch('esengine', 'ecs-editor-plugins', review.prNumber);
console.log(`[UserDashboard] Successfully resolved conflicts for PR #${review.prNumber}`);
alert(t('resolveConflictsSuccess'));
await loadData();
} catch (err) {
console.error('[UserDashboard] Failed to resolve conflicts:', err);
const errorMsg = err instanceof Error ? err.message : String(err);
if (errorMsg.includes('422') || errorMsg.includes('merge conflict')) {
const message = `${t('manualResolveRequired')}\n\n${t('manualResolveHint')}\n${t('manualResolveInstructions')}\n\n${t('openInGitHub')}?`;
if (confirm(message)) {
open(review.prUrl);
}
} else {
alert(t('resolveConflictsFailed') + ': ' + errorMsg);
}
} finally {
setResolvingConflicts(null);
}
};
const handleRecreatePR = async (review: PendingReview) => {
if (!confirm(t('recreatePRConfirm'))) {
return;
}
const removeMatch = review.pluginName.match(/^(.+)$/);
if (!removeMatch) {
alert(t('recreatePRFailed') + ': Cannot extract plugin name');
return;
}
const pluginName = removeMatch[0];
const plugin = publishedPlugins.find(p => p.name === pluginName);
if (!plugin) {
alert(t('recreatePRFailed') + ': Plugin not found in published list');
return;
}
setRecreatingPR(review.prNumber);
try {
await githubService.closePullRequest('esengine', 'ecs-editor-plugins', review.prNumber);
console.log(`[UserDashboard] Closed PR #${review.prNumber}`);
if (review.headBranch && review.headRepo) {
const [repoOwner, repoName] = review.headRepo.split('/');
if (repoOwner && repoName) {
try {
await githubService.deleteBranch(repoOwner, repoName, review.headBranch);
console.log(`[UserDashboard] Deleted branch ${review.headBranch}`);
} catch (err) {
console.warn('[UserDashboard] Failed to delete branch:', err);
}
}
}
const { PluginPublishService } = await import('../services/PluginPublishService');
const publishService = new PluginPublishService(githubService);
const deleteReason = 'Recreated due to conflicts';
const prUrl = await publishService.deletePlugin(
plugin.id,
plugin.name,
plugin.category_type as 'official' | 'community',
deleteReason,
true
);
console.log(`[UserDashboard] Recreated delete PR:`, prUrl);
alert(t('recreatePRSuccess'));
await loadData();
if (confirm(t('openPRQuestion'))) {
open(prUrl);
}
} catch (err) {
console.error('[UserDashboard] Failed to recreate PR:', err);
alert(t('recreatePRFailed') + ': ' + (err instanceof Error ? err.message : String(err)));
} finally {
setRecreatingPR(null);
}
};
const getCheckIcon = (check: CheckStatus) => {
if (!check.conclusion || check.conclusion === 'pending') {
return <Clock size={16} className="check-icon-pending" />;
}
if (check.conclusion === 'success') {
return <CheckCircle size={16} className="check-icon-success" />;
}
return <XCircle size={16} className="check-icon-failure" />;
};
const getCheckClassName = (check: CheckStatus) => {
if (!check.conclusion || check.conclusion === 'pending') {
return 'check-status check-pending';
}
if (check.conclusion === 'success') {
return 'check-status check-success';
}
return 'check-status check-failure';
};
const getFilteredReviews = () => {
if (prFilter === 'all') {
return pendingReviews;
}
return pendingReviews.filter(review => review.status === prFilter);
};
const handleLinkClick = (href: string) => (e: React.MouseEvent) => {
e.preventDefault();
open(href).catch(err => {
console.error('[UserDashboard] Failed to open link:', err);
});
};
const MarkdownLink = ({ href, children }: { href?: string; children: React.ReactNode }) => {
if (!href) return <a>{children}</a>;
return (
<a href={href} onClick={handleLinkClick(href)} className="markdown-link">
{children}
</a>
);
};
const toggleComments = (prNumber: number) => {
const newExpanded = new Set(expandedComments);
if (newExpanded.has(prNumber)) {
newExpanded.delete(prNumber);
} else {
newExpanded.add(prNumber);
}
setExpandedComments(newExpanded);
};
const getReviewStateLabel = (state: string) => {
switch (state) {
case 'APPROVED':
return t('reviewApproved');
case 'CHANGES_REQUESTED':
return t('reviewChangesRequested');
case 'COMMENTED':
return t('reviewCommented');
default:
return state;
}
};
const getReviewStateClass = (state: string) => {
switch (state) {
case 'APPROVED':
return 'review-state-approved';
case 'CHANGES_REQUESTED':
return 'review-state-changes';
case 'COMMENTED':
return 'review-state-commented';
default:
return '';
}
};
const renderPublishedPlugins = () => {
if (loading) {
return <div className="dashboard-loading">{t('loading')}</div>;
}
if (error) {
return (
<div className="dashboard-error-container">
<AlertCircle size={48} className="error-icon" />
<h3>{t('loadError')}</h3>
<p className="error-description">{t('networkError')}</p>
<div className="error-details">
<p className="error-message">{error}</p>
</div>
<button className="retry-button" onClick={loadData}>
<RefreshCw size={16} />
{t('retry')}
</button>
</div>
);
}
if (publishedPlugins.length === 0) {
return <div className="dashboard-empty">{t('noPublished')}</div>;
}
return (
<div className="plugin-list">
{publishedPlugins.map((plugin) => {
const isExpanded = expandedVersions.has(plugin.id);
const hasMultipleVersions = plugin.versions.length > 1;
const pendingPR = pendingReviews.find(pr => pr.pluginName === plugin.name && pr.status === 'open');
return (
<div key={plugin.id} className="plugin-card">
<div className="plugin-header">
<Package size={20} />
<div className="plugin-info">
<h3 className="plugin-name">{plugin.name}</h3>
<p className="plugin-description">{plugin.description}</p>
</div>
</div>
<div className="plugin-meta">
<span className="plugin-meta-item">
{t('latestVersion')}: <strong>{plugin.latestVersion}</strong>
{hasMultipleVersions && (
<button
className="btn-version-toggle"
onClick={() => {
const newExpanded = new Set(expandedVersions);
if (isExpanded) {
newExpanded.delete(plugin.id);
} else {
newExpanded.add(plugin.id);
}
setExpandedVersions(newExpanded);
}}
title={isExpanded ? t('hideVersions') : t('showVersions')}
>
({t('versionCount').replace('{{count}}', String(plugin.versions.length))})
</button>
)}
</span>
<span className="plugin-meta-item">
{t('category')}: <strong>{plugin.category}</strong>
</span>
<span className="plugin-meta-item">
{t('publishedAt')}: {formatDate(plugin.versions[0]?.publishedAt || '')}
</span>
</div>
{/* 版本历史列表 */}
{isExpanded && hasMultipleVersions && (
<div className="version-history-list">
<h4>{t('allVersions')}</h4>
<div className="versions-container">
{plugin.versions.map((version) => (
<div key={version.version} className="version-item">
<span className="version-number">v{version.version}</span>
<span className="version-date">{formatDate(version.publishedAt)}</span>
<a
href={version.prUrl}
onClick={(e) => {
e.preventDefault();
open(version.prUrl);
}}
className="version-pr-link"
title={t('viewPR')}
>
<ExternalLink size={14} />
</a>
</div>
))}
</div>
</div>
)}
<div className="plugin-actions">
{pendingPR && (
<div className="pending-pr-badge" title={t('pleaseDealWithPR').replace('{{number}}', String(pendingPR.prNumber))}>
<AlertCircle size={14} />
<span>PR #{pendingPR.prNumber} {t('statusOpen')}</span>
</div>
)}
<button
className="btn-update"
onClick={() => handleUpdatePlugin(plugin)}
disabled={!!pendingPR}
title={pendingPR
? t('pleaseDealWithPR').replace('{{number}}', String(pendingPR.prNumber))
: t('updatePlugin')
}
>
<Upload size={14} />
{t('updatePlugin')}
</button>
{plugin.repositoryUrl && (
<a
href={plugin.repositoryUrl}
target="_blank"
rel="noopener noreferrer"
className="plugin-link"
>
{t('viewRepo')} <ExternalLink size={14} />
</a>
)}
{plugin.versions[0]?.prUrl && (
<a
href={plugin.versions[0].prUrl}
target="_blank"
rel="noopener noreferrer"
className="plugin-link"
>
{t('viewPR')} <ExternalLink size={14} />
</a>
)}
<button
className="btn-delete"
onClick={() => setConfirmDeletePlugin(plugin)}
title={t('deletePlugin')}
>
<Trash2 size={14} />
{t('deletePlugin')}
</button>
</div>
</div>
);
})}
</div>
);
};
const renderPendingReviews = () => {
if (loading) {
return <div className="dashboard-loading">{t('loading')}</div>;
}
if (error) {
return (
<div className="dashboard-error-container">
<AlertCircle size={48} className="error-icon" />
<h3>{t('loadError')}</h3>
<p className="error-description">{t('networkError')}</p>
<div className="error-details">
<p className="error-message">{error}</p>
</div>
<button className="retry-button" onClick={loadData}>
<RefreshCw size={16} />
{t('retry')}
</button>
</div>
);
}
const filteredReviews = getFilteredReviews();
if (filteredReviews.length === 0) {
return <div className="dashboard-empty">{t('noPending')}</div>;
}
return (
<>
<div className="review-filters">
<button
className={`filter-btn ${prFilter === 'all' ? 'active' : ''}`}
onClick={() => setPRFilter('all')}
>
{t('filterAll')} ({pendingReviews.length})
</button>
<button
className={`filter-btn ${prFilter === 'open' ? 'active' : ''}`}
onClick={() => setPRFilter('open')}
>
{t('filterOpen')} ({pendingReviews.filter(r => r.status === 'open').length})
</button>
<button
className={`filter-btn ${prFilter === 'merged' ? 'active' : ''}`}
onClick={() => setPRFilter('merged')}
>
{t('filterMerged')} ({pendingReviews.filter(r => r.status === 'merged').length})
</button>
<button
className={`filter-btn ${prFilter === 'closed' ? 'active' : ''}`}
onClick={() => setPRFilter('closed')}
>
{t('filterClosed')} ({pendingReviews.filter(r => r.status === 'closed').length})
</button>
</div>
<div className="review-list">
{filteredReviews.map((review) => (
<div key={review.prNumber} className="review-card">
<div className="review-header">
<GitPullRequest size={20} />
<div className="review-info">
<h3 className="review-name">
{review.pluginName} <span className="review-version">v{review.version}</span>
</h3>
<span className={getStatusBadgeClass(review.status)}>
{t(`status${review.status.charAt(0).toUpperCase()}${review.status.slice(1)}`)}
</span>
</div>
</div>
<div className="review-meta">
<span className="review-meta-item">
PR #{review.prNumber}
</span>
<span className="review-meta-item">
{t('createdAt')}: {formatDate(review.createdAt)}
</span>
</div>
{review.hasConflicts && (
<div className="review-conflicts">
<div className="conflicts-header">
<AlertCircle size={16} className="conflicts-icon" />
{t('conflictsDetected')}
</div>
{review.conflictFiles && review.conflictFiles.length > 0 && (
<div className="conflicts-files">
<div className="conflicts-files-label">{t('conflictFilesLabel')}:</div>
<ul className="conflicts-files-list">
{review.conflictFiles.map((file, index) => (
<li key={index} className="conflict-file-item">{file}</li>
))}
</ul>
</div>
)}
<div className="conflicts-actions">
<button
className="resolve-conflicts-btn"
onClick={() => handleResolveConflicts(review)}
disabled={resolvingConflicts === review.prNumber || recreatingPR === review.prNumber}
title={t('resolveConflictsHint')}
>
{resolvingConflicts === review.prNumber ? t('resolving') : t('resolveConflicts')}
</button>
<button
className="recreate-pr-btn"
onClick={() => handleRecreatePR(review)}
disabled={recreatingPR === review.prNumber || resolvingConflicts === review.prNumber}
title={t('recreatePRHint')}
>
{recreatingPR === review.prNumber ? t('recreating') : t('recreatePR')}
</button>
<button
className="open-github-btn"
onClick={handleLinkClick(review.prUrl)}
title={t('manualResolveInstructions')}
disabled={recreatingPR === review.prNumber || resolvingConflicts === review.prNumber}
>
<ExternalLink size={14} />
{t('openInGitHub')}
</button>
</div>
</div>
)}
{review.checks && review.checks.length > 0 && (
<div className="review-checks">
<div className="checks-header">{t('ciChecks')}:</div>
<div className="checks-list">
{review.checks.map((check, index) => (
<div key={index} className={getCheckClassName(check)}>
{getCheckIcon(check)}
<span className="check-name">{check.name}</span>
{check.detailsUrl && (
<a
href={check.detailsUrl}
target="_blank"
rel="noopener noreferrer"
className="check-details-link"
>
{t('viewDetails')}
</a>
)}
</div>
))}
</div>
</div>
)}
{((review.reviews && review.reviews.length > 0) || (review.comments && review.comments.length > 0)) && (
<div className="review-feedback">
<button
className="feedback-toggle-btn"
onClick={() => toggleComments(review.prNumber)}
>
<MessageSquare size={14} />
{expandedComments.has(review.prNumber) ? t('hideComments') : t('showComments')}
{' '}({(review.reviews?.length || 0) + (review.comments?.length || 0)})
</button>
{expandedComments.has(review.prNumber) && (
<div className="feedback-content">
{review.reviews && review.reviews.length > 0 && (
<div className="reviews-section">
<div className="section-header">{t('reviews')}:</div>
{review.reviews.map((reviewItem) => (
<div key={reviewItem.id} className="review-item">
<div className="review-item-header">
<img
src={reviewItem.user.avatar_url}
alt={reviewItem.user.login}
className="reviewer-avatar"
/>
<div className="reviewer-info">
<span className="reviewer-name">{reviewItem.user.login}</span>
<span className={`review-state-badge ${getReviewStateClass(reviewItem.state)}`}>
{getReviewStateLabel(reviewItem.state)}
</span>
</div>
<span className="review-date">
{formatDate(reviewItem.submitted_at)}
</span>
</div>
<div className="review-item-body markdown-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
a: MarkdownLink as any
}}
>
{reviewItem.body}
</ReactMarkdown>
</div>
</div>
))}
</div>
)}
{review.comments && review.comments.length > 0 && (
<div className="comments-section">
<div className="section-header">{t('comments')}:</div>
{review.comments.map((comment) => (
<div key={comment.id} className="comment-item">
<div className="comment-item-header">
<img
src={comment.user.avatar_url}
alt={comment.user.login}
className="commenter-avatar"
/>
<div className="commenter-info">
<span className="commenter-name">{comment.user.login}</span>
<span className="comment-date">
{formatDate(comment.created_at)}
</span>
</div>
</div>
<div className="comment-item-body markdown-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
a: MarkdownLink as any
}}
>
{comment.body}
</ReactMarkdown>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
<div className="review-actions">
<a href={review.prUrl} target="_blank" rel="noopener noreferrer" className="review-link">
{t('viewPR')} <ExternalLink size={14} />
</a>
{review.status === 'open' && (
<button
className="review-delete-btn"
onClick={() => setConfirmDelete(review.prNumber)}
disabled={deletingPR === review.prNumber}
title={t('deletePR')}
>
<Trash2 size={14} />
{deletingPR === review.prNumber ? t('deleting') : t('deletePR')}
</button>
)}
</div>
</div>
))}
</div>
</>
);
};
return (
<div className="user-dashboard-overlay">
<div className="user-dashboard">
<div className="dashboard-header">
<h2 className="dashboard-title">{t('title')}</h2>
<div className="dashboard-header-actions">
<button className="dashboard-publish-btn" onClick={() => setShowPublishWizard(true)}>
<Plus size={16} />
{t('publishNewPlugin')}
</button>
<button className="dashboard-refresh-btn" onClick={loadData} disabled={loading}>
<RefreshCw size={16} className={loading ? 'spinning' : ''} />
{t('refresh')}
</button>
<button className="dashboard-close-btn" onClick={onClose}>
<X size={20} />
</button>
</div>
</div>
{user && (
<div className="dashboard-user-info">
<img src={user.avatar_url} alt={user.name} className="dashboard-user-avatar" />
<div className="dashboard-user-details">
<div className="dashboard-user-name">{user.name || user.login}</div>
<div className="dashboard-user-login">@{user.login}</div>
</div>
</div>
)}
<div className="dashboard-tabs">
<button
className={`dashboard-tab ${activeTab === 'published' ? 'active' : ''}`}
onClick={() => setActiveTab('published')}
>
<Package size={16} />
{t('published')} ({publishedPlugins.length})
</button>
<button
className={`dashboard-tab ${activeTab === 'pending' ? 'active' : ''}`}
onClick={() => setActiveTab('pending')}
>
<GitPullRequest size={16} />
{t('pending')} ({pendingReviews.filter((r) => r.status === 'open').length})
</button>
</div>
<div className="dashboard-content">
{activeTab === 'published' ? renderPublishedPlugins() : renderPendingReviews()}
</div>
{confirmDelete && (
<div className="confirm-dialog-overlay">
<div className="confirm-dialog">
<h3>{t('confirmDeleteTitle')}</h3>
<p>{t('confirmDeleteMessage')}</p>
<div className="confirm-dialog-actions">
<button
className="confirm-dialog-cancel"
onClick={() => setConfirmDelete(null)}
disabled={deletingPR !== null}
>
{t('cancel')}
</button>
<button
className="confirm-dialog-confirm"
onClick={() => handleDeletePR(confirmDelete)}
disabled={deletingPR !== null}
>
{deletingPR === confirmDelete ? t('deleting') : t('confirm')}
</button>
</div>
</div>
</div>
)}
{confirmDeletePlugin && (
<div className="confirm-dialog-overlay">
<div className="confirm-dialog">
<h3>{t('confirmDeletePluginTitle')}</h3>
<p>{t('confirmDeletePluginMessage').replace('{{name}}', confirmDeletePlugin.name)}</p>
<div className="confirm-dialog-input-group">
<label htmlFor="delete-reason">{t('deleteReasonLabel')}</label>
<textarea
id="delete-reason"
className="confirm-dialog-textarea"
value={deleteReason}
onChange={(e) => setDeleteReason(e.target.value)}
placeholder={t('deleteReasonPlaceholder')}
rows={4}
disabled={deletingPlugin}
/>
</div>
{deletingPlugin && (
<div className="confirm-dialog-progress">
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${deleteProgress.progress}%` }}></div>
</div>
<p className="progress-message">{deleteProgress.message}</p>
</div>
)}
<div className="confirm-dialog-actions">
<button
className="confirm-dialog-cancel"
onClick={() => {
setConfirmDeletePlugin(null);
setDeleteReason('');
}}
disabled={deletingPlugin}
>
{t('cancel')}
</button>
<button
className="confirm-dialog-confirm confirm-dialog-danger"
onClick={handleDeletePlugin}
disabled={deletingPlugin || !deleteReason.trim()}
>
{deletingPlugin ? t('deleting') : t('confirmDelete')}
</button>
</div>
</div>
</div>
)}
{pluginToUpdate && (
<PluginUpdateDialog
plugin={pluginToUpdate}
githubService={githubService}
onClose={() => setPluginToUpdate(null)}
onSuccess={() => {
loadData();
}}
locale={locale}
/>
)}
{showPublishWizard && (
<PluginPublishWizard
githubService={githubService}
onClose={() => setShowPublishWizard(false)}
locale={locale}
inline={false}
/>
)}
</div>
</div>
);
}