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

701 lines
35 KiB
TypeScript

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 '~/.client/components/ui/Badge';
import { Button } from '~/.client/components/ui/Button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '~/.client/components/ui/Collapsible';
import {
fetchNetlifyStats,
isFetchingStats,
netlifyConnection,
updateNetlifyConnection,
} from '~/.client/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: 'i-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: 'i-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="i-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="i-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="i-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="i-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(
'i-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="i-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="i-heroicons:check-circle size-4 text-green-500" />
) : (
<div className="i-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="i-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="i-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="i-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="i-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="i-heroicons:check-circle size-4 text-green-500" />
) : deploy.state === 'error' ? (
<div className="i-heroicons:x-circle size-4 text-red-500" />
) : (
<div className="i-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="i-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="i-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="i-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="i-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="i-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="i-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="i-heroicons:check-circle size-4" />
) : build.error ? (
<div className="i-heroicons:x-circle size-4" />
) : (
<div className="i-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="i-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',
'text-upage-elements-textPrimary',
)}
>
{isConnecting ? (
<>
<div className="i-ph:spinner-gap animate-spin size-4" />
...
</>
) : (
<>
<div className="i-ph:plug-charging 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="i-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="i-heroicons:academic-cap-solid 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>
);
}