🎉 first commit
This commit is contained in:
442
app/components/header/HeaderActionButtons.tsx
Normal file
442
app/components/header/HeaderActionButtons.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useFetcher } from '@remix-run/react';
|
||||
import classNames from 'classnames';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { toast } from 'sonner';
|
||||
import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client';
|
||||
import useViewport from '~/lib/hooks';
|
||||
import { setLocalStorage } from '~/lib/persistence';
|
||||
import { aiState, setShowChat } from '~/lib/stores/ai-state';
|
||||
import { webBuilderStore } from '~/lib/stores/web-builder';
|
||||
import type { _1PanelDeployResponse } from '~/types/1panel';
|
||||
import { DeploymentPlatformEnum } from '~/types/deployment';
|
||||
import type { ApiResponse } from '~/types/global';
|
||||
import { _1PanelDeploymentLink } from '../chat/_1PanelDeploymentLink.client';
|
||||
import { VercelDeploymentLink } from '../chat/VercelDeploymentLink.client';
|
||||
import { UPageIndex } from '../upage/Index';
|
||||
import { DeployTo1PanelDialog } from './DeployTo1PanelDialog';
|
||||
import { DeployToNetlifyDialog } from './DeployToNetlifyDialog';
|
||||
import { DeployToVercelDialog } from './DeployToVercelDialog';
|
||||
|
||||
interface HeaderActionButtonsProps {}
|
||||
|
||||
export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
const showWorkbench = useStore(webBuilderStore.showWorkbench);
|
||||
const { showChat, chatId, isStreaming } = useStore(aiState);
|
||||
const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | '1panel' | null>(null);
|
||||
const isSmallViewport = useViewport(1024);
|
||||
|
||||
const canHideChat = showWorkbench || !showChat;
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const netlifyFetcher = useFetcher<ApiResponse>();
|
||||
const vercelFetcher = useFetcher<ApiResponse>();
|
||||
const panelFetcher = useFetcher<_1PanelDeployResponse>();
|
||||
|
||||
const isDeploying = useMemo(() => {
|
||||
return netlifyFetcher.state !== 'idle' || vercelFetcher.state !== 'idle' || panelFetcher.state !== 'idle';
|
||||
}, [netlifyFetcher.state, vercelFetcher.state, panelFetcher.state]);
|
||||
|
||||
const [showNetlifyDialog, setShowNetlifyDialog] = useState(false);
|
||||
const [showVercelDialog, setShowVercelDialog] = useState(false);
|
||||
const [show1PanelDialog, setShow1PanelDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href);
|
||||
const deploy = url.searchParams.get('deploy');
|
||||
switch (deploy) {
|
||||
case DeploymentPlatformEnum.NETLIFY:
|
||||
setShowNetlifyDialog(true);
|
||||
break;
|
||||
case DeploymentPlatformEnum.VERCEL:
|
||||
setShowVercelDialog(true);
|
||||
break;
|
||||
case DeploymentPlatformEnum._1PANEL:
|
||||
setShow1PanelDialog(true);
|
||||
break;
|
||||
}
|
||||
const recommend = url.searchParams.get('recommend');
|
||||
if (recommend) {
|
||||
setLocalStorage('recommend', recommend || '');
|
||||
}
|
||||
if (deploy || recommend) {
|
||||
url.searchParams.delete('deploy');
|
||||
url.searchParams.delete('recommend');
|
||||
window.history.replaceState({}, '', url);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (netlifyFetcher.state === 'idle' && netlifyFetcher.data) {
|
||||
const { data, success, message } = netlifyFetcher.data;
|
||||
|
||||
if (success && data?.deploy && data?.site) {
|
||||
if (data.site) {
|
||||
localStorage.setItem(`netlify-site-${chatId!}`, data.site?.id);
|
||||
}
|
||||
|
||||
toast.success(
|
||||
<div>
|
||||
部署成功!{' '}
|
||||
<a href={data.deploy.url} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
查看站点
|
||||
</a>
|
||||
</div>,
|
||||
);
|
||||
|
||||
setShowNetlifyDialog(false);
|
||||
} else {
|
||||
console.error('Invalid deploy response:', data);
|
||||
toast.error(message || 'Invalid deployment response');
|
||||
}
|
||||
|
||||
setDeployingTo(null);
|
||||
}
|
||||
}, [netlifyFetcher.state, netlifyFetcher.data, chatId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (vercelFetcher.state === 'idle' && vercelFetcher.data) {
|
||||
const { data, success, message } = vercelFetcher.data;
|
||||
|
||||
if (success && data?.deploy && data?.project) {
|
||||
if (data.project) {
|
||||
localStorage.setItem(`vercel-project-${chatId!}`, data.project.id);
|
||||
}
|
||||
|
||||
toast.success(
|
||||
<div>
|
||||
部署到 Vercel 成功!{' '}
|
||||
<a href={data.deploy.url} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
查看站点
|
||||
</a>
|
||||
</div>,
|
||||
);
|
||||
|
||||
setShowVercelDialog(false);
|
||||
} else {
|
||||
console.error('Invalid deploy response:', data);
|
||||
toast.error(message || 'Invalid deployment response');
|
||||
}
|
||||
|
||||
setDeployingTo(null);
|
||||
}
|
||||
}, [vercelFetcher.state, vercelFetcher.data, chatId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (panelFetcher.state === 'idle' && panelFetcher.data) {
|
||||
const data = panelFetcher.data as _1PanelDeployResponse;
|
||||
|
||||
const { deploy } = data.data || {};
|
||||
if (data.success && deploy) {
|
||||
localStorage.setItem(`1panel-project-${chatId!}`, deploy.id.toString());
|
||||
|
||||
toast.success(
|
||||
<div>
|
||||
部署到 1Panel 成功!{' '}
|
||||
<a href={deploy.url} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
查看站点
|
||||
</a>
|
||||
</div>,
|
||||
);
|
||||
|
||||
setShow1PanelDialog(false);
|
||||
} else {
|
||||
console.error('Invalid deploy response:', data);
|
||||
toast.error(data.message || 'Invalid deployment response');
|
||||
}
|
||||
|
||||
setDeployingTo(null);
|
||||
}
|
||||
}, [panelFetcher.state, panelFetcher.data, chatId]);
|
||||
|
||||
async function getAllFiles(): Promise<Record<string, string>> {
|
||||
const files = await webBuilderStore.getProjectFiles({ inline: false }).then((files) => {
|
||||
return files.reduce(
|
||||
(acc, file) => {
|
||||
acc[file.filename] = file.content;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
});
|
||||
const newFiles: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(files)) {
|
||||
if (key.endsWith('.html')) {
|
||||
const html = new DOMParser().parseFromString(value, 'text/html');
|
||||
const originalContent = html.body.innerHTML;
|
||||
// 添加 UPageHtml 到 body 中
|
||||
const uPageHtml = renderToStaticMarkup(<UPageIndex />);
|
||||
html.body.innerHTML = originalContent + uPageHtml;
|
||||
newFiles[key] = '<!DOCTYPE html>\n' + html.documentElement.outerHTML;
|
||||
} else {
|
||||
newFiles[key] = value;
|
||||
}
|
||||
}
|
||||
return newFiles;
|
||||
}
|
||||
|
||||
const handleNetlifyDeploy = useCallback(async () => {
|
||||
if (!chatId) {
|
||||
toast.error('没有找到活动聊天');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDeployingTo('netlify');
|
||||
|
||||
const fileContents = await getAllFiles();
|
||||
const existingSiteId = localStorage.getItem(`netlify-site-${chatId}`);
|
||||
|
||||
netlifyFetcher.submit(
|
||||
{
|
||||
siteId: existingSiteId || '',
|
||||
files: fileContents,
|
||||
chatId: chatId!,
|
||||
} as any,
|
||||
{
|
||||
method: 'POST',
|
||||
action: '/api/netlify/deploy',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Deploy error:', error);
|
||||
toast.error(error instanceof Error ? error.message : '部署失败');
|
||||
setDeployingTo(null);
|
||||
}
|
||||
}, [chatId, netlifyFetcher]);
|
||||
|
||||
const handleVercelDeploy = useCallback(async () => {
|
||||
if (!chatId) {
|
||||
toast.error('没有找到活动聊天');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDeployingTo('vercel');
|
||||
|
||||
const fileContents = await getAllFiles();
|
||||
const existingProjectId = localStorage.getItem(`vercel-project-${chatId}`);
|
||||
|
||||
vercelFetcher.submit(
|
||||
{
|
||||
projectId: existingProjectId || '',
|
||||
files: fileContents,
|
||||
chatId: chatId!,
|
||||
} as any,
|
||||
{
|
||||
method: 'POST',
|
||||
action: '/api/vercel/deploy',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Vercel deploy error:', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Vercel 部署失败');
|
||||
setDeployingTo(null);
|
||||
}
|
||||
}, [chatId, vercelFetcher]);
|
||||
|
||||
const handle1PanelDeploy = useCallback(
|
||||
async (options?: { customDomain?: string; siteId?: number; protocol?: string }) => {
|
||||
if (!chatId) {
|
||||
toast.error('没有找到活动聊天');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDeployingTo('1panel');
|
||||
|
||||
const fileContents = await getAllFiles();
|
||||
const existingWebsiteId = localStorage.getItem(`1panel-project-${chatId}`);
|
||||
|
||||
panelFetcher.submit(
|
||||
{
|
||||
websiteId: options?.siteId || existingWebsiteId || '',
|
||||
websiteDomain: options?.customDomain || '',
|
||||
protocol: options?.protocol || 'http',
|
||||
files: fileContents,
|
||||
chatId: chatId!,
|
||||
} as any,
|
||||
{
|
||||
method: 'POST',
|
||||
action: '/api/1panel/deploy',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('1Panel deploy error:', error);
|
||||
toast.error(error instanceof Error ? error.message : '1Panel 部署失败');
|
||||
setDeployingTo(null);
|
||||
}
|
||||
},
|
||||
[chatId, panelFetcher],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<div className="flex border border-upage-elements-borderColor rounded-md overflow-hidden mr-2 text-sm">
|
||||
<Button
|
||||
active
|
||||
disabled={isDeploying || isStreaming}
|
||||
onClick={() => {
|
||||
if (isDeploying || isStreaming) {
|
||||
return;
|
||||
}
|
||||
setShow1PanelDialog(true);
|
||||
}}
|
||||
className="px-4 hover:bg-upage-elements-item-backgroundActive flex items-center gap-2"
|
||||
>
|
||||
<div className="i-mingcute:rocket-line size-4" />
|
||||
{isDeploying ? `部署至 ${deployingTo} 中...` : '部署'}
|
||||
</Button>
|
||||
<div className="w-[1px] bg-upage-elements-borderColor" />
|
||||
<Button
|
||||
active
|
||||
disabled={isDeploying || isStreaming}
|
||||
onClick={() => {
|
||||
if (isDeploying || isStreaming) {
|
||||
return;
|
||||
}
|
||||
setIsDropdownOpen(!isDropdownOpen);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={classNames('i-ph:caret-down size-4 transition-transform', isDropdownOpen ? 'rotate-180' : '')}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute right-2 flex flex-col gap-1 z-50 p-1 mt-1 min-w-[14rem] bg-upage-elements-background-depth-2 rounded-md shadow-lg bg-upage-elements-backgroundDefault border border-upage-elements-borderColor">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShow1PanelDialog(true);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
disabled={isDeploying}
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-upage-elements-textPrimary gap-3 rounded-md group relative"
|
||||
>
|
||||
<img src="/icons/1panel.png" alt="1Panel" className="size-5" />
|
||||
<span>部署到 1Panel</span>
|
||||
<_1PanelDeploymentLink />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowNetlifyDialog(true);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
disabled={isDeploying}
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-upage-elements-textPrimary gap-3 rounded-md group relative"
|
||||
>
|
||||
<div className="i-simple-icons:netlify size-5 bg-#00C7B7"></div>
|
||||
<span>部署到 Netlify</span>
|
||||
<NetlifyDeploymentLink />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowVercelDialog(true);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
disabled={isDeploying}
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-upage-elements-textPrimary gap-3 rounded-md group relative"
|
||||
>
|
||||
<div className="i-skill-icons:vercel-light size-5"></div>
|
||||
<span>部署到 Vercel</span>
|
||||
<VercelDeploymentLink />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex border border-upage-elements-borderColor rounded-md overflow-hidden mr-2">
|
||||
<Button
|
||||
active={showChat}
|
||||
disabled={!canHideChat || isSmallViewport}
|
||||
onClick={() => {
|
||||
if (canHideChat) {
|
||||
setShowChat(!showChat);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="i-mingcute:chat-2-line text-sm" />
|
||||
</Button>
|
||||
<div className="w-[1px] bg-upage-elements-borderColor" />
|
||||
<Button
|
||||
active={showWorkbench}
|
||||
onClick={() => {
|
||||
if (showWorkbench && !showChat) {
|
||||
setShowChat(true);
|
||||
}
|
||||
|
||||
webBuilderStore.showWorkbench.set(!showWorkbench);
|
||||
}}
|
||||
>
|
||||
<div className="i-mingcute:code-line" />
|
||||
</Button>
|
||||
</div>
|
||||
<DeployToNetlifyDialog
|
||||
isOpen={showNetlifyDialog}
|
||||
deploying={isDeploying}
|
||||
onClose={() => setShowNetlifyDialog(false)}
|
||||
onDeploy={handleNetlifyDeploy}
|
||||
/>
|
||||
<DeployToVercelDialog
|
||||
isOpen={showVercelDialog}
|
||||
deploying={isDeploying}
|
||||
onClose={() => setShowVercelDialog(false)}
|
||||
onDeploy={handleVercelDeploy}
|
||||
/>
|
||||
<DeployTo1PanelDialog
|
||||
isOpen={show1PanelDialog}
|
||||
deploying={isDeploying}
|
||||
onClose={() => setShow1PanelDialog(false)}
|
||||
onDeploy={handle1PanelDeploy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ButtonProps {
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
children?: any;
|
||||
onClick?: VoidFunction;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={classNames(
|
||||
'flex items-center p-1.5',
|
||||
{
|
||||
'bg-upage-elements-item-backgroundDefault hover:bg-upage-elements-item-backgroundAccent text-upage-elements-textPrimary hover:text-upage-elements-item-contentAccent':
|
||||
!active,
|
||||
'bg-upage-elements-item-backgroundAccent text-upage-elements-item-contentAccent': active && !disabled,
|
||||
'bg-upage-elements-item-backgroundAccent text-upage-elements-item-contentAccent cursor-not-allowed':
|
||||
active && disabled,
|
||||
'bg-upage-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed':
|
||||
!active && disabled,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user