Create Publish panel to easy GitHub and Vercel push (#655)
This commit is contained in:
@@ -5,7 +5,6 @@ import {
|
||||
Clipboard,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
@@ -32,6 +31,7 @@ import { Label } from "@/components/ui/label";
|
||||
interface GitHubConnectorProps {
|
||||
appId: number | null;
|
||||
folderName: string;
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
interface GitHubRepo {
|
||||
@@ -57,6 +57,7 @@ interface UnconnectedGitHubConnectorProps {
|
||||
settings: any;
|
||||
refreshSettings: () => void;
|
||||
refreshApp: () => void;
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
function ConnectedGitHubConnector({
|
||||
@@ -112,10 +113,7 @@ function ConnectedGitHubConnector({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mt-4 w-full border border-gray-200 rounded-md p-4"
|
||||
data-testid="github-connected-repo"
|
||||
>
|
||||
<div className="w-full" data-testid="github-connected-repo">
|
||||
<p>Connected to GitHub Repo:</p>
|
||||
<a
|
||||
onClick={(e) => {
|
||||
@@ -271,9 +269,10 @@ function UnconnectedGitHubConnector({
|
||||
settings,
|
||||
refreshSettings,
|
||||
refreshApp,
|
||||
expanded,
|
||||
}: UnconnectedGitHubConnectorProps) {
|
||||
// --- Collapsible State ---
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(expanded || false);
|
||||
|
||||
// --- GitHub Device Flow State ---
|
||||
const [githubUserCode, setGithubUserCode] = useState<string | null>(null);
|
||||
@@ -636,22 +635,19 @@ function UnconnectedGitHubConnector({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mt-4 w-full border border-gray-200 rounded-md"
|
||||
data-testid="github-setup-repo"
|
||||
>
|
||||
<div className="w-full" data-testid="github-setup-repo">
|
||||
{/* Collapsible Header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={`cursor-pointer w-full p-4 text-left transition-colors rounded-md flex items-center justify-between ${
|
||||
!isExpanded ? "hover:bg-gray-50 dark:hover:bg-gray-800/50" : ""
|
||||
onClick={!isExpanded ? () => setIsExpanded(true) : undefined}
|
||||
className={`w-full p-4 text-left transition-colors rounded-md flex items-center justify-between ${
|
||||
!isExpanded
|
||||
? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">Set up your GitHub repo</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-gray-500" />
|
||||
) : (
|
||||
{isExpanded ? undefined : (
|
||||
<ChevronRight className="h-4 w-4 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
@@ -879,7 +875,11 @@ function UnconnectedGitHubConnector({
|
||||
);
|
||||
}
|
||||
|
||||
export function GitHubConnector({ appId, folderName }: GitHubConnectorProps) {
|
||||
export function GitHubConnector({
|
||||
appId,
|
||||
folderName,
|
||||
expanded,
|
||||
}: GitHubConnectorProps) {
|
||||
const { app, refreshApp } = useLoadApp(appId);
|
||||
const { settings, refreshSettings } = useSettings();
|
||||
|
||||
@@ -899,6 +899,7 @@ export function GitHubConnector({ appId, folderName }: GitHubConnectorProps) {
|
||||
settings={settings}
|
||||
refreshSettings={refreshSettings}
|
||||
refreshApp={refreshApp}
|
||||
expanded={expanded}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
680
src/components/VercelConnector.tsx
Normal file
680
src/components/VercelConnector.tsx
Normal file
@@ -0,0 +1,680 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Globe } from "lucide-react";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useLoadApp } from "@/hooks/useLoadApp";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { App, VercelDeployment } from "@/ipc/ipc_types";
|
||||
|
||||
interface VercelConnectorProps {
|
||||
appId: number | null;
|
||||
folderName: string;
|
||||
}
|
||||
|
||||
interface VercelProject {
|
||||
id: string;
|
||||
name: string;
|
||||
framework: string | null;
|
||||
}
|
||||
|
||||
interface ConnectedVercelConnectorProps {
|
||||
appId: number;
|
||||
app: App;
|
||||
refreshApp: () => void;
|
||||
}
|
||||
|
||||
interface UnconnectedVercelConnectorProps {
|
||||
appId: number | null;
|
||||
folderName: string;
|
||||
settings: any;
|
||||
refreshSettings: () => void;
|
||||
refreshApp: () => void;
|
||||
}
|
||||
|
||||
function ConnectedVercelConnector({
|
||||
appId,
|
||||
app,
|
||||
refreshApp,
|
||||
}: ConnectedVercelConnectorProps) {
|
||||
const [isLoadingDeployments, setIsLoadingDeployments] = useState(false);
|
||||
const [deploymentsError, setDeploymentsError] = useState<string | null>(null);
|
||||
const [deployments, setDeployments] = useState<VercelDeployment[]>([]);
|
||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
||||
const [disconnectError, setDisconnectError] = useState<string | null>(null);
|
||||
|
||||
const handleDisconnectProject = async () => {
|
||||
setIsDisconnecting(true);
|
||||
setDisconnectError(null);
|
||||
try {
|
||||
await IpcClient.getInstance().disconnectVercelProject({ appId });
|
||||
refreshApp();
|
||||
} catch (err: any) {
|
||||
setDisconnectError(err.message || "Failed to disconnect project.");
|
||||
} finally {
|
||||
setIsDisconnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGetDeployments = async () => {
|
||||
setIsLoadingDeployments(true);
|
||||
setDeploymentsError(null);
|
||||
|
||||
try {
|
||||
const result = await IpcClient.getInstance().getVercelDeployments({
|
||||
appId,
|
||||
});
|
||||
setDeployments(result);
|
||||
} catch (err: any) {
|
||||
setDeploymentsError(
|
||||
err.message || "Failed to get deployments from Vercel.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoadingDeployments(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mt-4 w-full rounded-md"
|
||||
data-testid="vercel-connected-project"
|
||||
>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Connected to Vercel Project:
|
||||
</p>
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
IpcClient.getInstance().openExternalUrl(
|
||||
`https://vercel.com/${app.vercelTeamSlug}/${app.vercelProjectName}`,
|
||||
);
|
||||
}}
|
||||
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{app.vercelProjectName}
|
||||
</a>
|
||||
{app.vercelDeploymentUrl && (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Live URL:{" "}
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (app.vercelDeploymentUrl) {
|
||||
IpcClient.getInstance().openExternalUrl(
|
||||
app.vercelDeploymentUrl,
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400 font-mono"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{app.vercelDeploymentUrl}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button onClick={handleGetDeployments} disabled={isLoadingDeployments}>
|
||||
{isLoadingDeployments ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin h-5 w-5 mr-2 inline"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ display: "inline" }}
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Getting Deployments...
|
||||
</>
|
||||
) : (
|
||||
"Get Deployments"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDisconnectProject}
|
||||
disabled={isDisconnecting}
|
||||
variant="outline"
|
||||
>
|
||||
{isDisconnecting ? "Disconnecting..." : "Disconnect from project"}
|
||||
</Button>
|
||||
</div>
|
||||
{deploymentsError && (
|
||||
<div className="mt-2">
|
||||
<p className="text-red-600">{deploymentsError}</p>
|
||||
</div>
|
||||
)}
|
||||
{deployments.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="font-medium mb-2">Recent Deployments:</h4>
|
||||
<div className="space-y-2">
|
||||
{deployments.map((deployment) => (
|
||||
<div
|
||||
key={deployment.uid}
|
||||
className="bg-gray-50 dark:bg-gray-800 rounded-md p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
deployment.readyState === "READY"
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300"
|
||||
: deployment.readyState === "BUILDING"
|
||||
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300"
|
||||
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{deployment.readyState}
|
||||
</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{new Date(deployment.createdAt * 1000).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
IpcClient.getInstance().openExternalUrl(
|
||||
`https://${deployment.url}`,
|
||||
);
|
||||
}}
|
||||
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400 text-sm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Globe className="h-4 w-4 inline mr-1" />
|
||||
View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{disconnectError && (
|
||||
<p className="text-red-600 mt-2">{disconnectError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UnconnectedVercelConnector({
|
||||
appId,
|
||||
folderName,
|
||||
settings,
|
||||
refreshSettings,
|
||||
refreshApp,
|
||||
}: UnconnectedVercelConnectorProps) {
|
||||
// --- Manual Token Entry State ---
|
||||
const [accessToken, setAccessToken] = useState("");
|
||||
const [isSavingToken, setIsSavingToken] = useState(false);
|
||||
const [tokenError, setTokenError] = useState<string | null>(null);
|
||||
const [tokenSuccess, setTokenSuccess] = useState(false);
|
||||
|
||||
// --- Project Setup State ---
|
||||
const [projectSetupMode, setProjectSetupMode] = useState<
|
||||
"create" | "existing"
|
||||
>("create");
|
||||
const [availableProjects, setAvailableProjects] = useState<VercelProject[]>(
|
||||
[],
|
||||
);
|
||||
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
||||
const [selectedProject, setSelectedProject] = useState<string>("");
|
||||
|
||||
// Create new project state
|
||||
const [projectName, setProjectName] = useState(folderName);
|
||||
const [projectAvailable, setProjectAvailable] = useState<boolean | null>(
|
||||
null,
|
||||
);
|
||||
const [projectCheckError, setProjectCheckError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isCheckingProject, setIsCheckingProject] = useState(false);
|
||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||
const [createProjectError, setCreateProjectError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [createProjectSuccess, setCreateProjectSuccess] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Load available projects when Vercel is connected
|
||||
useEffect(() => {
|
||||
if (settings?.vercelAccessToken && projectSetupMode === "existing") {
|
||||
loadAvailableProjects();
|
||||
}
|
||||
}, [settings?.vercelAccessToken, projectSetupMode]);
|
||||
|
||||
// Cleanup debounce timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadAvailableProjects = async () => {
|
||||
setIsLoadingProjects(true);
|
||||
try {
|
||||
const projects = await IpcClient.getInstance().listVercelProjects();
|
||||
setAvailableProjects(projects);
|
||||
} catch (error) {
|
||||
console.error("Failed to load Vercel projects:", error);
|
||||
} finally {
|
||||
setIsLoadingProjects(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAccessToken = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!accessToken.trim()) return;
|
||||
|
||||
setIsSavingToken(true);
|
||||
setTokenError(null);
|
||||
setTokenSuccess(false);
|
||||
|
||||
try {
|
||||
await IpcClient.getInstance().saveVercelAccessToken({
|
||||
token: accessToken.trim(),
|
||||
});
|
||||
setTokenSuccess(true);
|
||||
setAccessToken("");
|
||||
refreshSettings();
|
||||
} catch (err: any) {
|
||||
setTokenError(err.message || "Failed to save access token.");
|
||||
} finally {
|
||||
setIsSavingToken(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkProjectAvailability = useCallback(async (name: string) => {
|
||||
setProjectCheckError(null);
|
||||
setProjectAvailable(null);
|
||||
if (!name) return;
|
||||
setIsCheckingProject(true);
|
||||
try {
|
||||
const result = await IpcClient.getInstance().isVercelProjectAvailable({
|
||||
name,
|
||||
});
|
||||
setProjectAvailable(result.available);
|
||||
if (!result.available) {
|
||||
setProjectCheckError(result.error || "Project name is not available.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setProjectCheckError(
|
||||
err.message || "Failed to check project availability.",
|
||||
);
|
||||
} finally {
|
||||
setIsCheckingProject(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const debouncedCheckProjectAvailability = useCallback(
|
||||
(name: string) => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
checkProjectAvailability(name);
|
||||
}, 500);
|
||||
},
|
||||
[checkProjectAvailability],
|
||||
);
|
||||
|
||||
const handleSetupProject = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!appId) return;
|
||||
|
||||
setCreateProjectError(null);
|
||||
setIsCreatingProject(true);
|
||||
setCreateProjectSuccess(false);
|
||||
|
||||
try {
|
||||
if (projectSetupMode === "create") {
|
||||
await IpcClient.getInstance().createVercelProject({
|
||||
name: projectName,
|
||||
appId,
|
||||
});
|
||||
} else {
|
||||
await IpcClient.getInstance().connectToExistingVercelProject({
|
||||
projectId: selectedProject,
|
||||
appId,
|
||||
});
|
||||
}
|
||||
setCreateProjectSuccess(true);
|
||||
setProjectCheckError(null);
|
||||
refreshApp();
|
||||
} catch (err: any) {
|
||||
setCreateProjectError(
|
||||
err.message ||
|
||||
`Failed to ${projectSetupMode === "create" ? "create" : "connect to"} project.`,
|
||||
);
|
||||
} finally {
|
||||
setIsCreatingProject(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!settings?.vercelAccessToken) {
|
||||
return (
|
||||
<div className="mt-1 w-full" data-testid="vercel-unconnected-project">
|
||||
<div className="w-ful">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="font-medium">Connect to Vercel</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md p-3">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200 mb-2">
|
||||
To connect your app to Vercel, you'll need to create an access
|
||||
token:
|
||||
</p>
|
||||
<ol className="list-decimal list-inside text-sm text-blue-700 dark:text-blue-300 space-y-1">
|
||||
<li>If you don't have a Vercel account, sign up first</li>
|
||||
<li>Go to Vercel settings to create a token</li>
|
||||
<li>Copy the token and paste it below</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button
|
||||
onClick={() => {
|
||||
IpcClient.getInstance().openExternalUrl(
|
||||
"https://vercel.com/signup",
|
||||
);
|
||||
}}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
Sign Up for Vercel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
IpcClient.getInstance().openExternalUrl(
|
||||
"https://vercel.com/account/settings/tokens",
|
||||
);
|
||||
}}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
Open Vercel Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveAccessToken} className="space-y-3">
|
||||
<div>
|
||||
<Label className="block text-sm font-medium mb-1">
|
||||
Vercel Access Token
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your Vercel access token"
|
||||
value={accessToken}
|
||||
onChange={(e) => setAccessToken(e.target.value)}
|
||||
disabled={isSavingToken}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!accessToken.trim() || isSavingToken}
|
||||
className="w-full"
|
||||
>
|
||||
{isSavingToken ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin h-4 w-4 mr-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Saving Token...
|
||||
</>
|
||||
) : (
|
||||
"Save Access Token"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{tokenError && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">
|
||||
{tokenError}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tokenSuccess && (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md p-3">
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
Successfully connected to Vercel! You can now set up your
|
||||
project below.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 w-full rounded-md" data-testid="vercel-setup-project">
|
||||
{/* Collapsible Header */}
|
||||
<div className="font-medium mb-2">Set up your Vercel project</div>
|
||||
|
||||
{/* Collapsible Content */}
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-300 ease-in-out`}
|
||||
>
|
||||
<div className="pt-0 space-y-4">
|
||||
{/* Mode Selection */}
|
||||
<div>
|
||||
<div className="flex rounded-md border border-gray-200 dark:border-gray-700">
|
||||
<Button
|
||||
type="button"
|
||||
variant={projectSetupMode === "create" ? "default" : "ghost"}
|
||||
className={`flex-1 rounded-none rounded-l-md border-0 ${
|
||||
projectSetupMode === "create"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setProjectSetupMode("create");
|
||||
setCreateProjectError(null);
|
||||
setCreateProjectSuccess(false);
|
||||
}}
|
||||
>
|
||||
Create new project
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={projectSetupMode === "existing" ? "default" : "ghost"}
|
||||
className={`flex-1 rounded-none rounded-r-md border-0 border-l border-gray-200 dark:border-gray-700 ${
|
||||
projectSetupMode === "existing"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setProjectSetupMode("existing");
|
||||
setCreateProjectError(null);
|
||||
setCreateProjectSuccess(false);
|
||||
}}
|
||||
>
|
||||
Connect to existing project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSetupProject}>
|
||||
{projectSetupMode === "create" ? (
|
||||
<>
|
||||
<div>
|
||||
<Label className="block text-sm font-medium">
|
||||
Project Name
|
||||
</Label>
|
||||
<Input
|
||||
data-testid="vercel-create-project-name-input"
|
||||
className="w-full mt-1"
|
||||
value={projectName}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setProjectName(newValue);
|
||||
setProjectAvailable(null);
|
||||
setProjectCheckError(null);
|
||||
debouncedCheckProjectAvailability(newValue);
|
||||
}}
|
||||
disabled={isCreatingProject}
|
||||
/>
|
||||
{isCheckingProject && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Checking availability...
|
||||
</p>
|
||||
)}
|
||||
{projectAvailable === true && (
|
||||
<p className="text-xs text-green-600 mt-1">
|
||||
Project name is available!
|
||||
</p>
|
||||
)}
|
||||
{projectAvailable === false && (
|
||||
<p className="text-xs text-red-600 mt-1">
|
||||
{projectCheckError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<Label className="block text-sm font-medium">
|
||||
Select Project
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedProject}
|
||||
onValueChange={setSelectedProject}
|
||||
disabled={isLoadingProjects}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full mt-1"
|
||||
data-testid="vercel-project-select"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoadingProjects
|
||||
? "Loading projects..."
|
||||
: "Select a project"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableProjects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.id}>
|
||||
{project.name}{" "}
|
||||
{project.framework && `(${project.framework})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isCreatingProject ||
|
||||
(projectSetupMode === "create" &&
|
||||
(projectAvailable === false || !projectName)) ||
|
||||
(projectSetupMode === "existing" && !selectedProject)
|
||||
}
|
||||
>
|
||||
{isCreatingProject
|
||||
? projectSetupMode === "create"
|
||||
? "Creating..."
|
||||
: "Connecting..."
|
||||
: projectSetupMode === "create"
|
||||
? "Create Project"
|
||||
: "Connect to Project"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{createProjectError && (
|
||||
<p className="text-red-600 mt-2">{createProjectError}</p>
|
||||
)}
|
||||
{createProjectSuccess && (
|
||||
<p className="text-green-600 mt-2">
|
||||
{projectSetupMode === "create"
|
||||
? "Project created and linked!"
|
||||
: "Connected to project!"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VercelConnector({ appId, folderName }: VercelConnectorProps) {
|
||||
const { app, refreshApp } = useLoadApp(appId);
|
||||
const { settings, refreshSettings } = useSettings();
|
||||
|
||||
if (app?.vercelProjectId && appId) {
|
||||
return (
|
||||
<ConnectedVercelConnector
|
||||
appId={appId}
|
||||
app={app}
|
||||
refreshApp={refreshApp}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<UnconnectedVercelConnector
|
||||
appId={appId}
|
||||
folderName={folderName}
|
||||
settings={settings}
|
||||
refreshSettings={refreshSettings}
|
||||
refreshApp={refreshApp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
61
src/components/VercelIntegration.tsx
Normal file
61
src/components/VercelIntegration.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { showSuccess, showError } from "@/lib/toast";
|
||||
|
||||
export function VercelIntegration() {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
||||
|
||||
const handleDisconnectFromVercel = async () => {
|
||||
setIsDisconnecting(true);
|
||||
try {
|
||||
const result = await updateSettings({
|
||||
vercelAccessToken: undefined,
|
||||
});
|
||||
if (result) {
|
||||
showSuccess("Successfully disconnected from Vercel");
|
||||
} else {
|
||||
showError("Failed to disconnect from Vercel");
|
||||
}
|
||||
} catch (err: any) {
|
||||
showError(
|
||||
err.message || "An error occurred while disconnecting from Vercel",
|
||||
);
|
||||
} finally {
|
||||
setIsDisconnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isConnected = !!settings?.vercelAccessToken;
|
||||
|
||||
if (!isConnected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Vercel Integration
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Your account is connected to Vercel.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleDisconnectFromVercel}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={isDisconnecting}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{isDisconnecting ? "Disconnecting..." : "Disconnect from Vercel"}
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M24 22.525H0l12-21.05 12 21.05z" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Trash2,
|
||||
AlertTriangle,
|
||||
Wrench,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
@@ -32,7 +33,12 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { useCheckProblems } from "@/hooks/useCheckProblems";
|
||||
import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
|
||||
|
||||
export type PreviewMode = "preview" | "code" | "problems" | "configure";
|
||||
export type PreviewMode =
|
||||
| "preview"
|
||||
| "code"
|
||||
| "problems"
|
||||
| "configure"
|
||||
| "publish";
|
||||
|
||||
const BUTTON_CLASS_NAME =
|
||||
"no-app-region-drag cursor-pointer relative flex items-center gap-1 px-2 py-1 rounded-md text-[13px] font-medium z-10 hover:bg-[var(--background)]";
|
||||
@@ -46,12 +52,13 @@ export const PreviewHeader = () => {
|
||||
const codeRef = useRef<HTMLButtonElement>(null);
|
||||
const problemsRef = useRef<HTMLButtonElement>(null);
|
||||
const configureRef = useRef<HTMLButtonElement>(null);
|
||||
const publishRef = useRef<HTMLButtonElement>(null);
|
||||
const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 });
|
||||
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
|
||||
const { problemReport } = useCheckProblems(selectedAppId);
|
||||
const { restartApp, refreshAppIframe } = useRunApp();
|
||||
|
||||
const isCompact = windowWidth < 840;
|
||||
const isCompact = windowWidth < 860;
|
||||
|
||||
// Track window width
|
||||
useEffect(() => {
|
||||
@@ -128,6 +135,9 @@ export const PreviewHeader = () => {
|
||||
case "configure":
|
||||
targetRef = configureRef;
|
||||
break;
|
||||
case "publish":
|
||||
targetRef = publishRef;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
@@ -239,6 +249,13 @@ export const PreviewHeader = () => {
|
||||
"Configure",
|
||||
"configure-mode-button",
|
||||
)}
|
||||
{renderButton(
|
||||
"publish",
|
||||
publishRef,
|
||||
<Globe size={14} />,
|
||||
"Publish",
|
||||
"publish-mode-button",
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels";
|
||||
import { Console } from "./Console";
|
||||
import { useRunApp } from "@/hooks/useRunApp";
|
||||
import { PublishPanel } from "./PublishPanel";
|
||||
|
||||
interface ConsoleHeaderProps {
|
||||
isOpen: boolean;
|
||||
@@ -116,6 +117,8 @@ export function PreviewPanel() {
|
||||
<CodeView loading={loading} app={app} />
|
||||
) : previewMode === "configure" ? (
|
||||
<ConfigurePanel />
|
||||
) : previewMode === "publish" ? (
|
||||
<PublishPanel />
|
||||
) : (
|
||||
<Problems />
|
||||
)}
|
||||
|
||||
169
src/components/preview_panel/PublishPanel.tsx
Normal file
169
src/components/preview_panel/PublishPanel.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useAtomValue } from "jotai";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { useLoadApp } from "@/hooks/useLoadApp";
|
||||
import { GitHubConnector } from "@/components/GitHubConnector";
|
||||
import { VercelConnector } from "@/components/VercelConnector";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export const PublishPanel = () => {
|
||||
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
||||
const { app, loading } = useLoadApp(selectedAppId);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-6 h-6 text-blue-600 dark:text-blue-400 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="m4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Loading...
|
||||
</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedAppId || !app) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-gray-100 dark:bg-gray-900/30 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-6 h-6 text-gray-600 dark:text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
No App Selected
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 max-w-md">
|
||||
Select an app to view publishing options.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-y-auto">
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Publish App
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* GitHub Section */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
GitHub
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Sync your code to GitHub for collaboration.
|
||||
</p>
|
||||
<GitHubConnector
|
||||
appId={selectedAppId}
|
||||
folderName={app.name}
|
||||
expanded={true}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Vercel Section */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
ipcClient.openExternalUrl("https://vercel.com/dashboard");
|
||||
}}
|
||||
className="flex items-center gap-2 hover:text-blue-600 dark:hover:text-blue-400 transition-colors cursor-pointer bg-transparent border-none p-0"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M24 22.525H0l12-21.05 12 21.05z" />
|
||||
</svg>
|
||||
Vercel
|
||||
</button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Publish your app by deploying it to Vercel.
|
||||
</p>
|
||||
|
||||
{!app?.githubOrg || !app?.githubRepo ? (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg
|
||||
className="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
GitHub Required for Vercel Deployment
|
||||
</h3>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
||||
Deploying to Vercel requires connecting to GitHub first.
|
||||
Please set up your GitHub repository above.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<VercelConnector appId={selectedAppId} folderName={app.name} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user