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:
Will Chen
2025-08-04 16:36:09 -07:00
committed by GitHub
parent 0f1a5c5c77
commit b0f08eaf15
50 changed files with 3525 additions and 205 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
};

View File

@@ -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>

View File

@@ -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"

View File

@@ -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>
))}

View File

@@ -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>
);
};

View 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>
);
};

View File

@@ -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>
) : (

View File

@@ -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">