Files
upage-git/app/.client/components/header/connections/components/PushToGitHubDialog.tsx

700 lines
31 KiB
TypeScript

import { Octokit } from '@octokit/rest';
import * as Dialog from '@radix-ui/react-dialog';
import classNames from 'classnames';
import { motion } from 'framer-motion';
import React, { Suspense, useEffect, useState } from 'react';
import { toast } from 'sonner';
import { getLocalStorage } from '~/.client/persistence';
import { webBuilderStore } from '~/.client/stores/web-builder';
import { formatSize } from '~/.client/utils/format';
import { logStore } from '~/stores/logs';
import type { GitHubUserResponse } from '~/types/github';
import { logger } from '~/utils/logger';
const GitHubConnection = React.lazy(() => import('~/.client/components/header/connections/GithubConnection'));
interface PushToGitHubDialogProps {
isOpen: boolean;
onClose: () => void;
onPush: (repoName: string, username?: string, token?: string, isPrivate?: boolean) => Promise<string>;
}
interface GitHubRepo {
name: string;
full_name: string;
html_url: string;
description: string;
stargazers_count: number;
forks_count: number;
default_branch: string;
updated_at: string;
language: string;
private: boolean;
}
export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDialogProps) {
const [repoName, setRepoName] = useState('');
const [isPrivate, setIsPrivate] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [user, setUser] = useState<GitHubUserResponse | null>(null);
const [recentRepos, setRecentRepos] = useState<GitHubRepo[]>([]);
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
const [createdRepoUrl, setCreatedRepoUrl] = useState('');
const [pushedFiles, setPushedFiles] = useState<{ path: string; size: number }[]>([]);
const [isGitHubConnected, setIsGitHubConnected] = useState(false);
const [showConnectionForm, setShowConnectionForm] = useState(false);
// Load GitHub connection on mount
useEffect(() => {
if (isOpen) {
loadGitHubConnection();
}
}, [isOpen, isGitHubConnected]);
const loadGitHubConnection = () => {
const connection = getLocalStorage('github_connection');
if (connection?.user && connection?.token) {
setUser(connection.user);
setShowConnectionForm(false);
// Only fetch if we have both user and token
if (connection.token.trim()) {
fetchRecentRepos(connection.token);
}
} else {
setShowConnectionForm(true);
}
};
// 添加检测 GitHub 连接变化的 useEffect
useEffect(() => {
// 监听 localStorage 变化
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'github_connection' && e.newValue) {
try {
const connection = JSON.parse(e.newValue);
if (connection?.user && connection?.token) {
setIsGitHubConnected(true);
loadGitHubConnection();
}
} catch (error) {
logger.error('Error parsing github_connection from storage event:', error);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, []);
// 检查 localStorage 变化的函数,在内部组件触发
const checkGitHubConnection = () => {
const connection = getLocalStorage('github_connection');
if (connection?.user && connection?.token) {
setIsGitHubConnected(true);
setShowConnectionForm(false);
loadGitHubConnection();
}
};
const fetchRecentRepos = async (token: string) => {
if (!token) {
logStore.logError('No GitHub token available');
toast.error('GitHub 认证失败');
return;
}
try {
setIsFetchingRepos(true);
const response = await fetch(
'https://api.github.com/user/repos?sort=updated&per_page=5&affiliation=owner,organization_member',
{
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token.trim()}`,
},
},
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
if (response.status === 401) {
toast.error('GitHub 令牌已过期。请重新连接您的账户。');
// Clear invalid token
const connection = getLocalStorage('github_connection');
if (connection) {
localStorage.removeItem('github_connection');
setUser(null);
}
} else {
logStore.logError('Failed to fetch GitHub repositories', {
status: response.status,
statusText: response.statusText,
error: errorData,
});
toast.error(`无法获取 GitHub 仓库: ${response.statusText}`);
}
return;
}
const repos = (await response.json()) as GitHubRepo[];
setRecentRepos(repos);
} catch (error) {
logStore.logError('Failed to fetch GitHub repositories', { error });
toast.error('无法获取最近仓库');
} finally {
setIsFetchingRepos(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const connection = getLocalStorage('github_connection');
if (!connection?.token || !connection?.user) {
setShowConnectionForm(true);
return;
}
if (!repoName.trim()) {
toast.error('仓库名称是必需的');
return;
}
setIsLoading(true);
try {
// Check if repository exists first
const octokit = new Octokit({ auth: connection.token });
try {
await octokit.repos.get({
owner: connection.user.login,
repo: repoName,
});
// If we get here, the repo exists
const confirmOverwrite = window.confirm(
`仓库 "${repoName}" 已存在。是否要更新它?这将添加或修改仓库中的文件。`,
);
if (!confirmOverwrite) {
setIsLoading(false);
return;
}
} catch (error) {
// 404 means repo doesn't exist, which is what we want for new repos
if (error instanceof Error && 'status' in error && error.status !== 404) {
throw error;
}
}
const repoUrl = await onPush(repoName, connection.user.login, connection.token, isPrivate);
setCreatedRepoUrl(repoUrl);
// Get list of pushed files
const files = await webBuilderStore.getProjectFilesAsMap({
inline: false,
});
const filesList = Object.entries(files).map(([path, content]) => ({
path,
size: new TextEncoder().encode(content).length,
}));
setPushedFiles(filesList);
setShowSuccessDialog(true);
} catch (error) {
logger.error('Error pushing to GitHub:', error);
toast.error('推送失败,请检查仓库名称并重试。');
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
setRepoName('');
setIsPrivate(false);
setShowSuccessDialog(false);
setCreatedRepoUrl('');
setShowConnectionForm(false);
onClose();
};
const handleSwitchAccount = () => {
setShowConnectionForm(true);
};
const handleDisconnect = () => {
// 清除 localStorage
localStorage.removeItem('github_connection');
// 清除 cookie
document.cookie = 'githubToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
document.cookie = 'githubUsername=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
document.cookie = 'git:github.com=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
// 更新状态
setUser(null);
setShowConnectionForm(true);
toast.success('已断开与 GitHub 的连接');
};
// Success Dialog
if (showSuccessDialog) {
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Portal>
<div className="fixed inset-0 flex items-center justify-center z-[100]">
<Dialog.Overlay asChild>
<motion.div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>
</Dialog.Overlay>
<Dialog.Content
aria-describedby={undefined}
onEscapeKeyDown={handleClose}
onPointerDownOutside={handleClose}
className="relative z-[101]"
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
className="w-[90vw] md:w-[600px] my-4"
>
<div className="bg-white dark:bg-[#1E1E1E] rounded-lg border border-[#E5E5E5] dark:border-[#333333] shadow-xl max-h-[calc(85vh-2rem)] flex flex-col">
<div className="p-6 overflow-y-auto flex-1 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-green-500">
<div className="i-ph:check-circle size-5" />
<h3 className="text-lg font-medium"> GitHub</h3>
</div>
<Dialog.Close
onClick={handleClose}
className="flex items-center justify-center size-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
>
<div className="i-ph:x size-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</Dialog.Close>
</div>
<div className="bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-3 rounded-lg p-3 text-left">
<p className="text-xs text-upage-elements-textSecondary dark:text-upage-elements-textSecondary-dark mb-2">
</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-sm bg-upage-elements-background dark:bg-upage-elements-background-dark px-3 py-2 rounded border border-upage-elements-borderColor dark:border-upage-elements-borderColor-dark text-upage-elements-textPrimary dark:text-upage-elements-textPrimary-dark font-mono">
{createdRepoUrl}
</code>
<motion.button
onClick={() => {
navigator.clipboard.writeText(createdRepoUrl);
toast.success('URL 已复制到剪贴板');
}}
className="p-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm inline-flex items-center gap-2"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<div className="i-ph:copy size-4" />
</motion.button>
</div>
</div>
<div className="bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-3 rounded-lg p-3">
<p className="text-xs text-upage-elements-textSecondary dark:text-upage-elements-textSecondary-dark mb-2">
({pushedFiles.length})
</p>
<div className="max-h-[200px] overflow-y-auto custom-scrollbar">
{pushedFiles.map((file) => (
<div
key={file.path}
className="flex items-center justify-between py-1 text-sm text-upage-elements-textPrimary dark:text-upage-elements-textPrimary-dark"
>
<span className="font-mono truncate flex-1">{file.path}</span>
<span className="text-xs text-upage-elements-textSecondary dark:text-upage-elements-textSecondary-dark ml-2">
{formatSize(file.size)}
</span>
</div>
))}
</div>
</div>
</div>
<div className="p-4 border-t border-[#E5E5E5] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] sticky bottom-0">
<div className="flex justify-end gap-2">
<motion.a
href={createdRepoUrl}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 text-sm inline-flex items-center gap-2"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:github-logo size-4" />
</motion.a>
<motion.button
onClick={() => {
navigator.clipboard.writeText(createdRepoUrl);
toast.success('URL 已复制到剪贴板');
}}
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm inline-flex items-center gap-2"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:copy size-4" />
URL
</motion.button>
<motion.button
onClick={handleClose}
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
</motion.button>
</div>
</div>
</div>
</motion.div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}
if (showConnectionForm) {
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Portal>
<div className="fixed inset-0 flex items-center justify-center z-[100]">
<Dialog.Overlay asChild>
<motion.div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>
</Dialog.Overlay>
<Dialog.Content
aria-describedby={undefined}
onEscapeKeyDown={handleClose}
onPointerDownOutside={handleClose}
className="relative z-[101]"
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
className="w-[90vw] md:w-[650px] my-4"
>
<div className="bg-white dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl max-h-[calc(85vh-2rem)] flex flex-col">
<div className="p-6 overflow-y-auto flex-1">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="i-ph:github-logo size-5 text-purple-500" />
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
{showConnectionForm ? 'GitHub 连接信息' : '连接 GitHub 账户'}
</h3>
</div>
<Dialog.Close
className="flex items-center justify-center size-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
onClick={handleClose}
>
<div className="i-ph:x size-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</Dialog.Close>
</div>
{!showConnectionForm && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
GitHub GitHub
</p>
)}
<div className="github-connection-wrapper">
<Suspense>
<GitHubConnection />
</Suspense>
</div>
</div>
<div className="p-4 border-t border-[#E5E5E5] dark:border-[#1A1A1A] bg-white dark:bg-[#0A0A0A] sticky bottom-0">
<div className="flex justify-end">
{isGitHubConnected || user ? (
<motion.button
onClick={() => {
setShowConnectionForm(false);
}}
className="px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600 inline-flex items-center gap-2"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:arrow-right" />
</motion.button>
) : (
<motion.button
onClick={() => {
checkGitHubConnection();
setTimeout(checkGitHubConnection, 500); // 延迟检查
}}
className="px-4 py-2 rounded-lg bg-upage-elements-item-backgroundAccent text-upage-elements-item-contentAccent text-sm hover:bg-upage-elements-item-backgroundAccent/90 inline-flex items-center gap-2"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:arrows-clockwise" />
</motion.button>
)}
</div>
</div>
</div>
</motion.div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Portal>
<div className="fixed inset-0 flex items-center justify-center z-[100]">
<Dialog.Overlay asChild>
<motion.div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>
</Dialog.Overlay>
<Dialog.Content
aria-describedby={undefined}
onEscapeKeyDown={handleClose}
onPointerDownOutside={handleClose}
className="relative z-[101]"
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
className="w-[90vw] md:w-[500px] my-4"
>
<div className="bg-white dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl max-h-[calc(85vh-2rem)] flex flex-col">
<div className="p-6 overflow-y-auto flex-1">
<div className="flex items-center gap-4 mb-6">
<motion.div
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{ delay: 0.1 }}
className="size-10 rounded-xl bg-upage-elements-background-depth-3 flex items-center justify-center text-purple-500"
>
<div className="i-ph:git-branch size-5" />
</motion.div>
<div>
<Dialog.Title className="text-lg font-medium text-gray-900 dark:text-white">
GitHub
</Dialog.Title>
<p className="text-sm text-gray-600 dark:text-gray-400"> GitHub </p>
</div>
<Dialog.Close
className="ml-auto flex items-center justify-center size-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
onClick={handleClose}
>
<div className="i-ph:x size-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
</Dialog.Close>
</div>
{user && (
<div className="flex items-center justify-between gap-3 mb-6 p-3 bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-3 rounded-lg">
<div className="flex items-center gap-3">
<img src={user?.avatar_url} alt={user?.login} className="size-10 rounded-full" />
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{user?.name || user?.login}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">@{user?.login}</p>
</div>
</div>
<div className="flex items-center gap-2">
<motion.button
onClick={handleSwitchAccount}
className="px-3 py-1.5 rounded-lg bg-gray-200 dark:bg-gray-800 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary text-sm hover:bg-gray-300 dark:hover:bg-gray-700 inline-flex items-center gap-1"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:chart-bar size-4" />
</motion.button>
<motion.button
onClick={handleDisconnect}
className="px-3 py-1.5 rounded-lg bg-red-500 text-white text-sm hover:bg-red-600 inline-flex items-center gap-1"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="i-ph:sign-out size-4" />
</motion.button>
</div>
</div>
)}
<form id="github-push-form" onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="repoName" className="text-sm text-gray-600 dark:text-gray-400">
</label>
<input
id="repoName"
type="text"
value={repoName}
onChange={(e) => setRepoName(e.target.value)}
placeholder="my-awesome-project"
className="w-full px-4 py-2 rounded-lg bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-3 border border-[#E5E5E5] dark:border-[#1A1A1A] text-gray-900 dark:text-white placeholder-gray-400"
required
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="private"
checked={isPrivate}
onChange={(e) => setIsPrivate(e.target.checked)}
className="rounded border-[#E5E5E5] dark:border-[#1A1A1A] text-purple-500 focus:ring-purple-500 dark:bg-[#0A0A0A]"
/>
<label htmlFor="private" className="text-sm text-gray-600 dark:text-gray-400">
</label>
</div>
{recentRepos.length > 0 && (
<div className="space-y-2">
<label className="text-sm text-gray-600 dark:text-gray-400"></label>
<div className="space-y-2">
{recentRepos.map((repo) => (
<motion.button
key={repo.full_name}
type="button"
onClick={() => setRepoName(repo.name)}
className="w-full p-3 text-left rounded-lg bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-3 hover:bg-upage-elements-background-depth-3 dark:hover:bg-upage-elements-background-depth-4 transition-colors group"
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="i-mingcute:github-line size-4 text-purple-500" />
<span className="text-sm font-medium text-gray-900 dark:text-white group-hover:text-purple-500">
{repo.name}
</span>
</div>
{repo.private && (
<span className="text-xs px-2 py-1 rounded-full bg-purple-500/10 text-purple-500">
Private
</span>
)}
</div>
{repo.description && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{repo.description}
</p>
)}
<div className="mt-2 flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500">
{repo.language && (
<span className="flex items-center gap-1">
<div className="i-mingcute:code-line size-3" />
{repo.language}
</span>
)}
<span className="flex items-center gap-1">
<div className="i-ph:star size-3" />
{repo.stargazers_count.toLocaleString()}
</span>
<span className="flex items-center gap-1">
<div className="i-ph:git-fork size-3" />
{repo.forks_count.toLocaleString()}
</span>
<span className="flex items-center gap-1">
<div className="i-ph:clock size-3" />
{new Date(repo.updated_at).toLocaleDateString()}
</span>
</div>
</motion.button>
))}
</div>
</div>
)}
{isFetchingRepos && (
<div className="flex items-center justify-center py-4 text-gray-500 dark:text-gray-400">
<div className="i-ph:spinner-gap-bold animate-spin size-4 mr-2" />
...
</div>
)}
</form>
</div>
<div className="p-4 border-t border-[#E5E5E5] dark:border-[#1A1A1A] bg-white dark:bg-[#0A0A0A] sticky bottom-0">
<div className="flex gap-2">
<motion.button
type="button"
onClick={handleClose}
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
</motion.button>
<motion.button
type="submit"
form="github-push-form"
disabled={isLoading}
className={classNames(
'flex-1 px-4 py-2 bg-purple-500 text-white rounded-lg hover:bg-purple-600 text-sm inline-flex items-center justify-center gap-2',
isLoading ? 'opacity-50 cursor-not-allowed' : '',
)}
whileHover={!isLoading ? { scale: 1.02 } : {}}
whileTap={!isLoading ? { scale: 0.98 } : {}}
>
{isLoading ? (
<>
<div className="i-ph:spinner-gap-bold animate-spin size-4" />
...
</>
) : (
<>
<div className="i-ph:git-branch size-4" />
GitHub
</>
)}
</motion.button>
</div>
</div>
</div>
</motion.div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}