← vibesdk
src/routes/chat/components/deployment-controls.tsx
17.3 KB
Download file
import { useRef, useEffect, useState } from 'react';
import { Button } from '../../../components/primitives/button';
import { Loader, ExternalLink, Zap, Check, Globe, Lock, Share2 } from 'lucide-react';
import { cn } from '@cloudflare/kumo';
import { apiClient } from '../../../lib/api-client';
import { toast } from 'sonner';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
interface DeploymentControlsProps {
// Deployment state
isPhase1Complete: boolean;
isDeploying: boolean;
deploymentUrl?: string;
instanceId: string;
isRedeployReady: boolean;
deploymentError?: string;
// App state
appId?: string;
appVisibility?: 'public' | 'private';
// Generation state (kept for compatibility but pause button will not be rendered)
isGenerating: boolean;
isPaused: boolean;
// Actions
onDeploy: (instanceId: string) => void;
onStopGeneration: () => void;
onResumeGeneration: () => void;
onVisibilityUpdate?: (newVisibility: 'public' | 'private') => void;
deploymentTarget?: 'platform' | 'user';
}
// Deployment state enum for better state management
enum DeploymentState {
WAITING_PHASE1 = 'waiting_phase1',
READY_TO_DEPLOY = 'ready_to_deploy',
DEPLOYING = 'deploying',
DEPLOYED = 'deployed',
REDEPLOYING = 'redeploying',
ERROR = 'error'
}
export function DeploymentControls({
isPhase1Complete,
isDeploying,
deploymentUrl,
instanceId,
isRedeployReady,
deploymentError,
appId,
appVisibility = 'private',
onDeploy,
onVisibilityUpdate,
deploymentTarget = 'platform',
}: DeploymentControlsProps) {
const [isDeployButtonClicked, setIsDeployButtonClicked] = useState(false);
const [isUpdatingVisibility, setIsUpdatingVisibility] = useState(false);
const [localVisibility, setLocalVisibility] = useState(appVisibility);
const deploymentRef = useRef<HTMLDivElement>(null);
const { copied: urlCopied, copy: copyUrl } = useCopyToClipboard();
const { copied: linkCopied, copy: copyLink } = useCopyToClipboard();
// Reset deployment button state when deployment completes (success or failure)
useEffect(() => {
if (!isDeploying) {
setIsDeployButtonClicked(false);
}
}, [isDeploying]);
// Sync local visibility with prop
useEffect(() => {
setLocalVisibility(appVisibility);
}, [appVisibility]);
// Determine current deployment state with proper logic
const getCurrentDeploymentState = (): DeploymentState => {
if (deploymentError && !isDeploying) {
return DeploymentState.ERROR;
}
if (isDeploying) {
if (deploymentUrl) {
return DeploymentState.REDEPLOYING;
} else {
return DeploymentState.DEPLOYING;
}
}
if (deploymentUrl && !isDeploying) {
return DeploymentState.DEPLOYED;
}
if (isPhase1Complete) {
return DeploymentState.READY_TO_DEPLOY;
}
return DeploymentState.WAITING_PHASE1;
};
const currentState = getCurrentDeploymentState();
const destination = deploymentTarget === 'user'
? 'your Cloudflare account'
: 'Cloudflare Workers for Platforms';
const handleDeploy = () => {
setIsDeployButtonClicked(true);
// Smooth scroll animation to deployment section
if (deploymentRef.current) {
deploymentRef.current.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}
onDeploy(instanceId);
};
const handleViewLive = async () => {
if (!deploymentUrl) return;
// Public apps (or missing appId): open the deployed URL directly.
if (deploymentTarget === 'user' || localVisibility !== 'private' || !appId) {
window.open(deploymentUrl, '_blank', 'noopener,noreferrer');
return;
}
// Private apps are gated at the dispatch layer, so mint a short-lived,
// deployment-scoped owner-preview token and open the tokenized URL.
try {
const response = await apiClient.generatePreviewToken(appId);
if (response.success && response.data) {
window.open(response.data.previewUrl, '_blank', 'noopener,noreferrer');
return;
}
} catch (error) {
console.error('Failed to generate owner preview token:', error);
}
// Fallback: attempt the raw URL.
window.open(deploymentUrl, '_blank', 'noopener,noreferrer');
};
const handleToggleVisibility = async () => {
if (!appId) {
toast.error('App ID not found');
return;
}
try {
setIsUpdatingVisibility(true);
const newVisibility = localVisibility === 'private' ? 'public' : 'private';
const response = await apiClient.updateAppVisibility(appId, newVisibility);
if (response.success && response.data) {
setLocalVisibility(newVisibility);
onVisibilityUpdate?.(newVisibility);
if (newVisibility === 'public') {
toast.success('🎉 Your app is now public! Share the link with anyone.');
} else {
toast.success('App is now private');
}
} else {
throw new Error(response.error?.message || 'Failed to update visibility');
}
} catch (error) {
console.error('Error updating app visibility:', error);
toast.error('Failed to update visibility');
} finally {
setIsUpdatingVisibility(false);
}
};
// State-based styling and content
const getStateConfig = (state: DeploymentState) => {
switch (state) {
case DeploymentState.WAITING_PHASE1:
return {
panelClass: "bg-kumo-base/30 dark:bg-kumo-base/20 dark:border-border-primary/40",
iconClass: "bg-kumo-base-foreground/40 dark:bg-kumo-base-foreground/30 border-muted-foreground/40 dark:border-muted-foreground/30",
icon: null,
titleColor: "text-text-tertiary dark:text-text-tertiary",
subtitleColor: "text-text-tertiary/80 dark:text-text-tertiary/70",
title: "Deploy to Cloudflare",
subtitle: deploymentTarget === 'user'
? "Deploy will be enabled after the app has files"
: "Deploy will be enabled after Phase 1 is implemented",
buttonDisabled: true,
buttonVariant: "secondary" as const,
buttonClass: "bg-kumo-base dark:bg-kumo-base text-text-tertiary dark:text-text-tertiary border-muted dark:border-muted cursor-not-allowed"
};
case DeploymentState.READY_TO_DEPLOY:
return {
panelClass: "bg-brand/5 dark:bg-brand/10 border-brand/20 dark:border-brand/20",
iconClass: "bg-brand border-brand",
icon: <Zap className="w-2.5 h-2.5 text-white" />,
titleColor: "text-text-primary dark:text-text-primary",
subtitleColor: "text-text-tertiary dark:text-text-tertiary",
title: "Ready to Deploy",
subtitle: `Publishes a standalone Worker to ${destination}`,
buttonDisabled: false,
buttonVariant: "primary" as const,
buttonClass: "bg-brand text-white border-orange-500 dark:border-orange-600 hover:scale-105"
};
case DeploymentState.DEPLOYING:
return {
panelClass: "bg-blue-50/40 dark:bg-blue-950/20 border-blue-200/60 dark:border-blue-800/30 shadow-sm dark:shadow-blue-900/20",
iconClass: "bg-blue-500 dark:bg-blue-600 border-blue-500 dark:border-blue-600 animate-pulse",
icon: <Loader className="w-2.5 h-2.5 text-white animate-spin" />,
titleColor: "text-blue-900 dark:text-blue-100",
subtitleColor: "text-blue-600 dark:text-blue-300",
title: "Deploying to Cloudflare",
subtitle: `Publishing your application to ${destination}...`,
buttonDisabled: true,
buttonVariant: "primary" as const,
buttonClass: "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white border-blue-500 dark:border-blue-600 scale-105 shadow-lg dark:shadow-blue-900/50"
};
case DeploymentState.REDEPLOYING:
return {
panelClass: "bg-blue-50/40 dark:bg-blue-950/20 border-blue-200/60 dark:border-blue-800/30 shadow-sm dark:shadow-blue-900/20",
iconClass: "bg-blue-500 dark:bg-blue-600 border-blue-500 dark:border-blue-600 animate-pulse",
icon: <Loader className="w-2.5 h-2.5 text-white animate-spin" />,
titleColor: "text-blue-900 dark:text-blue-100",
subtitleColor: "text-blue-600 dark:text-blue-300",
title: "Redeploying to Cloudflare",
subtitle: `Updating your application in ${destination}...`,
buttonDisabled: true,
buttonVariant: "primary" as const,
buttonClass: "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white border-blue-500 dark:border-blue-600 scale-105 shadow-lg dark:shadow-blue-900/50"
};
case DeploymentState.ERROR:
return {
panelClass: "bg-red-50/40 dark:bg-red-950/20 border-red-200/60 dark:border-red-800/30 shadow-sm dark:shadow-red-900/20",
iconClass: "bg-red-500 dark:bg-red-600 border-red-500 dark:border-red-600",
icon: <svg className="w-2.5 h-2.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>,
titleColor: "text-red-900 dark:text-red-100",
subtitleColor: "text-red-600 dark:text-red-300",
title: "❌ Deployment Failed",
subtitle: "Error in deployment, please try again",
buttonDisabled: !isPhase1Complete,
buttonVariant: "primary" as const,
buttonClass: isPhase1Complete
? "bg-orange-500 hover:bg-orange-600 dark:bg-orange-600 dark:hover:bg-orange-700 text-white border-orange-500 dark:border-orange-600 hover:scale-105"
: "bg-kumo-base dark:bg-kumo-base text-text-tertiary dark:text-text-tertiary border-muted dark:border-muted cursor-not-allowed"
};
default:
return getStateConfig(DeploymentState.WAITING_PHASE1);
}
};
const stateConfig = getStateConfig(currentState);
const isCurrentlyDeploying = currentState === DeploymentState.DEPLOYING || currentState === DeploymentState.REDEPLOYING;
return (
<div className="space-y-3 deployment-controls">
{/* Main Deployment Panel - Always visible, changes based on state */}
{currentState !== DeploymentState.DEPLOYED && (
<div
ref={deploymentRef}
className={cn(
"border rounded-lg p-3 transition-all duration-500 mt-2",
stateConfig.panelClass
)}
>
<div className="flex items-center gap-3">
{/* Enhanced Status Icon with deployment state */}
<div className={cn(
"flex-shrink-0 w-4 h-4 rounded border-2 flex items-center justify-center transition-all duration-500",
stateConfig.iconClass
)}>
{stateConfig.icon}
</div>
{/* Enhanced Deployment Section Content */}
<div className="flex-1">
<div className={cn(
"text-sm font-medium transition-colors duration-300",
stateConfig.titleColor
)}>
{stateConfig.title}
</div>
<div className={cn(
"text-xs mt-0.5 transition-colors duration-300",
stateConfig.subtitleColor
)}>
{stateConfig.subtitle}
</div>
</div>
{/* Enhanced Deploy Button - Always visible, state-aware */}
<Button
onClick={handleDeploy}
disabled={stateConfig.buttonDisabled || isCurrentlyDeploying || isDeployButtonClicked}
className={cn(
"h-8 px-4 text-sm font-medium transition-all duration-300 transform",
stateConfig.buttonClass
)}
>
{isCurrentlyDeploying ? (
<>
<Loader className="w-4 h-4 mr-2 animate-spin" />
{currentState === DeploymentState.REDEPLOYING ? 'Redeploying...' : 'Deploying...'}
</>
) : (
<>
<Zap className="w-4 h-4 mr-2" />
{deploymentTarget === 'user' ? 'Deploy to My Account' : 'Deploy to Cloudflare'}
</>
)}
</Button>
</div>
</div>
)}
{/* Deployed Success State - Enhanced with Visibility Toggle */}
{currentState === DeploymentState.DEPLOYED && (
<div
ref={deploymentRef}
className="border rounded-lg p-4 bg-gradient-to-r from-green-50/40 to-emerald-50/40 dark:from-green-950/20 dark:to-emerald-950/20 border-green-200/60 dark:border-green-800/30 transition-all duration-700 mt-2 animate-in slide-in-from-top-2 shadow-sm dark:shadow-green-900/20"
>
<div className="flex items-center gap-3 mb-3">
{/* Success Icon with animation */}
<div className="flex-shrink-0 w-5 h-5 bg-green-500 border-2 border-green-500 rounded-full flex items-center justify-center animate-in zoom-in-50 duration-500">
<Check className="w-3 h-3 text-white" />
</div>
{/* Success Header */}
<div className="flex-1">
<div className="text-sm font-semibold text-green-900 dark:text-green-100">
🎉 Successfully Deployed!
</div>
<div className="text-xs text-green-700 dark:text-green-300 mt-0.5">
Your application is now live in {destination}
</div>
</div>
</div>
{/* Elegant URL Display */}
<div className="bg-kumo-base/60 dark:bg-bg-4/60 border border-green-200/40 dark:border-green-800/20 rounded-md p-3 mb-3">
<div className="text-xs text-green-600 dark:text-green-400 font-medium mb-1">Live URL:</div>
<div className="flex items-center gap-2">
<code className="flex-1 text-sm font-mono text-green-800 dark:text-green-200 bg-green-50/50 dark:bg-green-950/30 px-2 py-1 rounded text-ellipsis overflow-hidden">
{deploymentUrl}
</code>
<Button
onClick={() => deploymentUrl && copyUrl(deploymentUrl)}
variant="secondary"
className="h-7 px-2 text-xs bg-kumo-base dark:bg-bg-4 border border-green-300 dark:border-green-300/50 text-green-700 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-950 hover:border-green-400 transition-all flex-shrink-0"
>
{urlCopied ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
{/* Shareable Link - Only shown when app is public */}
{deploymentTarget === 'platform' && localVisibility === 'public' && appId && (
<div className="bg-brand/5 border border-brand/20 rounded-md p-3 mb-3">
<div className="text-xs text-kumo-brand font-medium mb-1 flex items-center gap-1">
<Share2 className="w-3 h-3" />
Shareable Link:
</div>
<div className="flex items-center gap-2">
<code className="flex-1 text-sm font-mono text-kumo-brand bg-brand/5 px-2 py-1 rounded text-ellipsis overflow-hidden">
{window.location.origin}/app/{appId}
</code>
<Button
onClick={() => copyLink(`${window.location.origin}/app/${appId}`)}
variant="secondary"
className="h-7 px-2 text-xs bg-brand/10 border border-brand/30 text-kumo-brand hover:bg-brand/20 transition-all flex-shrink-0"
>
{linkCopied ? 'Copied!' : 'Copy Link'}
</Button>
</div>
</div>
)}
{/* Action Buttons - Enhanced with visibility toggle */}
<div className={cn(
"grid gap-3",
isRedeployReady ? "grid-cols-3" : "grid-cols-2"
)}>
{/* View Live Site Button */}
<Button
onClick={handleViewLive}
variant="primary"
className="h-10 text-sm bg-green-600 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-800 text-white border-green-600 dark:border-green-700 font-medium shadow-sm hover:shadow-md dark:hover:shadow-green-900/50 transition-all duration-200 hover:scale-[1.02]"
>
<ExternalLink className="w-4 h-4 mr-2" />
View Live
</Button>
{/* Make Public/Private Button - Always visible after deployment */}
{deploymentTarget === 'platform' && appId && (
<Button
onClick={handleToggleVisibility}
disabled={isUpdatingVisibility}
variant="secondary"
className={cn(
"h-10 text-sm font-medium transition-all duration-200 shadow-sm",
localVisibility === 'private'
? "bg-brand hover:bg-brand/90 text-white border-brand hover:shadow-md hover:scale-[1.02]"
: "bg-kumo-base hover:bg-bg-4 text-text-primary hover:shadow-sm hover:scale-[1.02]"
)}
>
{isUpdatingVisibility ? (
<>
<Loader className="w-4 h-4 mr-2 animate-spin" />
Updating...
</>
) : localVisibility === 'private' ? (
<>
<Globe className="w-4 h-4 mr-2" />
Make Public
</>
) : (
<>
<Lock className="w-4 h-4 mr-2" />
Make Private
</>
)}
</Button>
)}
{/* Redeploy Button - Only shown when changes are made */}
{isRedeployReady && (
<Button
onClick={handleDeploy}
disabled={isDeploying || isDeployButtonClicked}
variant="secondary"
className={cn(
"h-10 text-sm font-medium transition-all duration-200 shadow-sm",
!isDeploying
? "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white border-blue-500 dark:border-blue-600 hover:shadow-md dark:hover:shadow-blue-900/50 hover:scale-[1.02]"
: "bg-kumo-base dark:bg-kumo-base text-text-tertiary dark:text-text-tertiary border-muted dark:border-muted cursor-not-allowed"
)}
>
{isDeploying ? (
<>
<Loader className="w-4 h-4 mr-2 animate-spin" />
Redeploying...
</>
) : (
<>
<Zap className="w-4 h-4 mr-2" />
Redeploy
</>
)}
</Button>
)}
</div>
</div>
)}
</div>
);
}