Neon / portal template support (#713)
TODOs: - [x] Do restart when checkout / restore if there is a DB - [x] List all branches (branch id, name, date) - [x] Allow checking out versions with no DB - [x] safeguard to never delete main branches - [x] create app hook for neon template - [x] weird UX with connector on configure panel - [x] tiny neon logo in connector - [x] deploy to vercel - [x] build forgot password page - [x] what about email setup - [x] lots of imgix errors - [x] edit file - db snapshot - [x] DYAD_DISABLE_DB_PUSH - [ ] update portal doc - [x] switch preview branch to be read-only endpoint - [x] disable supabase sys prompt if neon is enabled - [ ] https://payloadcms.com/docs/upload/storage-adapters - [x] need to use main branch... Phase 2? - [x] generate DB migrations
This commit is contained in:
137
src/components/CreateAppDialog.tsx
Normal file
137
src/components/CreateAppDialog.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useCreateApp } from "@/hooks/useCreateApp";
|
||||
import { useCheckName } from "@/hooks/useCheckName";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { NEON_TEMPLATE_IDS, Template } from "@/shared/templates";
|
||||
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { neonTemplateHook } from "@/client_logic/template_hook";
|
||||
import { showError } from "@/lib/toast";
|
||||
|
||||
interface CreateAppDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
template: Template | undefined;
|
||||
}
|
||||
|
||||
export function CreateAppDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
template,
|
||||
}: CreateAppDialogProps) {
|
||||
const setSelectedAppId = useSetAtom(selectedAppIdAtom);
|
||||
const [appName, setAppName] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createApp } = useCreateApp();
|
||||
const { data: nameCheckResult } = useCheckName(appName);
|
||||
const router = useRouter();
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!appName.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nameCheckResult?.exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await createApp({ name: appName.trim() });
|
||||
if (template && NEON_TEMPLATE_IDS.has(template.id)) {
|
||||
await neonTemplateHook({
|
||||
appId: result.app.id,
|
||||
appName: result.app.name,
|
||||
});
|
||||
}
|
||||
setSelectedAppId(result.app.id);
|
||||
// Navigate to the new app's first chat
|
||||
router.navigate({
|
||||
to: "/chat",
|
||||
search: { id: result.chatId },
|
||||
});
|
||||
setAppName("");
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
showError(error as any);
|
||||
// Error is already handled by createApp hook or shown above
|
||||
console.error("Error creating app:", error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isNameValid = appName.trim().length > 0;
|
||||
const nameExists = nameCheckResult?.exists;
|
||||
const canSubmit = isNameValid && !nameExists && !isSubmitting;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New App</DialogTitle>
|
||||
<DialogDescription>
|
||||
{`Create a new app using the ${template?.title} template.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="appName">App Name</Label>
|
||||
<Input
|
||||
id="appName"
|
||||
value={appName}
|
||||
onChange={(e) => setAppName(e.target.value)}
|
||||
placeholder="Enter app name..."
|
||||
className={nameExists ? "border-red-500" : ""}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{nameExists && (
|
||||
<p className="text-sm text-red-500">
|
||||
An app with this name already exists
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
{isSubmitting && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{isSubmitting ? "Creating..." : "Create App"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
89
src/components/InputRequestToast.tsx
Normal file
89
src/components/InputRequestToast.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { X, AlertTriangle } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
interface InputRequestToastProps {
|
||||
message: string;
|
||||
toastId: string | number;
|
||||
onResponse: (response: "y" | "n") => void;
|
||||
}
|
||||
|
||||
export function InputRequestToast({
|
||||
message,
|
||||
toastId,
|
||||
onResponse,
|
||||
}: InputRequestToastProps) {
|
||||
const handleClose = () => {
|
||||
toast.dismiss(toastId);
|
||||
};
|
||||
|
||||
const handleResponse = (response: "y" | "n") => {
|
||||
onResponse(response);
|
||||
toast.dismiss(toastId);
|
||||
};
|
||||
|
||||
// Clean up the message by removing excessive newlines and whitespace
|
||||
const cleanMessage = message
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.join("\n");
|
||||
|
||||
return (
|
||||
<div className="relative bg-amber-50/95 dark:bg-slate-800/95 backdrop-blur-sm border border-amber-200 dark:border-slate-600 rounded-xl shadow-lg min-w-[400px] max-w-[500px] overflow-hidden">
|
||||
{/* Content */}
|
||||
<div className="p-5">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-6 h-6 bg-gradient-to-br from-amber-500 to-amber-600 dark:from-amber-400 dark:to-amber-500 rounded-full flex items-center justify-center shadow-sm">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="ml-3 text-base font-semibold text-amber-900 dark:text-amber-100">
|
||||
Input Required
|
||||
</h3>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="ml-auto flex-shrink-0 p-1.5 text-amber-500 dark:text-slate-400 hover:text-amber-700 dark:hover:text-slate-200 transition-colors duration-200 rounded-md hover:bg-amber-100/50 dark:hover:bg-slate-700/50"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="mb-5">
|
||||
<p className="text-sm text-amber-900 dark:text-slate-200 whitespace-pre-wrap leading-relaxed">
|
||||
{cleanMessage}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={() => handleResponse("y")}
|
||||
size="sm"
|
||||
className="bg-primary text-white dark:bg-primary dark:text-black px-6"
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleResponse("n")}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-amber-300 dark:border-slate-500 text-amber-800 dark:text-slate-300 hover:bg-amber-100 dark:hover:bg-slate-700 px-6"
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
src/components/NeonConnector.tsx
Normal file
157
src/components/NeonConnector.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { toast } from "sonner";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
|
||||
import { useDeepLink } from "@/contexts/DeepLinkContext";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { NeonDisconnectButton } from "@/components/NeonDisconnectButton";
|
||||
|
||||
export function NeonConnector() {
|
||||
const { settings, refreshSettings } = useSettings();
|
||||
const { lastDeepLink } = useDeepLink();
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const handleDeepLink = async () => {
|
||||
if (lastDeepLink?.type === "neon-oauth-return") {
|
||||
await refreshSettings();
|
||||
toast.success("Successfully connected to Neon!");
|
||||
}
|
||||
};
|
||||
handleDeepLink();
|
||||
}, [lastDeepLink]);
|
||||
|
||||
if (settings?.neon?.accessToken) {
|
||||
return (
|
||||
<div className="flex flex-col space-y-4 p-4 border bg-white dark:bg-gray-800 max-w-100 rounded-md">
|
||||
<div className="flex flex-col items-start justify-between">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h2 className="text-lg font-medium pb-1">Neon Database</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
IpcClient.getInstance().openExternalUrl(
|
||||
"https://console.neon.tech/",
|
||||
);
|
||||
}}
|
||||
className="ml-2 px-2 py-1 h-8 mb-2"
|
||||
style={{ display: "inline-flex", alignItems: "center" }}
|
||||
asChild
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Neon
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 pb-3">
|
||||
You are connected to Neon Database
|
||||
</p>
|
||||
<NeonDisconnectButton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4 p-4 border bg-white dark:bg-gray-800 max-w-100 rounded-md">
|
||||
<div className="flex flex-col items-start justify-between">
|
||||
<h2 className="text-lg font-medium pb-1">Neon Database</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 pb-3">
|
||||
Neon Database has a good free tier with backups and up to 10 projects.
|
||||
</p>
|
||||
<div
|
||||
onClick={async () => {
|
||||
if (settings?.isTestMode) {
|
||||
await IpcClient.getInstance().fakeHandleNeonConnect();
|
||||
} else {
|
||||
await IpcClient.getInstance().openExternalUrl(
|
||||
"https://oauth.dyad.sh/api/integrations/neon/login",
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="w-auto h-10 cursor-pointer flex items-center justify-center px-4 py-2 rounded-md border-2 transition-colors font-medium text-sm dark:bg-gray-900 dark:border-gray-700"
|
||||
data-testid="connect-neon-button"
|
||||
>
|
||||
<span className="mr-2">Connect to</span>
|
||||
<NeonSvg isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NeonSvg({
|
||||
isDarkMode,
|
||||
className,
|
||||
}: {
|
||||
isDarkMode?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const textColor = isDarkMode ? "#fff" : "#000";
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="68"
|
||||
height="18"
|
||||
fill="none"
|
||||
viewBox="0 0 102 28"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fill="#12FFF7"
|
||||
fillRule="evenodd"
|
||||
d="M0 4.828C0 2.16 2.172 0 4.851 0h18.436c2.679 0 4.85 2.161 4.85 4.828V20.43c0 2.758-3.507 3.955-5.208 1.778l-5.318-6.809v8.256c0 2.4-1.955 4.345-4.367 4.345H4.851C2.172 28 0 25.839 0 23.172zm4.851-.966a.97.97 0 0 0-.97.966v18.344c0 .534.435.966.97.966h8.539c.268 0 .34-.216.34-.483v-11.07c0-2.76 3.507-3.956 5.208-1.779l5.319 6.809V4.828c0-.534.05-.966-.485-.966z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="url(#a)"
|
||||
fillRule="evenodd"
|
||||
d="M0 4.828C0 2.16 2.172 0 4.851 0h18.436c2.679 0 4.85 2.161 4.85 4.828V20.43c0 2.758-3.507 3.955-5.208 1.778l-5.318-6.809v8.256c0 2.4-1.955 4.345-4.367 4.345H4.851C2.172 28 0 25.839 0 23.172zm4.851-.966a.97.97 0 0 0-.97.966v18.344c0 .534.435.966.97.966h8.539c.268 0 .34-.216.34-.483v-11.07c0-2.76 3.507-3.956 5.208-1.779l5.319 6.809V4.828c0-.534.05-.966-.485-.966z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="url(#b)"
|
||||
fillRule="evenodd"
|
||||
d="M0 4.828C0 2.16 2.172 0 4.851 0h18.436c2.679 0 4.85 2.161 4.85 4.828V20.43c0 2.758-3.507 3.955-5.208 1.778l-5.318-6.809v8.256c0 2.4-1.955 4.345-4.367 4.345H4.851C2.172 28 0 25.839 0 23.172zm4.851-.966a.97.97 0 0 0-.97.966v18.344c0 .534.435.966.97.966h8.539c.268 0 .34-.216.34-.483v-11.07c0-2.76 3.507-3.956 5.208-1.779l5.319 6.809V4.828c0-.534.05-.966-.485-.966z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="#B9FFB3"
|
||||
d="M23.287 0c2.679 0 4.85 2.161 4.85 4.828V20.43c0 2.758-3.507 3.955-5.208 1.778l-5.319-6.809v8.256c0 2.4-1.954 4.345-4.366 4.345a.484.484 0 0 0 .485-.483V12.584c0-2.758 3.508-3.955 5.21-1.777l5.318 6.808V.965a.97.97 0 0 0-.97-.965"
|
||||
/>
|
||||
<path
|
||||
fill={textColor}
|
||||
d="M48.112 7.432v8.032l-7.355-8.032H36.93v13.136h3.49v-8.632l8.01 8.632h3.173V7.432zM58.075 17.64v-2.326h7.815v-2.797h-7.815V10.36h9.48V7.432H54.514v13.136H67.75v-2.927zM77.028 21c4.909 0 8.098-2.552 8.098-7s-3.19-7-8.098-7c-4.91 0-8.081 2.552-8.081 7s3.172 7 8.08 7m0-3.115c-2.73 0-4.413-1.408-4.413-3.885s1.701-3.885 4.413-3.885c2.729 0 4.412 1.408 4.412 3.885s-1.683 3.885-4.412 3.885M98.508 7.432v8.032l-7.355-8.032h-3.828v13.136h3.491v-8.632l8.01 8.632H102V7.432z"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="a"
|
||||
x1="28.138"
|
||||
x2="3.533"
|
||||
y1="28"
|
||||
y2="-.12"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#B9FFB3" />
|
||||
<stop offset="1" stopColor="#B9FFB3" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="b"
|
||||
x1="28.138"
|
||||
x2="11.447"
|
||||
y1="28"
|
||||
y2="21.476"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#1A1A1A" stopOpacity=".9" />
|
||||
<stop offset="1" stopColor="#1A1A1A" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
38
src/components/NeonDisconnectButton.tsx
Normal file
38
src/components/NeonDisconnectButton.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
|
||||
interface NeonDisconnectButtonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NeonDisconnectButton({ className }: NeonDisconnectButtonProps) {
|
||||
const { updateSettings, settings } = useSettings();
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
try {
|
||||
await updateSettings({
|
||||
neon: undefined,
|
||||
});
|
||||
toast.success("Disconnected from Neon successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to disconnect from Neon:", error);
|
||||
toast.error("Failed to disconnect from Neon");
|
||||
}
|
||||
};
|
||||
|
||||
if (!settings?.neon?.accessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDisconnect}
|
||||
className={className}
|
||||
size="sm"
|
||||
>
|
||||
Disconnect from Neon
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
29
src/components/NeonIntegration.tsx
Normal file
29
src/components/NeonIntegration.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { NeonDisconnectButton } from "@/components/NeonDisconnectButton";
|
||||
|
||||
export function NeonIntegration() {
|
||||
const { settings } = useSettings();
|
||||
|
||||
const isConnected = !!settings?.neon?.accessToken;
|
||||
|
||||
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">
|
||||
Neon Integration
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Your account is connected to Neon.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<NeonDisconnectButton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
src/components/PortalMigrate.tsx
Normal file
110
src/components/PortalMigrate.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink, Database, Loader2 } from "lucide-react";
|
||||
import { showSuccess, showError } from "@/lib/toast";
|
||||
import { useVersions } from "@/hooks/useVersions";
|
||||
|
||||
interface PortalMigrateProps {
|
||||
appId: number;
|
||||
}
|
||||
|
||||
export const PortalMigrate = ({ appId }: PortalMigrateProps) => {
|
||||
const [output, setOutput] = useState<string>("");
|
||||
const { refreshVersions } = useVersions(appId);
|
||||
|
||||
const migrateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
return ipcClient.portalMigrateCreate({ appId });
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
setOutput(result.output);
|
||||
showSuccess(
|
||||
"Database migration file generated and committed successfully!",
|
||||
);
|
||||
refreshVersions();
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
setOutput(`Error: ${errorMessage}`);
|
||||
showError(errorMessage);
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreateMigration = () => {
|
||||
setOutput(""); // Clear previous output
|
||||
migrateMutation.mutate();
|
||||
};
|
||||
|
||||
const openDocs = () => {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
ipcClient.openExternalUrl(
|
||||
"https://www.dyad.sh/docs/templates/portal#create-a-database-migration",
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-primary" />
|
||||
Portal Database Migration
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Generate a new database migration file for your Portal app.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={handleCreateMigration}
|
||||
disabled={migrateMutation.isPending}
|
||||
// className="bg-primary hover:bg-purple-700 text-white"
|
||||
>
|
||||
{migrateMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Database className="w-4 h-4 mr-2" />
|
||||
Generate database migration
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openDocs}
|
||||
className="text-sm"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
Docs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{output && (
|
||||
<div className="mt-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-900 border rounded-lg p-3">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Command Output:
|
||||
</h4>
|
||||
<div className="max-h-64 overflow-auto">
|
||||
<pre className="text-xs text-gray-600 dark:text-gray-400 whitespace-pre-wrap font-mono">
|
||||
{output}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -4,17 +4,22 @@ import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { CommunityCodeConsentDialog } from "./CommunityCodeConsentDialog";
|
||||
import type { Template } from "@/shared/templates";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { showWarning } from "@/lib/toast";
|
||||
|
||||
interface TemplateCardProps {
|
||||
template: Template;
|
||||
isSelected: boolean;
|
||||
onSelect: (templateId: string) => void;
|
||||
onCreateApp: () => void;
|
||||
}
|
||||
|
||||
export const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
template,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onCreateApp,
|
||||
}) => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const [showConsentDialog, setShowConsentDialog] = useState(false);
|
||||
@@ -26,6 +31,11 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (template.requiresNeon && !settings?.neon?.accessToken) {
|
||||
showWarning("Please connect your Neon account to use this template.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, proceed with selection
|
||||
onSelect(template.id);
|
||||
};
|
||||
@@ -93,7 +103,7 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
>
|
||||
{template.title}
|
||||
</h2>
|
||||
{template.isOfficial && (
|
||||
{template.isOfficial && !template.isExperimental && (
|
||||
<span
|
||||
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
||||
isSelected
|
||||
@@ -104,8 +114,13 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
Official
|
||||
</span>
|
||||
)}
|
||||
{template.isExperimental && (
|
||||
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-700 dark:text-yellow-200">
|
||||
Experimental
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3 h-8 overflow-y-auto">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3 h-10 overflow-y-auto">
|
||||
{template.description}
|
||||
</p>
|
||||
{template.githubUrl && (
|
||||
@@ -121,6 +136,20 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
<ArrowLeft className="w-4 h-4 ml-1 transform rotate-180" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCreateApp();
|
||||
}}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold mt-2",
|
||||
settings?.selectedTemplateId !== template.id && "invisible",
|
||||
)}
|
||||
>
|
||||
Create App
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -120,15 +120,26 @@ export function ChatHeader({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center gap-1">
|
||||
<strong>Warning:</strong>
|
||||
<span>You are not on a branch</span>
|
||||
<Info size={14} />
|
||||
{isAnyCheckoutVersionInProgress ? (
|
||||
<>
|
||||
<span>
|
||||
Please wait, switching back to latest version...
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>Warning:</strong>
|
||||
<span>You are not on a branch</span>
|
||||
<Info size={14} />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Checkout main branch, otherwise changes will not be
|
||||
saved properly
|
||||
{isAnyCheckoutVersionInProgress
|
||||
? "Version checkout is currently in progress"
|
||||
: "Checkout main branch, otherwise changes will not be saved properly"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -152,7 +163,7 @@ export function ChatHeader({
|
||||
>
|
||||
{isRenamingBranch ? "Renaming..." : "Rename master to main"}
|
||||
</Button>
|
||||
) : (
|
||||
) : isAnyCheckoutVersionInProgress && !isCheckingOutVersion ? null : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useAtom, useAtomValue } from "jotai";
|
||||
import { selectedAppIdAtom, selectedVersionIdAtom } from "@/atoms/appAtoms";
|
||||
import { useVersions } from "@/hooks/useVersions";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { RotateCcw, X } from "lucide-react";
|
||||
import { RotateCcw, X, Database, Loader2 } from "lucide-react";
|
||||
import type { Version } from "@/ipc/ipc_types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
import { useRunApp } from "@/hooks/useRunApp";
|
||||
|
||||
interface VersionPaneProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
@@ -21,12 +23,15 @@ interface VersionPaneProps {
|
||||
|
||||
export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
|
||||
const appId = useAtomValue(selectedAppIdAtom);
|
||||
const { refreshApp } = useLoadApp(appId);
|
||||
const { refreshApp, app } = useLoadApp(appId);
|
||||
const { restartApp } = useRunApp();
|
||||
const {
|
||||
versions: liveVersions,
|
||||
refreshVersions,
|
||||
revertVersion,
|
||||
isRevertingVersion,
|
||||
} = useVersions(appId);
|
||||
|
||||
const [selectedVersionId, setSelectedVersionId] = useAtom(
|
||||
selectedVersionIdAtom,
|
||||
);
|
||||
@@ -49,6 +54,9 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
|
||||
setSelectedVersionId(null);
|
||||
if (appId) {
|
||||
await checkoutVersion({ appId, versionId: "main" });
|
||||
if (app?.neonProjectId) {
|
||||
await restartApp();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,16 +84,19 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleVersionClick = async (versionOid: string) => {
|
||||
const handleVersionClick = async (version: Version) => {
|
||||
if (appId) {
|
||||
setSelectedVersionId(versionOid);
|
||||
setSelectedVersionId(version.oid);
|
||||
try {
|
||||
await checkoutVersion({ appId, versionId: versionOid });
|
||||
await checkoutVersion({ appId, versionId: version.oid });
|
||||
} catch (error) {
|
||||
console.error("Could not checkout version, unselecting version", error);
|
||||
setSelectedVersionId(null);
|
||||
}
|
||||
await refreshApp();
|
||||
if (version.dbTimestamp) {
|
||||
await restartApp();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,21 +105,23 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
|
||||
return (
|
||||
<div className="h-full border-t border-2 border-border w-full">
|
||||
<div className="p-2 border-b border-border flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold pl-2">Version History</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-(--background-lightest) rounded-md "
|
||||
aria-label="Close version pane"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<h2 className="text-base font-medium pl-2">Version History</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-(--background-lightest) rounded-md "
|
||||
aria-label="Close version pane"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto h-[calc(100%-60px)]">
|
||||
{versions.length === 0 ? (
|
||||
<div className="p-4 ">No versions available</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{versions.map((version: Version, index) => (
|
||||
{versions.map((version: Version, index: number) => (
|
||||
<div
|
||||
key={version.oid}
|
||||
className={cn(
|
||||
@@ -121,19 +134,67 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isCheckingOutVersion) {
|
||||
handleVersionClick(version.oid);
|
||||
handleVersionClick(version);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-xs">
|
||||
Version {versions.length - index}
|
||||
</span>
|
||||
<span className="text-xs opacity-90">
|
||||
{formatDistanceToNow(new Date(version.timestamp * 1000), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-xs">
|
||||
Version {versions.length - index} (
|
||||
{version.oid.slice(0, 7)})
|
||||
</span>
|
||||
{/* example format: '2025-07-25T21:52:01Z' */}
|
||||
{version.dbTimestamp &&
|
||||
(() => {
|
||||
const timestampMs = new Date(
|
||||
version.dbTimestamp,
|
||||
).getTime();
|
||||
const isExpired =
|
||||
Date.now() - timestampMs > 24 * 60 * 60 * 1000;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-md",
|
||||
isExpired
|
||||
? "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
|
||||
)}
|
||||
>
|
||||
<Database size={10} />
|
||||
<span>DB</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isExpired
|
||||
? "DB snapshot may have expired (older than 24 hours)"
|
||||
: `Database snapshot available at timestamp ${version.dbTimestamp}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isCheckingOutVersion &&
|
||||
selectedVersionId === version.oid && (
|
||||
<Loader2
|
||||
size={12}
|
||||
className="animate-spin text-primary"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs opacity-90">
|
||||
{isCheckingOutVersion && selectedVersionId === version.oid
|
||||
? "Loading..."
|
||||
: formatDistanceToNow(
|
||||
new Date(version.timestamp * 1000),
|
||||
{
|
||||
addSuffix: true,
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{version.message && (
|
||||
@@ -158,30 +219,50 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedVersionId(null);
|
||||
await revertVersion({
|
||||
versionId: version.oid,
|
||||
});
|
||||
// Close the pane after revert to force a refresh on next open
|
||||
onClose();
|
||||
}}
|
||||
className={cn(
|
||||
"invisible mt-1 flex items-center gap-1 px-2 py-0.5 text-sm font-medium bg-(--primary) text-(--primary-foreground) hover:bg-background-lightest rounded-md transition-colors",
|
||||
selectedVersionId === version.oid && "visible",
|
||||
)}
|
||||
aria-label="Restore to this version"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
<span>Restore</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Restore to this version</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Restore button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
await revertVersion({
|
||||
versionId: version.oid,
|
||||
});
|
||||
setSelectedVersionId(null);
|
||||
// Close the pane after revert to force a refresh on next open
|
||||
onClose();
|
||||
if (version.dbTimestamp) {
|
||||
await restartApp();
|
||||
}
|
||||
}}
|
||||
disabled={isRevertingVersion}
|
||||
className={cn(
|
||||
"invisible mt-1 flex items-center gap-1 px-2 py-0.5 text-sm font-medium bg-(--primary) text-(--primary-foreground) hover:bg-background-lightest rounded-md transition-colors",
|
||||
selectedVersionId === version.oid && "visible",
|
||||
isRevertingVersion &&
|
||||
"opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
aria-label="Restore to this version"
|
||||
>
|
||||
{isRevertingVersion ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<RotateCcw size={12} />
|
||||
)}
|
||||
<span>
|
||||
{isRevertingVersion ? "Restoring..." : "Restore"}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isRevertingVersion
|
||||
? "Restoring to this version..."
|
||||
: "Restore to this version"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { showError, showSuccess } from "@/lib/toast";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { NeonConfigure } from "./NeonConfigure";
|
||||
|
||||
const EnvironmentVariablesTitle = () => (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -396,6 +397,12 @@ export const ConfigurePanel = () => {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Neon Database Configuration */}
|
||||
{/* Neon Connector */}
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<NeonConfigure />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
178
src/components/preview_panel/NeonConfigure.tsx
Normal file
178
src/components/preview_panel/NeonConfigure.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
import { Database, GitBranch } from "lucide-react";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { useLoadApp } from "@/hooks/useLoadApp";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import type { GetNeonProjectResponse, NeonBranch } from "@/ipc/ipc_types";
|
||||
import { NeonDisconnectButton } from "@/components/NeonDisconnectButton";
|
||||
|
||||
const getBranchTypeColor = (type: NeonBranch["type"]) => {
|
||||
switch (type) {
|
||||
case "production":
|
||||
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300";
|
||||
case "development":
|
||||
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300";
|
||||
case "snapshot":
|
||||
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300";
|
||||
case "preview":
|
||||
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300";
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
export const NeonConfigure = () => {
|
||||
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
||||
const { app } = useLoadApp(selectedAppId);
|
||||
|
||||
// Query to get Neon project information
|
||||
const {
|
||||
data: neonProject,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery<GetNeonProjectResponse, Error>({
|
||||
queryKey: ["neon-project", selectedAppId],
|
||||
queryFn: async () => {
|
||||
if (!selectedAppId) throw new Error("No app selected");
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
return await ipcClient.getNeonProject({ appId: selectedAppId });
|
||||
},
|
||||
enabled: !!selectedAppId && !!app?.neonProjectId,
|
||||
meta: { showErrorToast: true },
|
||||
});
|
||||
|
||||
// Don't show component if app doesn't have Neon project
|
||||
if (!app?.neonProjectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database size={20} />
|
||||
Neon Database
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-8">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading Neon project information...
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database size={20} />
|
||||
Neon Database
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-8">
|
||||
<div className="text-sm text-red-500">
|
||||
Error loading Neon project: {error.message}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!neonProject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database size={20} />
|
||||
Neon Database
|
||||
</div>
|
||||
<NeonDisconnectButton />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Project Information */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Project Information</div>
|
||||
<div className="bg-muted/50 p-3 rounded-md space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Project Name:</span>
|
||||
<span className="font-medium">{neonProject.projectName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Project ID:</span>
|
||||
<span className="font-mono text-xs">{neonProject.projectId}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Organization:</span>
|
||||
<span className="font-mono text-xs">{neonProject.orgId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Branches */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium flex items-center gap-2">
|
||||
<GitBranch size={16} />
|
||||
Branches ({neonProject.branches.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{neonProject.branches.map((branch) => (
|
||||
<div
|
||||
key={branch.branchId}
|
||||
className="flex items-center justify-between p-3 border rounded-md"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{branch.branchName}
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={getBranchTypeColor(branch.type)}
|
||||
>
|
||||
{branch.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
ID: {branch.branchId}
|
||||
</div>
|
||||
{branch.parentBranchName && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Parent: {branch.parentBranchName.slice(0, 20)}...
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Updated: {formatDate(branch.lastUpdated)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -413,7 +413,20 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
|
||||
// Display loading state
|
||||
if (loading) {
|
||||
return <div className="p-4 dark:text-gray-300">Loading app preview...</div>;
|
||||
return (
|
||||
<div className="flex flex-col h-full relative">
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center space-y-4 bg-gray-50 dark:bg-gray-950">
|
||||
<div className="relative w-5 h-5 animate-spin">
|
||||
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 w-2 h-2 bg-primary rounded-full"></div>
|
||||
<div className="absolute bottom-0 left-0 w-2 h-2 bg-primary rounded-full opacity-80"></div>
|
||||
<div className="absolute bottom-0 right-0 w-2 h-2 bg-primary rounded-full opacity-60"></div>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Preparing app preview...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Display message if no app is selected
|
||||
@@ -565,7 +578,7 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center space-y-4 bg-gray-50 dark:bg-gray-950">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-gray-400 dark:text-gray-500" />
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Starting up your app...
|
||||
Starting your app server...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { useLoadApp } from "@/hooks/useLoadApp";
|
||||
import { GitHubConnector } from "@/components/GitHubConnector";
|
||||
import { VercelConnector } from "@/components/VercelConnector";
|
||||
import { PortalMigrate } from "@/components/PortalMigrate";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
@@ -78,6 +79,9 @@ export const PublishPanel = () => {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Portal Section - Show only if app has neon project */}
|
||||
{app.neonProjectId && <PortalMigrate appId={selectedAppId} />}
|
||||
|
||||
{/* GitHub Section */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
|
||||
Reference in New Issue
Block a user