🎉 first commit

This commit is contained in:
LIlGG
2025-09-24 13:06:25 +08:00
commit 1f4fb103e9
409 changed files with 61222 additions and 0 deletions

View File

@@ -0,0 +1,957 @@
import classNames from 'classnames';
import Cookies from 'js-cookie';
import React, { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Button } from '~/components/ui/Button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '~/components/ui/Collapsible';
import { logStore } from '~/lib/stores/logs';
import ConnectionBorder from './components/ConnectionBorder';
interface GitHubUserResponse {
login: string;
avatar_url: string;
html_url: string;
name: string;
bio: string;
public_repos: number;
followers: number;
following: number;
created_at: string;
public_gists: number;
}
interface GitHubRepoInfo {
name: string;
full_name: string;
html_url: string;
description: string;
stargazers_count: number;
forks_count: number;
default_branch: string;
updated_at: string;
languages_url: string;
}
interface GitHubOrganization {
login: string;
avatar_url: string;
html_url: string;
}
interface GitHubEvent {
id: string;
type: string;
repo: {
name: string;
};
created_at: string;
}
interface GitHubLanguageStats {
[language: string]: number;
}
interface GitHubStats {
repos: GitHubRepoInfo[];
recentActivity: GitHubEvent[];
languages: GitHubLanguageStats;
totalGists: number;
publicRepos: number;
privateRepos: number;
stars: number;
forks: number;
followers: number;
publicGists: number;
privateGists: number;
lastUpdated: string;
// Keep these for backward compatibility
totalStars?: number;
totalForks?: number;
organizations?: GitHubOrganization[];
}
interface GitHubConnection {
user: GitHubUserResponse | null;
token: string;
tokenType: 'classic' | 'fine-grained';
stats?: GitHubStats;
rateLimit?: {
limit: number;
remaining: number;
reset: number;
};
}
export default function GitHubConnection() {
const [connection, setConnection] = useState<GitHubConnection>({
user: null,
token: '',
tokenType: 'classic',
});
const [isLoading, setIsLoading] = useState(true);
const [isConnecting, setIsConnecting] = useState(false);
const [isFetchingStats, setIsFetchingStats] = useState(false);
const [isStatsExpanded, setIsStatsExpanded] = useState(false);
const tokenTypeRef = React.useRef<'classic' | 'fine-grained'>('classic');
const fetchGithubUser = async (token: string) => {
try {
console.log('正在获取 GitHub 用户,使用令牌:', token.substring(0, 5) + '...');
// Use server-side API endpoint instead of direct GitHub API call
const response = await fetch(`/api/system/git-info?action=getUser`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`, // Include token in headers for validation
},
});
if (!response.ok) {
console.error('获取 GitHub 用户时出错。状态:', response.status);
throw new Error(`错误: ${response.status}`);
}
// Get rate limit information from headers
const rateLimit = {
limit: parseInt(response.headers.get('x-ratelimit-limit') || '0'),
remaining: parseInt(response.headers.get('x-ratelimit-remaining') || '0'),
reset: parseInt(response.headers.get('x-ratelimit-reset') || '0'),
};
const data = await response.json();
console.log('GitHub 用户 API 响应:', data);
const { user } = data as { user: GitHubUserResponse };
// Validate that we received a user object
if (!user || !user.login) {
console.error('收到无效的用户数据:', user);
throw new Error('收到无效的用户数据');
}
// Use the response data
setConnection((prev) => ({
...prev,
user,
token,
tokenType: tokenTypeRef.current,
rateLimit,
}));
// Set cookies for client-side access
Cookies.set('githubUsername', user.login);
Cookies.set('githubToken', token);
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
// Store connection details in localStorage
localStorage.setItem(
'github_connection',
JSON.stringify({
user,
token,
tokenType: tokenTypeRef.current,
}),
);
logStore.logInfo('已连接到 GitHub', {
type: 'system',
message: `已连接到 GitHub用户 ${user.login}`,
});
// Fetch additional GitHub stats
fetchGitHubStats(token);
} catch (error) {
console.error('Failed to fetch GitHub user:', error);
logStore.logError(`GitHub 认证失败: ${error instanceof Error ? error.message : '未知错误'}`, {
type: 'system',
message: 'GitHub 认证失败',
});
toast.error(`认证失败: ${error instanceof Error ? error.message : '未知错误'}`);
throw error; // Rethrow to allow handling in the calling function
}
};
const fetchGitHubStats = async (token: string) => {
setIsFetchingStats(true);
try {
// Get the current user first to ensure we have the latest value
const userResponse = await fetch('https://api.github.com/user', {
headers: {
Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`,
},
});
if (!userResponse.ok) {
if (userResponse.status === 401) {
toast.error('您的 GitHub 令牌已过期。请重新连接您的账户。');
handleDisconnect();
return;
}
throw new Error(`Failed to fetch user data: ${userResponse.statusText}`);
}
const userData = (await userResponse.json()) as any;
// Fetch repositories with pagination
let allRepos: any[] = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const reposResponse = await fetch(`https://api.github.com/user/repos?per_page=100&page=${page}`, {
headers: {
Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`,
},
});
if (!reposResponse.ok) {
throw new Error(`Failed to fetch repositories: ${reposResponse.statusText}`);
}
const repos = (await reposResponse.json()) as any[];
allRepos = [...allRepos, ...repos];
// Check if there are more pages
const linkHeader = reposResponse.headers.get('Link');
hasMore = linkHeader?.includes('rel="next"') ?? false;
page++;
}
// Calculate stats
const repoStats = await calculateRepoStats(allRepos);
// Fetch recent activity
const eventsResponse = await fetch(`https://api.github.com/users/${userData.login}/events?per_page=10`, {
headers: {
Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`,
},
});
if (!eventsResponse.ok) {
throw new Error(`Failed to fetch events: ${eventsResponse.statusText}`);
}
const events = (await eventsResponse.json()) as any[];
const recentActivity = events.slice(0, 5).map((event: any) => ({
id: event.id,
type: event.type,
repo: event.repo.name,
created_at: event.created_at,
}));
// Calculate total stars and forks
const totalStars = allRepos.reduce((sum: number, repo: any) => sum + repo.stargazers_count, 0);
const totalForks = allRepos.reduce((sum: number, repo: any) => sum + repo.forks_count, 0);
const privateRepos = allRepos.filter((repo: any) => repo.private).length;
// Update the stats in the store
const stats: GitHubStats = {
repos: repoStats.repos,
recentActivity,
languages: repoStats.languages || {},
totalGists: repoStats.totalGists || 0,
publicRepos: userData.public_repos || 0,
privateRepos: privateRepos || 0,
stars: totalStars || 0,
forks: totalForks || 0,
followers: userData.followers || 0,
publicGists: userData.public_gists || 0,
privateGists: userData.private_gists || 0,
lastUpdated: new Date().toISOString(),
// For backward compatibility
totalStars: totalStars || 0,
totalForks: totalForks || 0,
organizations: [],
};
// Get the current user first to ensure we have the latest value
const currentConnection = JSON.parse(localStorage.getItem('github_connection') || '{}');
const currentUser = currentConnection.user || connection.user;
// Update connection with stats
const updatedConnection: GitHubConnection = {
user: currentUser,
token,
tokenType: connection.tokenType,
stats,
rateLimit: connection.rateLimit,
};
// Update localStorage
localStorage.setItem('github_connection', JSON.stringify(updatedConnection));
// Update state
setConnection(updatedConnection);
toast.success('GitHub 统计已刷新');
} catch (error) {
console.error('Error fetching GitHub stats:', error);
toast.error(`Failed to fetch GitHub stats: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setIsFetchingStats(false);
}
};
const calculateRepoStats = async (
repos: any[],
): Promise<{ repos: GitHubRepoInfo[]; languages: GitHubLanguageStats; totalGists: number }> => {
// 构建基本仓库信息
const repoStats = {
repos: repos.map((repo: any) => ({
name: repo.name,
full_name: repo.full_name,
html_url: repo.html_url,
description: repo.description,
stargazers_count: repo.stargazers_count,
forks_count: repo.forks_count,
default_branch: repo.default_branch,
updated_at: repo.updated_at,
languages_url: repo.languages_url,
})),
languages: {} as Record<string, number>,
totalGists: 0,
};
// 首先使用仓库的主要语言属性构建基本的语言统计
repos.forEach((repo: any) => {
if (repo.language) {
if (!repoStats.languages[repo.language]) {
repoStats.languages[repo.language] = 0;
}
repoStats.languages[repo.language] += 1;
}
});
const topRepos = [...repos].sort((a, b) => b.stargazers_count - a.stargazers_count).slice(0, 10);
try {
const batchSize = 3;
for (let i = 0; i < topRepos.length; i += batchSize) {
const batch = topRepos.slice(i, i + batchSize);
const batchPromises = batch.map((repo) =>
fetch(repo.languages_url)
.then((response) => {
if (!response.ok) {
if (response.status === 429) {
console.warn('GitHub API rate limit exceeded when fetching languages');
throw new Error('Rate limit exceeded');
}
throw new Error(`Error fetching languages: ${response.status}`);
}
return response.json();
})
.then((languages: any) => {
const typedLanguages = languages as Record<string, number>;
Object.keys(typedLanguages).forEach((language) => {
if (!repoStats.languages[language]) {
repoStats.languages[language] = 0;
}
repoStats.languages[language] += 1;
});
})
.catch((error) => {
console.error(`Error processing languages for ${repo.name}:`, error);
}),
);
await Promise.all(batchPromises);
if (i + batchSize < topRepos.length) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
} catch (error) {
console.error('Error fetching repository languages:', error);
}
return repoStats;
};
useEffect(() => {
const loadSavedConnection = async () => {
setIsLoading(true);
const savedConnection = localStorage.getItem('github_connection');
if (savedConnection) {
try {
const parsed = JSON.parse(savedConnection);
if (!parsed.tokenType) {
parsed.tokenType = 'classic';
}
// Update the ref with the parsed token type
tokenTypeRef.current = parsed.tokenType;
// Set the connection
setConnection(parsed);
// If we have a token but no stats or incomplete stats, fetch them
if (
parsed.user &&
parsed.token &&
(!parsed.stats || !parsed.stats.repos || parsed.stats.repos.length === 0)
) {
console.log('Fetching missing GitHub stats for saved connection');
await fetchGitHubStats(parsed.token);
}
} catch (error) {
console.error('Error parsing saved GitHub connection:', error);
localStorage.removeItem('github_connection');
}
}
setIsLoading(false);
};
loadSavedConnection();
}, []);
// Ensure cookies are updated when connection changes
useEffect(() => {
if (!connection) {
return;
}
const token = connection.token;
const data = connection.user;
if (token) {
Cookies.set('githubToken', token);
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
}
if (data) {
Cookies.set('githubUsername', data.login);
}
}, [connection]);
// Add function to update rate limits
const updateRateLimits = async (token: string) => {
try {
const response = await fetch('https://api.github.com/rate_limit', {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json',
},
});
if (response.ok) {
const rateLimit = {
limit: parseInt(response.headers.get('x-ratelimit-limit') || '0'),
remaining: parseInt(response.headers.get('x-ratelimit-remaining') || '0'),
reset: parseInt(response.headers.get('x-ratelimit-reset') || '0'),
};
setConnection((prev) => ({
...prev,
rateLimit,
}));
}
} catch (error) {
console.error('Failed to fetch rate limits:', error);
}
};
// Add effect to update rate limits periodically
useEffect(() => {
let interval: NodeJS.Timeout;
if (connection.token && connection.user) {
updateRateLimits(connection.token);
interval = setInterval(() => updateRateLimits(connection.token), 60000); // Update every minute
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [connection.token, connection.user]);
if (isLoading || isConnecting || isFetchingStats) {
return <LoadingSpinner />;
}
const handleConnect = async (event: React.FormEvent) => {
event.preventDefault();
setIsConnecting(true);
try {
// Update the ref with the current state value before connecting
tokenTypeRef.current = connection.tokenType;
/*
* Save token type to localStorage even before connecting
* This ensures the token type is persisted even if connection fails
*/
localStorage.setItem(
'github_connection',
JSON.stringify({
user: null,
token: connection.token,
tokenType: connection.tokenType,
}),
);
// Attempt to fetch the user info which validates the token
await fetchGithubUser(connection.token);
toast.success('已成功连接到 GitHub');
} catch (error) {
console.error('Failed to connect to GitHub:', error);
// Reset connection state on failure
setConnection({ user: null, token: connection.token, tokenType: connection.tokenType });
toast.error(`Failed to connect to GitHub: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setIsConnecting(false);
}
};
const handleDisconnect = () => {
localStorage.removeItem('github_connection');
// Remove all GitHub-related cookies
Cookies.remove('githubToken');
Cookies.remove('githubUsername');
Cookies.remove('git:github.com');
// Reset the token type ref
tokenTypeRef.current = 'classic';
setConnection({ user: null, token: '', tokenType: 'classic' });
toast.success('已断开与 GitHub 的连接');
};
return (
<ConnectionBorder>
{!isConnecting && !connection.user && (
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-sm text-upage-elements-textSecondary dark:text-upage-elements-textSecondary mb-2">
</label>
<select
value={connection.tokenType}
onChange={(e) => {
const newTokenType = e.target.value as 'classic' | 'fine-grained';
tokenTypeRef.current = newTokenType;
setConnection((prev) => ({ ...prev, tokenType: newTokenType }));
}}
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-upage-elements-background-depth-1 dark:bg-upage-elements-background-depth-1',
'border border-upage-elements-borderColor dark:border-upage-elements-borderColor',
'text-upage-elements-textPrimary dark:text-upage-elements-textPrimary',
'focus:outline-none focus:ring-1 focus:ring-upage-elements-item-contentAccent dark:focus:ring-upage-elements-item-contentAccent',
'disabled:opacity-50',
)}
>
<option value="classic">Personal Access Token (Classic)</option>
<option value="fine-grained">Fine-grained Token</option>
</select>
</div>
<div>
<label className="block text-sm text-upage-elements-textSecondary dark:text-upage-elements-textSecondary mb-2">
{connection.tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'}
</label>
<input
type="password"
value={connection.token}
onChange={(e) => setConnection((prev) => ({ ...prev, token: e.target.value }))}
disabled={isConnecting || !!connection.user}
placeholder={`输入您的 GitHub ${
connection.tokenType === 'classic' ? 'personal access token' : 'fine-grained token'
}`}
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-upage-elements-background-depth-1 dark:bg-upage-elements-background-depth-1',
'border border-upage-elements-borderColor dark:border-upage-elements-borderColor',
'text-upage-elements-textPrimary dark:text-upage-elements-textPrimary placeholder-upage-elements-textTertiary dark:placeholder-upage-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-upage-elements-item-contentAccent dark:focus:ring-upage-elements-item-contentAccent',
'disabled:opacity-50',
)}
/>
<div className="mt-2 text-sm text-upage-elements-textSecondary">
<a
href={`https://github.com/settings/tokens${connection.tokenType === 'fine-grained' ? '/beta' : '/new'}`}
target="_blank"
rel="noopener noreferrer"
className="text-upage-elements-link-text dark:text-upage-elements-link-text hover:text-upage-elements-link-textHover dark:hover:text-upage-elements-link-textHover flex items-center gap-1"
>
<div className="i-ph:key size-4" />
<div className="i-ph:arrow-square-out size-3" />
</a>
<span className="mx-2"></span>
<span>
:{' '}
{connection.tokenType === 'classic'
? 'repo, read:org, read:user'
: 'Repository access, Organization access'}
</span>
</div>
</div>
</div>
)}
<div className="flex items-center justify-between">
{!connection.user ? (
<Button
onClick={handleConnect}
disabled={isConnecting || !connection.token}
variant="default"
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-upage-elements-button-secondary-background',
'hover:bg-upage-elements-button-secondary-backgroundHover',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
{isConnecting ? (
<>
<div className="i-ph:spinner-gap animate-spin size-4" />
...
</>
) : (
<>
<div className="i-ph:github-logo size-4" />
</>
)}
</Button>
) : (
<>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-4">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<div className="i-ph:check-circle size-4 text-upage-elements-icon-success dark:text-upage-elements-icon-success" />
<span className="text-sm text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
GitHub 使{' '}
<span className="text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent font-medium">
{connection.tokenType === 'classic' ? 'PAT' : 'Fine-grained Token'}
</span>
</span>
</div>
{connection.rateLimit && (
<div className="flex items-center gap-2 text-xs text-upage-elements-textSecondary">
<div className="i-ph:chart-line-up w-3.5 h-3.5 text-upage-elements-icon-success" />
<span>
API : {connection.rateLimit.remaining.toLocaleString()}/
{connection.rateLimit.limit.toLocaleString()} :
{Math.max(0, Math.floor((connection.rateLimit.reset * 1000 - Date.now()) / 60000))} min
</span>
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => window.open('https://github.com/dashboard', '_blank', 'noopener,noreferrer')}
className="flex items-center gap-2 hover:bg-upage-elements-item-backgroundActive/10 hover:text-upage-elements-textPrimary dark:hover:text-upage-elements-textPrimary transition-colors"
>
<div className="i-mingcute:dashboard-line size-4" />
</Button>
<Button
onClick={() => {
fetchGitHubStats(connection.token);
updateRateLimits(connection.token);
}}
disabled={isFetchingStats}
variant="outline"
className="flex items-center gap-2 hover:bg-upage-elements-item-backgroundActive/10 hover:text-upage-elements-textPrimary dark:hover:text-upage-elements-textPrimary transition-colors"
>
{isFetchingStats ? (
<>
<div className="i-ph:spinner-gap size-4 animate-spin" />
...
</>
) : (
<>
<div className="i-ph:arrows-clockwise size-4" />
</>
)}
</Button>
<Button onClick={handleDisconnect} variant="destructive" size="sm" className="flex items-center gap-2">
<div className="i-ph:sign-out size-4" />
</Button>
</div>
</div>
</>
)}
</div>
{connection.user && connection.stats && (
<div className="mt-6 border-t border-upage-elements-borderColor dark:border-upage-elements-borderColor pt-6">
<div className="flex items-center gap-4 p-4 bg-upage-elements-background-depth-1 dark:bg-upage-elements-background-depth-1 rounded-lg mb-4">
<img
src={connection.user.avatar_url}
alt={connection.user.login}
className="size-12 rounded-full border-2 border-upage-elements-item-contentAccent dark:border-upage-elements-item-contentAccent"
/>
<div>
<h4 className="text-sm font-medium text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
{connection.user.name || connection.user.login}
</h4>
<p className="text-sm text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
{connection.user.login}
</p>
</div>
</div>
<Collapsible open={isStatsExpanded} onOpenChange={setIsStatsExpanded}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between p-4 rounded-lg bg-upage-elements-background dark:bg-upage-elements-background-depth-2 border border-upage-elements-borderColor dark:border-upage-elements-borderColor hover:border-upage-elements-borderColorActive/70 dark:hover:border-upage-elements-borderColorActive/70 transition-all duration-200 cursor-pointer">
<div className="flex items-center gap-2">
<div className="i-ph:chart-bar size-4 text-upage-elements-item-contentAccent" />
<span className="text-sm font-medium text-upage-elements-textPrimary">GitHub </span>
</div>
<div
className={classNames(
'i-ph:caret-down size-4 transform transition-transform duration-200 text-upage-elements-textSecondary',
isStatsExpanded ? 'rotate-180' : '',
)}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="space-y-4 mt-4">
{/* Languages Section */}
<div className="mb-6">
<h4 className="text-sm font-medium text-upage-elements-textPrimary mb-3">Top Languages</h4>
<div className="flex flex-wrap gap-2">
{Object.entries(connection.stats.languages)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([language]) => (
<span
key={language}
className="px-3 py-1 text-xs rounded-full bg-upage-elements-sidebar-buttonBackgroundDefault text-upage-elements-sidebar-buttonText"
>
{language}
</span>
))}
</div>
</div>
{/* Additional Stats */}
<div className="grid grid-cols-4 gap-4 mb-6">
{[
{
label: 'Member Since',
value: new Date(connection.user.created_at).toLocaleDateString(),
},
{
label: 'Public Gists',
value: connection.stats.publicGists,
},
{
label: 'Organizations',
value: connection.stats.organizations ? connection.stats.organizations.length : 0,
},
{
label: 'Languages',
value: Object.keys(connection.stats.languages).length,
},
].map((stat, index) => (
<div
key={index}
className="flex flex-col p-3 rounded-lg bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-2 border border-upage-elements-borderColor dark:border-upage-elements-borderColor"
>
<span className="text-xs text-upage-elements-textSecondary">{stat.label}</span>
<span className="text-lg font-medium text-upage-elements-textPrimary">{stat.value}</span>
</div>
))}
</div>
{/* Repository Stats */}
<div className="mt-4">
<div className="space-y-4">
<div>
<h5 className="text-sm font-medium text-upage-elements-textPrimary mb-2">Repository Stats</h5>
<div className="grid grid-cols-2 gap-4">
{[
{
label: 'Public Repos',
value: connection.stats.publicRepos,
},
{
label: 'Private Repos',
value: connection.stats.privateRepos,
},
].map((stat, index) => (
<div
key={index}
className="flex flex-col p-3 rounded-lg bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-2 border border-upage-elements-borderColor dark:border-upage-elements-borderColor"
>
<span className="text-xs text-upage-elements-textSecondary">{stat.label}</span>
<span className="text-lg font-medium text-upage-elements-textPrimary">{stat.value}</span>
</div>
))}
</div>
</div>
<div>
<h5 className="text-sm font-medium text-upage-elements-textPrimary mb-2">Contribution Stats</h5>
<div className="grid grid-cols-3 gap-4">
{[
{
label: 'Stars',
value: connection.stats.stars || 0,
icon: 'i-ph:star',
iconColor: 'text-upage-elements-icon-warning',
},
{
label: 'Forks',
value: connection.stats.forks || 0,
icon: 'i-ph:git-fork',
iconColor: 'text-upage-elements-icon-info',
},
{
label: 'Followers',
value: connection.stats.followers || 0,
icon: 'i-ph:users',
iconColor: 'text-upage-elements-icon-success',
},
].map((stat, index) => (
<div
key={index}
className="flex flex-col p-3 rounded-lg bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-2 border border-upage-elements-borderColor dark:border-upage-elements-borderColor"
>
<span className="text-xs text-upage-elements-textSecondary">{stat.label}</span>
<span className="text-lg font-medium text-upage-elements-textPrimary flex items-center gap-1">
<div className={`${stat.icon} size-4 ${stat.iconColor}`} />
{stat.value}
</span>
</div>
))}
</div>
</div>
<div>
<h5 className="text-sm font-medium text-upage-elements-textPrimary mb-2">Gists</h5>
<div className="grid grid-cols-2 gap-4">
{[
{
label: 'Public',
value: connection.stats.publicGists,
},
{
label: 'Private',
value: connection.stats.privateGists || 0,
},
].map((stat, index) => (
<div
key={index}
className="flex flex-col p-3 rounded-lg bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-2 border border-upage-elements-borderColor dark:border-upage-elements-borderColor"
>
<span className="text-xs text-upage-elements-textSecondary">{stat.label}</span>
<span className="text-lg font-medium text-upage-elements-textPrimary">{stat.value}</span>
</div>
))}
</div>
</div>
<div className="pt-2 border-t border-upage-elements-borderColor">
<span className="text-xs text-upage-elements-textSecondary">
Last updated: {new Date(connection.stats.lastUpdated).toLocaleString()}
</span>
</div>
</div>
</div>
{/* Repositories Section */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-upage-elements-textPrimary">Recent Repositories</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{connection.stats.repos.map((repo) => (
<a
key={repo.full_name}
href={repo.html_url}
target="_blank"
rel="noopener noreferrer"
className="group block p-4 rounded-lg bg-upage-elements-background-depth-1 dark:bg-upage-elements-background-depth-1 border border-upage-elements-borderColor dark:border-upage-elements-borderColor hover:border-upage-elements-borderColorActive dark:hover:border-upage-elements-borderColorActive transition-all duration-200"
>
<div className="space-y-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className="i-mingcute:github-line size-4 text-upage-elements-icon-info dark:text-upage-elements-icon-info" />
<h5 className="text-sm font-medium text-upage-elements-textPrimary group-hover:text-upage-elements-item-contentAccent transition-colors">
{repo.name}
</h5>
</div>
<div className="flex items-center gap-3 text-xs text-upage-elements-textSecondary">
<span className="flex items-center gap-1" title="Stars">
<div className="i-ph:star w-3.5 h-3.5 text-upage-elements-icon-warning" />
{repo.stargazers_count.toLocaleString()}
</span>
<span className="flex items-center gap-1" title="Forks">
<div className="i-ph:git-fork w-3.5 h-3.5 text-upage-elements-icon-info" />
{repo.forks_count.toLocaleString()}
</span>
</div>
</div>
{repo.description && (
<p className="text-xs text-upage-elements-textSecondary line-clamp-2">{repo.description}</p>
)}
<div className="flex items-center gap-3 text-xs text-upage-elements-textSecondary">
<span className="flex items-center gap-1" title="Default Branch">
<div className="i-ph:git-branch w-3.5 h-3.5" />
{repo.default_branch}
</span>
<span className="flex items-center gap-1" title="Last Updated">
<div className="i-ph:clock w-3.5 h-3.5" />
{new Date(repo.updated_at).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
<span className="flex items-center gap-1 ml-auto group-hover:text-upage-elements-item-contentAccent transition-colors">
<div className="i-ph:arrow-square-out w-3.5 h-3.5" />
View
</span>
</div>
</div>
</a>
))}
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
)}
</ConnectionBorder>
);
}
function LoadingSpinner() {
return (
<div className="flex items-center justify-center p-4">
<div className="flex items-center gap-2">
<div className="i-ph:spinner-gap-bold animate-spin size-4" />
<span className="text-upage-elements-textSecondary">...</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,694 @@
import { useStore } from '@nanostores/react';
import { useFetcher, useRouteLoaderData } from '@remix-run/react';
import classNames from 'classnames';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale/zh-CN';
import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Badge } from '~/components/ui/Badge';
import { Button } from '~/components/ui/Button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '~/components/ui/Collapsible';
import { fetchNetlifyStats, isFetchingStats, netlifyConnection, updateNetlifyConnection } from '~/lib/stores/netlify';
import type { ConnectionSettings } from '~/root';
import type { ApiResponse } from '~/types/global';
import type { NetlifyBuild, NetlifyDeploy, NetlifySite } from '~/types/netlify';
import { logger } from '~/utils/logger';
import ConnectionBorder from './components/ConnectionBorder';
// Add new interface for site actions
interface SiteAction {
name: string;
icon: string;
action: (siteId: string) => Promise<void>;
requiresConfirmation?: boolean;
variant?: 'default' | 'destructive' | 'outline';
}
export default function NetlifyConnection() {
const rootData = useRouteLoaderData<{ connectionSettings?: ConnectionSettings }>('root');
const connectFetcher = useFetcher<ApiResponse>();
const settingsFetcher = useFetcher<ApiResponse>();
const connection = useStore(netlifyConnection);
const [tokenInput, setTokenInput] = useState('');
const fetchingStats = useStore(isFetchingStats);
const [sites, setSites] = useState<NetlifySite[]>([]);
const [deploys, setDeploys] = useState<NetlifyDeploy[]>([]);
const [builds, setBuilds] = useState<NetlifyBuild[]>([]);
const [deploymentCount, setDeploymentCount] = useState(0);
const [lastUpdated, setLastUpdated] = useState('');
const [isStatsOpen, setIsStatsOpen] = useState(false);
const [activeSiteIndex, setActiveSiteIndex] = useState(0);
const [isActionLoading, setIsActionLoading] = useState(false);
const isConnecting = useMemo(() => {
return connectFetcher.state !== 'idle';
}, [connectFetcher.state]);
useEffect(() => {
updateNetlifyConnection({
isConnect: rootData?.connectionSettings?.netlifyConnection,
});
}, [rootData]);
// Add site actions
const siteActions: SiteAction[] = [
{
name: '清除缓存',
icon: 'heroicons:arrow-path',
action: async (siteId: string) => {
try {
const response = await fetch(`/api/netlify/sites/${siteId}/cache`, {
method: 'POST',
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || '清除缓存失败');
}
toast.success('站点缓存清除成功');
} catch (err: unknown) {
const error = err instanceof Error ? err.message : '未知错误';
toast.error(`清除站点缓存失败: ${error}`);
}
},
},
{
name: '删除站点',
icon: 'heroicons:trash',
action: async (siteId: string) => {
try {
const response = await fetch(`/api/netlify/sites/${siteId}`, {
method: 'DELETE',
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || '删除站点失败');
}
toast.success('站点删除成功');
fetchNetlifyStats().catch((err) => {
logger.error('获取 Netlify 统计信息失败:', err);
});
} catch (err: unknown) {
const error = err instanceof Error ? err.message : '未知错误';
toast.error(`删除站点失败: ${error}`);
}
},
requiresConfirmation: true,
variant: 'destructive',
},
];
const handleDeploy = async (siteId: string, deployId: string, action: 'lock' | 'unlock' | 'publish') => {
try {
setIsActionLoading(true);
const response = await fetch(`/api/netlify/deploys/${deployId}/${action}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ siteId }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `Failed to ${action} deploy`);
}
toast.success(`Deploy ${action}ed successfully`);
fetchNetlifyStats().catch((err) => {
logger.error('获取 Netlify 统计信息失败:', err);
});
} catch (err: unknown) {
const error = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to ${action} deploy: ${error}`);
} finally {
setIsActionLoading(false);
}
};
useEffect(() => {
if (connection.isConnect && (!connection.stats || !connection.stats.sites)) {
fetchNetlifyStats().catch((err) => {
logger.error('获取 Netlify 统计信息失败:', err);
});
}
// Update local state from connection
if (connection.stats) {
setSites(connection.stats.sites || []);
setDeploys(connection.stats.deploys || []);
setBuilds(connection.stats.builds || []);
setDeploymentCount(connection.stats.deploys?.length || 0);
setLastUpdated(connection.stats.lastDeployTime || '');
}
}, [connection]);
// 监听 connectFetcher 状态变化(连接)
useEffect(() => {
if (connectFetcher.state === 'idle' && connectFetcher.data) {
if (connectFetcher.data.success) {
updateNetlifyConnection({
isConnect: connectFetcher.data.data.isConnect,
});
fetchNetlifyStats().catch((err) => {
logger.error('获取 Netlify 统计信息失败:', err);
});
toast.success('连接 Netlify 成功');
setTokenInput('');
} else if (connectFetcher.data.message) {
toast.error(connectFetcher.data.message || '连接失败');
}
}
}, [connectFetcher.state, connectFetcher.data]);
// 监听 settingsFetcher 状态变化(断开连接)
useEffect(() => {
if (settingsFetcher.state === 'idle' && settingsFetcher.data) {
if (settingsFetcher.data.success) {
localStorage.removeItem('netlify_connection');
document.cookie = 'netlifyToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
updateNetlifyConnection({ isConnect: false });
toast.success('断开 Netlify 连接');
}
}
}, [settingsFetcher.state, settingsFetcher.data]);
const handleConnect = async () => {
if (!tokenInput) {
toast.error('请输入 Netlify API 令牌');
return;
}
try {
connectFetcher.submit(
{ token: tokenInput },
{
method: 'POST',
action: '/api/netlify/auth',
encType: 'application/json',
},
);
} catch (error) {
logger.error('连接 Netlify 失败:', error);
toast.error(`连接 Netlify 失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
};
const handleDisconnect = async () => {
try {
settingsFetcher.submit(
{
category: 'connectivity',
key: 'netlify_token',
},
{
method: 'DELETE',
action: '/api/user/settings',
encType: 'application/json',
},
);
} catch (error) {
toast.error('断开 Netlify 连接失败');
logger.error('断开 Netlify 连接失败:', error);
}
};
const renderStats = () => {
if (!connection.isConnect || !connection.stats) {
return null;
}
return (
<div className="mt-6">
<Collapsible open={isStatsOpen} onOpenChange={setIsStatsOpen}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between p-4 rounded-lg bg-upage-elements-background dark:bg-upage-elements-background-depth-2 border border-upage-elements-borderColor dark:border-upage-elements-borderColor hover:border-upage-elements-borderColorActive/70 dark:hover:border-upage-elements-borderColorActive/70 transition-all duration-200">
<div className="flex items-center gap-2">
<div className="i-ph:chart-bar size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
<span className="text-sm font-medium text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
Netlify
</span>
</div>
<div
className={classNames(
'i-ph:caret-down size-4 transform transition-transform duration-200 text-upage-elements-textSecondary',
isStatsOpen ? 'rotate-180' : '',
)}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="space-y-4 mt-4">
<div className="flex flex-wrap items-center gap-4">
<Badge
variant="outline"
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
>
<div className="heroicons:building-library size-4 text-upage-elements-item-contentAccent" />
<span>{connection.stats.totalSites} </span>
</Badge>
<Badge
variant="outline"
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
>
<div className="heroicons:rocket-launch size-4 text-upage-elements-item-contentAccent" />
<span>{deploymentCount} </span>
</Badge>
{lastUpdated && (
<Badge
variant="outline"
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
>
<div className="heroicons:clock size-4 text-upage-elements-item-contentAccent" />
<span> {formatDistanceToNow(new Date(lastUpdated), { locale: zhCN })} </span>
</Badge>
)}
</div>
{sites.length > 0 && (
<div className="mt-4 space-y-4">
<div className="bg-upage-elements-background dark:bg-upage-elements-background-depth-1 border border-upage-elements-borderColor dark:border-upage-elements-borderColor rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<h4 className="text-sm font-medium flex items-center gap-2 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
<div className="heroicons:building-library size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
</h4>
<Button
variant="outline"
size="sm"
onClick={() =>
fetchNetlifyStats().catch((err) => {
logger.error('获取 Netlify 统计信息失败:', err);
})
}
disabled={fetchingStats}
className="flex items-center gap-2 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary hover:bg-upage-elements-item-backgroundActive/10"
>
<div
className={classNames(
'heroicons:arrow-path size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent',
{ 'animate-spin': fetchingStats },
)}
/>
{fetchingStats ? '刷新中...' : '刷新'}
</Button>
</div>
<div className="space-y-3">
{sites.map((site, index) => (
<div
key={site.id}
className={classNames(
'bg-upage-elements-background dark:bg-upage-elements-background-depth-1 border rounded-lg p-4 transition-all',
activeSiteIndex === index
? 'border-upage-elements-item-contentAccent bg-upage-elements-item-backgroundActive/10'
: 'border-upage-elements-borderColor hover:border-upage-elements-borderColorActive/70',
)}
onClick={() => {
setActiveSiteIndex(index);
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="heroicons:cloud size-5 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
<span className="font-medium text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
{site.name}
</span>
</div>
<div className="flex items-center gap-2">
<Badge
variant={site.published_deploy?.state === 'ready' ? 'default' : 'destructive'}
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
>
{site.published_deploy?.state === 'ready' ? (
<div className="heroicons:check-circle size-4 text-green-500" />
) : (
<div className="heroicons:x-circle size-4 text-red-500" />
)}
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
{site.published_deploy?.state || 'Unknown'}
</span>
</Badge>
</div>
</div>
<div className="mt-3 flex items-center gap-2">
<a
href={site.ssl_url || site.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm flex items-center gap-1 transition-colors text-upage-elements-link-text hover:text-upage-elements-link-textHover dark:text-white dark:hover:text-upage-elements-link-textHover"
onClick={(e) => e.stopPropagation()}
>
<div className="heroicons:cloud size-3 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
<span className="underline decoration-1 underline-offset-2">
{site.ssl_url || site.url}
</span>
</a>
</div>
{activeSiteIndex === index && (
<>
<div className="mt-4 pt-3 border-t border-upage-elements-borderColor">
<div className="flex items-center gap-2">
{siteActions.map((action) => (
<Button
key={action.name}
variant={action.variant || 'outline'}
size="sm"
onClick={async (e) => {
e.stopPropagation();
if (action.requiresConfirmation) {
if (!confirm(`您确定要 ${action.name.toLowerCase()}?`)) {
return;
}
}
setIsActionLoading(true);
await action.action(site.id);
setIsActionLoading(false);
}}
disabled={isActionLoading}
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
>
<div
className={`${action.icon} size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent`}
/>
{action.name}
</Button>
))}
</div>
</div>
{site.published_deploy && (
<div className="mt-3 text-sm">
<div className="flex items-center gap-1">
<div className="heroicons:clock size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
<span className="text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
{' '}
{formatDistanceToNow(new Date(site.published_deploy.published_at), {
locale: zhCN,
})}{' '}
</span>
</div>
{site.published_deploy.branch && (
<div className="flex items-center gap-1 mt-1">
<div className="heroicons:code-bracket size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
<span className="text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
: {site.published_deploy.branch}
</span>
</div>
)}
</div>
)}
</>
)}
</div>
))}
</div>
</div>
{activeSiteIndex !== -1 && deploys.length > 0 && (
<div className="bg-upage-elements-background dark:bg-upage-elements-background-depth-1 border border-upage-elements-borderColor dark:border-upage-elements-borderColor rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium flex items-center gap-2 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
<div className="heroicons:building-library size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
</h4>
</div>
<div className="space-y-2">
{deploys.map((deploy) => (
<div
key={deploy.id}
className="bg-upage-elements-background dark:bg-upage-elements-background-depth-1 border border-upage-elements-borderColor dark:border-upage-elements-borderColor rounded-lg p-3"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge
variant={
deploy.state === 'ready'
? 'default'
: deploy.state === 'error'
? 'destructive'
: 'outline'
}
className="flex items-center gap-1"
>
{deploy.state === 'ready' ? (
<div className="heroicons:check-circle size-4 text-green-500" />
) : deploy.state === 'error' ? (
<div className="heroicons:x-circle size-4 text-red-500" />
) : (
<div className="heroicons:building-library size-4 text-upage-elements-item-contentAccent" />
)}
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
{deploy.state}
</span>
</Badge>
</div>
<span className="text-xs text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
{formatDistanceToNow(new Date(deploy.created_at), { locale: zhCN })}
</span>
</div>
{deploy.branch && (
<div className="mt-2 text-xs text-upage-elements-textSecondary dark:text-upage-elements-textSecondary flex items-center gap-1">
<div className="heroicons:code-bracket size-3 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
<span className="text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
: {deploy.branch}
</span>
</div>
)}
{deploy.deploy_url && (
<div className="mt-2 text-xs">
<a
href={deploy.deploy_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 transition-colors text-upage-elements-link-text hover:text-upage-elements-link-textHover dark:text-white dark:hover:text-upage-elements-link-textHover"
onClick={(e) => e.stopPropagation()}
>
<div className="heroicons:cloud size-3 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
<span className="underline decoration-1 underline-offset-2">{deploy.deploy_url}</span>
</a>
</div>
)}
<div className="flex items-center gap-2 mt-2">
<Button
variant="outline"
size="sm"
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'publish')}
disabled={isActionLoading}
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
>
<div className="heroicons:building-library size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
</Button>
{deploy.state === 'ready' ? (
<Button
variant="outline"
size="sm"
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'lock')}
disabled={isActionLoading}
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
>
<div className="heroicons:lock-closed size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'unlock')}
disabled={isActionLoading}
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
>
<div className="heroicons:lock-open size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
</Button>
)}
</div>
</div>
))}
</div>
</div>
)}
{activeSiteIndex !== -1 && builds.length > 0 && (
<div className="bg-upage-elements-background dark:bg-upage-elements-background-depth-1 border border-upage-elements-borderColor dark:border-upage-elements-borderColor rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium flex items-center gap-2 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
<div className="heroicons:code-bracket size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
</h4>
</div>
<div className="space-y-2">
{builds.map((build) => (
<div
key={build.id}
className="bg-upage-elements-background dark:bg-upage-elements-background-depth-1 border border-upage-elements-borderColor dark:border-upage-elements-borderColor rounded-lg p-3"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge
variant={
build.done && !build.error ? 'default' : build.error ? 'destructive' : 'outline'
}
className="flex items-center gap-1"
>
{build.done && !build.error ? (
<div className="heroicons:check-circle size-4" />
) : build.error ? (
<div className="heroicons:x-circle size-4" />
) : (
<div className="heroicons:code-bracket size-4" />
)}
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
{build.done ? (build.error ? '失败' : '完成') : '进行中'}
</span>
</Badge>
</div>
<span className="text-xs text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
{formatDistanceToNow(new Date(build.created_at), { locale: zhCN })}
</span>
</div>
{build.error && (
<div className="mt-2 text-xs text-upage-elements-textDestructive dark:text-upage-elements-textDestructive flex items-center gap-1">
<div className="heroicons:x-circle size-3 text-upage-elements-textDestructive dark:text-upage-elements-textDestructive" />
: {build.error}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
return (
<ConnectionBorder>
<div className="p-6">
{!connection.isConnect ? (
<div>
<label className="block text-sm text-upage-elements-textSecondary dark:text-upage-elements-textSecondary mb-2">
API
</label>
<input
type="password"
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
placeholder="输入您的 Netlify API 令牌"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-upage-elements-background-depth-1 dark:bg-upage-elements-background-depth-1',
'border border-upage-elements-borderColor dark:border-upage-elements-borderColor',
'text-upage-elements-textPrimary dark:text-upage-elements-textPrimary placeholder-upage-elements-textTertiary dark:placeholder-upage-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-upage-elements-item-contentAccent dark:focus:ring-upage-elements-item-contentAccent',
)}
/>
<div className="mt-2 text-sm text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
<a
href="https://app.netlify.com/user/applications#personal-access-tokens"
target="_blank"
rel="noopener noreferrer"
className="text-upage-elements-link-text dark:text-upage-elements-link-text hover:text-upage-elements-link-textHover dark:hover:text-upage-elements-link-textHover flex items-center gap-1"
>
<div className="i-ph:key size-4" />
<div className="i-ph:arrow-square-out size-3" />
</a>
</div>
<div className="flex items-center justify-between mt-4">
<Button
onClick={handleConnect}
disabled={isConnecting || !tokenInput}
variant="default"
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-upage-elements-button-secondary-background',
'hover:bg-upage-elements-button-secondary-backgroundHover',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
{isConnecting ? (
<>
<div className="i-ph:spinner-gap animate-spin size-4" />
...
</>
) : (
<>
<div className="heroicons:cloud size-4" />
</>
)}
</Button>
</div>
</div>
) : (
<div className="flex flex-col w-full gap-4 mt-4">
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<div className="heroicons:check-circle size-4 text-green-500" />
<span className="text-sm text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
Netlify
</span>
</div>
<div className="flex items-center gap-2 ml-auto">
<Button
variant="outline"
onClick={() => window.open('https://app.netlify.com', '_blank', 'noopener,noreferrer')}
className="flex items-center gap-2 hover:bg-upage-elements-item-backgroundActive/10 hover:text-upage-elements-textPrimary dark:hover:text-upage-elements-textPrimary transition-colors"
>
<div className="i-mingcute:dashboard-line size-4" />
</Button>
<Button
onClick={() =>
fetchNetlifyStats().catch((err) => {
logger.error('获取 Netlify 统计信息失败:', err);
})
}
disabled={fetchingStats}
variant="outline"
className="flex items-center gap-2 hover:bg-upage-elements-item-backgroundActive/10 hover:text-upage-elements-textPrimary dark:hover:text-upage-elements-textPrimary transition-colors"
>
{fetchingStats ? (
<>
<div className="i-ph:spinner-gap size-4 animate-spin text-upage-elements-textPrimary dark:text-upage-elements-textPrimary" />
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
...
</span>
</>
) : (
<>
<div className="heroicons:arrow-path size-4 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary" />
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
</span>
</>
)}
</Button>
<Button onClick={handleDisconnect} variant="destructive" size="sm" className="flex items-center gap-2">
<div className="i-ph:sign-out size-4" />
</Button>
</div>
</div>
{renderStats()}
</div>
)}
</div>
</ConnectionBorder>
);
}

View File

@@ -0,0 +1,314 @@
import { useStore } from '@nanostores/react';
import { useFetcher, useRouteLoaderData } from '@remix-run/react';
import classNames from 'classnames';
import React, { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import { logStore } from '~/lib/stores/logs';
import { fetchVercelStats, isFetchingStats, updateVercelConnection, vercelConnection } from '~/lib/stores/vercel';
import type { ConnectionSettings } from '~/root';
import { logger } from '~/utils/logger';
import ConnectionBorder from './components/ConnectionBorder';
interface ApiResponse {
success: boolean;
message?: string;
data?: any;
}
export default function VercelConnection() {
const rootData = useRouteLoaderData<{ connectionSettings?: ConnectionSettings }>('root');
const settingsFetcher = useFetcher<ApiResponse>();
const connectFetcher = useFetcher<ApiResponse>();
const connection = useStore(vercelConnection);
const fetchingStats = useStore(isFetchingStats);
const [isProjectsExpanded, setIsProjectsExpanded] = useState(false);
const [tokenInput, setTokenInput] = useState('');
useEffect(() => {
updateVercelConnection({
isConnect: rootData?.connectionSettings?.vercelConnection,
});
}, [rootData]);
useEffect(() => {
if (connection.isConnect) {
fetchVercelStats().catch((err) => {
logger.error('获取 Vercel 统计信息失败:', err);
});
if (!connection.user) {
handleConnect();
}
}
}, [connection.isConnect]);
useEffect(() => {
if (settingsFetcher.state === 'idle' && settingsFetcher.data) {
if (settingsFetcher.data.success) {
updateVercelConnection({ isConnect: false, user: null });
toast.success('断开 Vercel 连接');
}
}
}, [settingsFetcher.state, settingsFetcher.data]);
useEffect(() => {
if (connectFetcher.state === 'idle' && connectFetcher.data) {
if (connectFetcher.data.success) {
updateVercelConnection({
isConnect: connectFetcher.data.data.isConnect,
user: connectFetcher.data.data.user,
});
toast.success('连接 Vercel 成功');
setTokenInput('');
} else if (connectFetcher.data.message) {
toast.error(connectFetcher.data.message || '连接失败');
updateVercelConnection({ isConnect: false, user: null });
}
}
}, [connectFetcher.state, connectFetcher.data]);
const isConnecting = useMemo(() => {
return connectFetcher.state !== 'idle';
}, [connectFetcher.state]);
const handleConnect = async () => {
try {
connectFetcher.submit(
{ token: tokenInput },
{
method: 'POST',
action: '/api/vercel/auth',
encType: 'application/json',
},
);
} catch (error) {
toast.error('连接 Vercel 失败');
logger.error('连接 Vercel 失败:', error);
logStore.logError('Failed to authenticate with Vercel', { error });
}
};
const handleDisconnect = async () => {
try {
settingsFetcher.submit(
{
category: 'connectivity',
key: 'vercel_token',
},
{
method: 'DELETE',
action: '/api/user/settings',
encType: 'application/json',
},
);
} catch (error) {
toast.error('断开 Vercel 连接失败');
logger.error('断开 Vercel 连接失败:', error);
}
};
return (
<ConnectionBorder>
{!connection.isConnect ? (
<div className="space-y-4">
<div>
<label className="block text-sm text-upage-elements-textSecondary mb-2">访</label>
<input
type="password"
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
disabled={isConnecting}
placeholder="输入您的 Vercel 个人访问令牌"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#333333]',
'text-upage-elements-textPrimary placeholder-upage-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-upage-elements-borderColorActive',
'disabled:opacity-50',
)}
/>
<div className="mt-2 text-sm text-upage-elements-textSecondary">
<a
href="https://vercel.com/account/tokens"
target="_blank"
rel="noopener noreferrer"
className="text-upage-elements-borderColorActive hover:underline inline-flex items-center gap-1"
>
<div className="i-ph:arrow-square-out size-4" />
</a>
</div>
</div>
<button
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!tokenInput) {
toast.error('请输入 Vercel 访问令牌');
return;
}
handleConnect();
}}
disabled={isConnecting || !tokenInput}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-upage-elements-button-secondary-background',
'hover:bg-upage-elements-button-secondary-backgroundHover',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
{isConnecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
...
</>
) : (
<>
<div className="i-ph:plug-charging size-4" />
</>
)}
</button>
</div>
) : (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={handleDisconnect}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-red-500 text-white',
'hover:bg-red-600',
)}
>
<div className="i-ph:plug size-4" />
</button>
<span className="text-sm text-upage-elements-textSecondary flex items-center gap-1">
<div className="i-ph:check-circle size-4 text-green-500" />
Vercel
</span>
</div>
</div>
<div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
<pre className="hidden">{JSON.stringify(connection.user, null, 2)}</pre>
<img
src={`https://vercel.com/api/www/avatar?u=${connection.user?.username || connection.user?.user?.username}`}
referrerPolicy="no-referrer"
crossOrigin="anonymous"
alt="User Avatar"
className="size-12 rounded-full border-2 border-upage-elements-borderColorActive"
/>
<div>
<h4 className="text-sm font-medium text-upage-elements-textPrimary">
{connection.user?.username || connection.user?.user?.username || 'Vercel User'}
</h4>
<p className="text-sm text-upage-elements-textSecondary">
{connection.user?.email || connection.user?.user?.email || 'No email available'}
</p>
</div>
</div>
{fetchingStats ? (
<div className="flex items-center gap-2 text-sm text-upage-elements-textSecondary">
<div className="i-ph:spinner-gap size-4 animate-spin" />
Vercel ...
</div>
) : (
<div>
<button
onClick={() => setIsProjectsExpanded(!isProjectsExpanded)}
className="w-full bg-transparent text-left text-sm font-medium text-upage-elements-textPrimary mb-3 flex items-center gap-2"
>
<div className="i-ph:buildings size-4" />
({connection.stats?.totalProjects || 0})
<div
className={classNames(
'i-ph:caret-down size-4 ml-auto transition-transform',
isProjectsExpanded ? 'rotate-180' : '',
)}
/>
</button>
{isProjectsExpanded && connection.stats?.projects?.length ? (
<div className="grid gap-3">
{connection.stats.projects.map((project) => (
<a
key={project.id}
href={`https://vercel.com/dashboard/${project.id}`}
target="_blank"
rel="noopener noreferrer"
className="block p-4 rounded-lg border border-upage-elements-borderColor hover:border-upage-elements-borderColorActive transition-colors"
>
<div className="flex items-center justify-between">
<div>
<h5 className="text-sm font-medium text-upage-elements-textPrimary flex items-center gap-2">
<div className="i-ph:globe size-4 text-upage-elements-borderColorActive" />
{project.name}
</h5>
<div className="flex items-center gap-2 mt-2 text-xs text-upage-elements-textSecondary">
{project.targets?.production?.alias && project.targets.production.alias.length > 0 ? (
<>
<a
href={`https://${project.targets.production.alias.find((a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app')) || project.targets.production.alias[0]}`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-upage-elements-borderColorActive"
>
{project.targets.production.alias.find(
(a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app'),
) || project.targets.production.alias[0]}
</a>
<span></span>
<span className="flex items-center gap-1">
<div className="i-ph:clock size-3" />
{new Date(project.createdAt).toLocaleDateString()}
</span>
</>
) : project.latestDeployments && project.latestDeployments.length > 0 ? (
<>
<a
href={`https://${project.latestDeployments[0].url}`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-upage-elements-borderColorActive"
>
{project.latestDeployments[0].url}
</a>
<span></span>
<span className="flex items-center gap-1">
<div className="i-ph:clock size-3" />
{new Date(project.latestDeployments[0].created).toLocaleDateString()}
</span>
</>
) : null}
</div>
</div>
{project.framework && (
<div className="text-xs text-upage-elements-textSecondary px-2 py-1 rounded-md bg-[#F0F0F0] dark:bg-[#252525]">
<span className="flex items-center gap-1">
<div className="i-mingcute:code-line size-3" />
{project.framework}
</span>
</div>
)}
</div>
</a>
))}
</div>
) : isProjectsExpanded ? (
<div className="text-sm text-upage-elements-textSecondary flex items-center gap-2">
<div className="i-ph:info size-4" />
Vercel
</div>
) : null}
</div>
)}
</div>
)}
</ConnectionBorder>
);
}

View File

@@ -0,0 +1,581 @@
import { useStore } from '@nanostores/react';
import { useFetcher, useRouteLoaderData } from '@remix-run/react';
import classNames from 'classnames';
import { format, formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale/zh-CN';
import { motion } from 'framer-motion';
import React, { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Badge } from '~/components/ui/Badge';
import { Button } from '~/components/ui/Button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '~/components/ui/Collapsible';
import { _1PanelConnectionStore, fetch1PanelStats, isFetchingStats, update1PanelConnection } from '~/lib/stores/1panel';
import { getChatId } from '~/lib/stores/ai-state';
import type { ConnectionSettings } from '~/root';
import type { _1PanelWebsite } from '~/types/1panel';
import type { ApiResponse } from '~/types/global';
import ConnectionBorder from './components/ConnectionBorder';
export default function _1PanelConnection({
isDeploying,
onDeploy,
}: {
isDeploying: boolean;
onDeploy: (siteId: number) => void;
}) {
const rootData = useRouteLoaderData<{ connectionSettings?: ConnectionSettings }>('root');
const connectFetcher = useFetcher<ApiResponse>();
const settingsFetcher = useFetcher<ApiResponse>();
const connection = useStore(_1PanelConnectionStore);
const [serverUrl, setServerUrl] = useState('');
const [apiKey, setApiKey] = useState('');
const fetching = useStore(isFetchingStats);
const [isStatsOpen, setIsStatsOpen] = useState(false);
const [activeSiteIndex, setActiveSiteIndex] = useState(-1);
const [isActionLoading, setIsActionLoading] = useState(false);
// 使用 useMemo 计算 isConnecting 状态
const isConnecting = useMemo(() => {
return connectFetcher.state !== 'idle';
}, [connectFetcher.state]);
useEffect(() => {
update1PanelConnection({
isConnect: rootData?.connectionSettings?._1PanelConnection,
});
}, [rootData]);
useEffect(() => {
if (connection.isConnect) {
fetch1PanelStats();
}
}, [connection.isConnect]);
// 监听 connectFetcher 状态变化(连接)
useEffect(() => {
const data = connectFetcher.data as ApiResponse<{
websites: _1PanelWebsite[];
totalWebsites: number;
lastUpdated: string;
}>;
if (connectFetcher.state === 'idle' && data) {
if (data.success) {
update1PanelConnection({
isConnect: true,
stats: data.data,
serverUrl,
});
toast.success('连接 1Panel 成功');
} else if (data.message) {
toast.error(`连接 1Panel 失败: ${data.message}`);
}
}
}, [connectFetcher.state, connectFetcher.data, serverUrl]);
// 监听 settingsFetcher 状态变化(断开连接)
useEffect(() => {
if (settingsFetcher.state === 'idle' && settingsFetcher.data) {
if (settingsFetcher.data.success) {
update1PanelConnection({ isConnect: false, serverUrl: '' });
toast.success('断开 1Panel 服务器连接');
}
}
}, [settingsFetcher.state, settingsFetcher.data]);
const handleConnect = async (event: React.FormEvent) => {
if (!serverUrl) {
toast.error('请填写服务器地址');
return;
}
if (!apiKey) {
toast.error('请输入 API 密钥');
return;
}
event.preventDefault();
try {
connectFetcher.submit(
{ serverUrl, apiKey },
{
method: 'POST',
action: '/api/1panel/auth',
encType: 'application/json',
},
);
} catch (error) {
toast.error(`连接 1Panel 失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
};
const handleDisconnect = async () => {
try {
settingsFetcher.submit(
{
category: 'connectivity',
key: '1panel_server_url',
},
{
method: 'DELETE',
action: '/api/user/settings',
encType: 'application/json',
},
);
await fetch('/api/user/settings', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
category: 'connectivity',
key: '1panel_api_key',
}),
});
} catch (error) {
toast.error('断开 1Panel 连接失败');
console.error('断开 1Panel 连接失败:', error);
}
};
const handleDeleteWebsite = async (e: React.MouseEvent<HTMLButtonElement>, site: _1PanelWebsite) => {
e.stopPropagation();
if (!confirm(`您确定要删除站点 ${site.alias} 吗?`)) {
return;
}
setIsActionLoading(true);
try {
const response = await fetch('/api/1panel/websites', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
siteId: site.id,
}),
});
const { success, message } = (await response.json()) as ApiResponse;
if (!response.ok || !success) {
toast.error(`删除站点失败: ${message}`);
return;
}
toast.success(message || '站点删除成功');
const currentSiteId = localStorage.getItem(`1panel-project-${getChatId()}`);
if (currentSiteId === site.id.toString()) {
localStorage.removeItem(`1panel-project-${getChatId()}`);
}
fetch1PanelStats();
} catch (err: unknown) {
const error = err instanceof Error ? err.message : '未知错误';
toast.error(`删除站点失败: ${error}`);
}
setIsActionLoading(false);
};
const handleDeployToSite = (e: React.MouseEvent<HTMLButtonElement>, site: _1PanelWebsite) => {
e.stopPropagation();
onDeploy(site.id);
};
const formatExpirationDate = (date: string) => {
const dateObj = new Date(date);
if (isNaN(dateObj.getTime())) {
return '未知';
}
// 将日期格式化为 YYYY-MM-DD
const formattedDate = format(dateObj, 'yyyy-MM-dd');
if (formattedDate === '9999-12-31') {
return '永不过期';
}
return formattedDate;
};
const renderStats = () => {
if (!connection.isConnect || !connection.stats) {
return null;
}
return (
<div className="mt-6">
<Collapsible open={isStatsOpen} onOpenChange={setIsStatsOpen}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between p-4 rounded-lg bg-upage-elements-background dark:bg-upage-elements-background-depth-2 border border-upage-elements-borderColor dark:border-upage-elements-borderColor hover:border-upage-elements-borderColorActive/70 dark:hover:border-upage-elements-borderColorActive/70 transition-all duration-200">
<div className="flex items-center gap-2">
<div className="i-ph:chart-bar size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
<span className="text-sm font-medium text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
1Panel
</span>
</div>
<div
className={classNames(
'i-ph:caret-down size-4 transform transition-transform duration-200 text-upage-elements-textSecondary',
isStatsOpen ? 'rotate-180' : '',
)}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="space-y-4 mt-4">
<div className="flex flex-wrap items-center gap-4">
<Badge
variant="outline"
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
>
<div className="heroicons:building-library size-4 text-upage-elements-item-contentAccent" />
<span>{connection.stats.totalWebsites} </span>
</Badge>
{connection.stats.lastUpdated && (
<Badge
variant="outline"
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
>
<div className="i-lucide:clock size-4 text-upage-elements-item-contentAccent" />
<span>
{formatDistanceToNow(new Date(connection.stats.lastUpdated), { locale: zhCN })}
</span>
</Badge>
)}
</div>
{connection.stats.websites.length > 0 && (
<div className="mt-4 space-y-4">
<div className="bg-upage-elements-background dark:bg-upage-elements-background-depth-1 border border-upage-elements-borderColor dark:border-upage-elements-borderColor rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<h4 className="text-sm font-medium flex items-center gap-2 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
<div className="heroicons:building-library size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
</h4>
<Button
variant="outline"
size="sm"
onClick={() => fetch1PanelStats()}
disabled={fetching}
className="flex items-center gap-2 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary hover:bg-upage-elements-item-backgroundActive/10"
>
<div
className={classNames(
'heroicons:arrow-path size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent',
{ 'animate-spin': fetching },
)}
/>
{fetching ? '刷新中...' : '刷新'}
</Button>
</div>
<div className="space-y-3">
{connection.stats.websites.map((site, index) => (
<div
key={site.id}
className={classNames(
'bg-upage-elements-background dark:bg-upage-elements-background-depth-1 border rounded-lg p-4 transition-all',
activeSiteIndex === index
? 'border-upage-elements-item-contentAccent bg-upage-elements-item-backgroundActive/10'
: 'border-upage-elements-borderColor hover:border-upage-elements-borderColorActive/70',
)}
onClick={() => {
if (activeSiteIndex === index) {
setActiveSiteIndex(-1);
} else {
setActiveSiteIndex(index);
}
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="heroicons:globe-alt size-5 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
<span className="font-medium text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
{site.alias}
</span>
</div>
<div className="flex items-center gap-2">
<Badge
variant={site.status === 'Running' ? 'default' : 'destructive'}
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
>
{site.status === 'Running' ? (
<div className="i-lucide:check-circle size-4 text-green-500" />
) : (
<div className="i-lucide:x-circle size-4 text-red-500" />
)}
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
{site.status === 'Running' ? '已启动' : '已停止'}
</span>
</Badge>
</div>
</div>
<div className="mt-3 flex flex-col gap-2">
{site.domains.map((domain) => (
<a
key={domain.id}
href={`${site.protocol.toLowerCase()}://${domain.domain}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm flex items-center gap-1 transition-colors text-upage-elements-link-text hover:text-upage-elements-link-textHover dark:text-white dark:hover:text-upage-elements-link-textHover w-fit"
onClick={(e) => e.stopPropagation()}
>
<div className="heroicons:paper-airplane size-3 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
<span className="underline decoration-1 underline-offset-2">
{`${site.protocol.toLowerCase()}://${domain.domain}`}
</span>
</a>
))}
<div className="flex items-center gap-2 mt-1">
{(() => {
const typeInfo = getWebsiteTypeInfo(site.type);
return (
<Badge
variant="secondary"
className={`flex items-center gap-1 px-1.5 py-0.5 rounded-md ${typeInfo.color}`}
>
<div className={`${typeInfo.icon} size-3`} />
<span>{typeInfo.label}</span>
</Badge>
);
})()}
{activeSiteIndex === index && (
<div className="flex gap-4 text-sm text-gray-700">
<div className="flex items-center gap-1">
<div className="i-lucide:clock size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
<span className="text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
{' '}
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
{formatDistanceToNow(new Date(site.createdAt), { locale: zhCN })}
</span>{' '}
</span>
</div>
<div className="flex items-center gap-1">
<div className="i-pajamas:expire size-3 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent"></div>
<span className="text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
:{' '}
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
{formatExpirationDate(site.expireDate)}
</span>
</span>
</div>
</div>
)}
</div>
</div>
{activeSiteIndex === index && (
<>
<div className="mt-4 pt-3 border-t border-upage-elements-borderColor"></div>
<div className="text-sm flex justify-end">
<div>
<div className="flex items-center gap-2">
{site.type === 'static' && (
<motion.button
onClick={(e) => handleDeployToSite(e, site)}
disabled={isDeploying}
className="px-4 py-2 rounded-lg h-8 bg-black dark:bg-white dark:text-black text-white text-sm hover:bg-gray-800 dark:hover:bg-gray-200 inline-flex items-center gap-2"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{isDeploying ? (
<>
<div className="i-ph:spinner-gap animate-spin size-4" />
...
</>
) : (
<>
<div className="i-ph:rocket-launch size-4" />
</>
)}
</motion.button>
)}
<Button
key="delete"
variant="destructive"
size="sm"
onClick={(e) => handleDeleteWebsite(e, site)}
disabled={isActionLoading}
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
>
<div className="i-lucide:trash size-4 text-white text-upage-elements-textPrimary dark:text-upage-elements-textPrimary" />
</Button>
</div>
</div>
</div>
</>
)}
</div>
))}
</div>
</div>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
// 根据网站类型返回对应的标签信息
const getWebsiteTypeInfo = (type: string) => {
switch (type) {
case 'deployment':
return {
label: '一键部署',
icon: 'i-ph:rocket-launch',
color: 'bg-blue-100 text-blue-700 dark:bg-blue-800/30 dark:text-blue-300',
};
case 'runtime':
return {
label: '运行环境',
icon: 'i-ph:code',
color: 'bg-green-100 text-green-700 dark:bg-green-800/30 dark:text-green-300',
};
case 'proxy':
return {
label: '反向代理',
icon: 'i-ph:arrows-left-right',
color: 'bg-purple-100 text-purple-700 dark:bg-purple-800/30 dark:text-purple-300',
};
case 'static':
return {
label: '静态网站',
icon: 'i-ph:file-html',
color: 'bg-orange-100 text-orange-700 dark:bg-orange-800/30 dark:text-orange-300',
};
case 'subsite':
return {
label: '子网站',
icon: 'i-ph:tree-structure',
color: 'bg-gray-100 text-gray-700 dark:bg-gray-800/30 dark:text-gray-300',
};
default:
return {
label: '未知类型',
icon: 'i-ph:question',
color: 'bg-gray-100 text-gray-700 dark:bg-gray-800/30 dark:text-gray-300',
};
}
};
return (
<ConnectionBorder>
{!connection.isConnect ? (
<div className="space-y-4">
<div>
<label className="block text-sm text-upage-elements-textSecondary mb-2"></label>
<input
type="text"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
disabled={fetching}
placeholder="https://your-1panel-server.com"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#333333]',
'text-upage-elements-textPrimary placeholder-upage-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-upage-elements-borderColorActive',
'disabled:opacity-50',
)}
/>
</div>
<div>
<label className="block text-sm text-upage-elements-textSecondary mb-2">API </label>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={fetching}
placeholder="请输入您的 API 密钥"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#333333]',
'text-upage-elements-textPrimary placeholder-upage-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-upage-elements-borderColorActive',
'disabled:opacity-50',
)}
/>
</div>
<button
onClick={handleConnect}
disabled={isConnecting || !serverUrl || !apiKey}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-upage-elements-button-secondary-background',
'hover:bg-upage-elements-button-secondary-backgroundHover',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
{isConnecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
...
</>
) : (
<>
<div className="i-ph:plug-charging size-4" />
</>
)}
</button>
</div>
) : (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm text-upage-elements-textSecondary flex items-center gap-1">
<div className="i-ph:check-circle size-4 text-green-500" />
1Panel
</span>
</div>
<div className="flex items-center gap-2 ml-auto">
<Button
variant="outline"
onClick={() => window.open(`${connection.serverUrl}/websites`, '_blank', 'noopener,noreferrer')}
className="flex items-center gap-2 hover:bg-upage-elements-item-backgroundActive/10 hover:text-upage-elements-textPrimary dark:hover:text-upage-elements-textPrimary transition-colors"
>
<div className="i-mingcute:dashboard-line size-4" />
</Button>
<Button
onClick={() => fetch1PanelStats()}
disabled={fetching}
variant="outline"
className="flex items-center gap-2 hover:bg-upage-elements-item-backgroundActive/10 hover:text-upage-elements-textPrimary dark:hover:text-upage-elements-textPrimary transition-colors"
>
{fetching ? (
<>
<div className="i-ph:spinner-gap size-4 animate-spin text-upage-elements-textPrimary dark:text-upage-elements-textPrimary" />
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
...
</span>
</>
) : (
<>
<div className="heroicons:arrow-path size-4 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary" />
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
</span>
</>
)}
</Button>
<Button onClick={handleDisconnect} variant="destructive" size="sm" className="flex items-center gap-2">
<div className="i-ph:sign-out size-4" />
</Button>
</div>
</div>
{renderStats()}
</div>
)}
</ConnectionBorder>
);
}

View File

@@ -0,0 +1,18 @@
import classNames from 'classnames';
import { motion } from 'framer-motion';
export default function ConnectionBorder({ className, children }: { className?: string; children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className={classNames(
'bg-upage-elements-background dark:bg-upage-elements-background border border-upage-elements-borderColor dark:border-upage-elements-borderColor rounded-lg',
className,
)}
>
<div className="p-6 space-y-6">{children}</div>
</motion.div>
);
}

View File

@@ -0,0 +1,699 @@
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 '~/lib/persistence';
import { logStore } from '~/lib/stores/logs';
import { webBuilderStore } from '~/lib/stores/web-builder';
import type { GitHubUserResponse } from '~/types/github';
import { formatSize } from '~/utils/format';
import { logger } from '~/utils/logger';
const GitHubConnection = React.lazy(() => import('~/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>
);
}