aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMaheshtheDev <[email protected]>2025-10-12 06:56:15 +0000
committerMaheshtheDev <[email protected]>2025-10-12 06:56:15 +0000
commita9e986e68012c68fd46cdb73661837f2dff756b3 (patch)
tree7dafe0ae1ca75f87f732f9dfbcdf6a3d9cf86575
parentMerge pull request #485 from supermemoryai/10-10-fix_add_memory_code_params_a... (diff)
downloadsupermemory-a9e986e68012c68fd46cdb73661837f2dff756b3.tar.xz
supermemory-a9e986e68012c68fd46cdb73661837f2dff756b3.zip
feat: project selection, creation for each connectors (#486)
- Added Project Selection for Each Connectors - Updated the Layout from list the cards layout
-rw-r--r--apps/web/components/views/integrations.tsx628
1 files changed, 527 insertions, 101 deletions
diff --git a/apps/web/components/views/integrations.tsx b/apps/web/components/views/integrations.tsx
index 5a6d09d0..f020a3d6 100644
--- a/apps/web/components/views/integrations.tsx
+++ b/apps/web/components/views/integrations.tsx
@@ -16,6 +16,13 @@ import {
DialogPortal,
DialogTitle,
} from "@repo/ui/components/dialog"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@repo/ui/components/dropdown-menu"
+import { Input } from "@repo/ui/components/input"
import { Skeleton } from "@repo/ui/components/skeleton"
import type { ConnectionResponseSchema } from "@repo/validation/api"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
@@ -23,16 +30,20 @@ import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
import { useCustomer } from "autumn-js/react"
import {
Check,
+ ChevronDown,
Copy,
DownloadIcon,
+ FolderIcon,
KeyIcon,
+ Loader,
+ Plus,
Smartphone,
Trash2,
} from "lucide-react"
import { motion } from "motion/react"
import Image from "next/image"
import { useSearchParams } from "next/navigation"
-import { useEffect, useId, useState } from "react"
+import { startTransition, useEffect, useId, useMemo, useState } from "react"
import { toast } from "sonner"
import type { z } from "zod"
import { analytics } from "@/lib/analytics"
@@ -40,6 +51,15 @@ import { useProject } from "@/stores"
type Connection = z.infer<typeof ConnectionResponseSchema>
+interface Project {
+ id: string
+ name: string
+ containerTag: string
+ createdAt: string
+ updatedAt: string
+ isExperimental?: boolean
+}
+
const CONNECTORS = {
"google-drive": {
title: "Google Drive",
@@ -56,10 +76,32 @@ const CONNECTORS = {
description: "Access your Microsoft Office documents",
icon: OneDrive,
},
+ "more-coming": {
+ title: "More Coming Soon",
+ description: "Additional integrations are in development",
+ icon: () => (
+ <svg
+ className="h-6 w-6"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ >
+ <title>More Coming Soon Icon</title>
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M12 6v6m0 0v6m0-6h6m-6 0H6"
+ />
+ </svg>
+ ),
+ },
} as const
type ConnectorProvider = keyof typeof CONNECTORS
+const COMING_SOON_CONNECTOR = "more-coming" as const
+
const ChromeIcon = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -108,6 +150,15 @@ export function IntegrationsView() {
const [raycastApiKey, setRaycastApiKey] = useState<string>("")
const [raycastCopied, setRaycastCopied] = useState(false)
const [hasTriggeredRaycast, setHasTriggeredRaycast] = useState(false)
+ const [selectedProjectForConnection, setSelectedProjectForConnection] =
+ useState<Record<string, string>>({})
+ const [showCreateProjectForm, setShowCreateProjectForm] = useState(false)
+ const [newProjectName, setNewProjectName] = useState("")
+ const [creatingProjectForConnector, setCreatingProjectForConnector] =
+ useState<string | null>(null)
+ const [connectingProvider, setConnectingProvider] = useState<string | null>(
+ null,
+ )
const apiKeyId = useId()
const raycastApiKeyId = useId()
@@ -165,6 +216,20 @@ export function IntegrationsView() {
refetchInterval: 60 * 1000,
})
+ const { data: projects = [] } = useQuery({
+ queryKey: ["projects"],
+ queryFn: async () => {
+ const response = await $fetch("@get/projects")
+
+ if (response.error) {
+ throw new Error(response.error?.message || "Failed to load projects")
+ }
+
+ return response.data?.projects || []
+ },
+ staleTime: 30 * 1000,
+ })
+
useEffect(() => {
if (connectionsError) {
toast.error("Failed to load connections", {
@@ -176,19 +241,44 @@ export function IntegrationsView() {
}
}, [connectionsError])
+ useEffect(() => {
+ if (selectedProject) {
+ setSelectedProjectForConnection((prev) => {
+ const updatedProjects = { ...prev }
+ let hasChanges = false
+
+ Object.keys(CONNECTORS).forEach((provider) => {
+ if (!updatedProjects[provider]) {
+ updatedProjects[provider] = selectedProject
+ hasChanges = true
+ }
+ })
+
+ return hasChanges ? updatedProjects : prev
+ })
+ }
+ }, [selectedProject])
+
const addConnectionMutation = useMutation({
mutationFn: async (provider: ConnectorProvider) => {
+ if (provider === COMING_SOON_CONNECTOR) {
+ throw new Error("This integration is coming soon!")
+ }
+
if (!canAddConnection && !isProUser) {
throw new Error(
"Free plan doesn't include connections. Upgrade to Pro for unlimited connections.",
)
}
+ const projectToUse =
+ selectedProjectForConnection[provider] || selectedProject
+
const response = await $fetch("@post/connections/:provider", {
params: { provider },
body: {
redirectUrl: window.location.href,
- containerTags: [selectedProject],
+ containerTags: [projectToUse],
},
})
@@ -203,11 +293,15 @@ export function IntegrationsView() {
analytics.connectionAdded(provider)
analytics.connectionAuthStarted()
if (data?.authLink) {
+ setConnectingProvider(provider)
window.location.href = data.authLink
+ } else {
+ setConnectingProvider(null)
}
},
onError: (error, provider) => {
analytics.connectionAuthFailed()
+ setConnectingProvider(null)
toast.error(`Failed to connect ${provider}`, {
description: error instanceof Error ? error.message : "Unknown error",
})
@@ -232,6 +326,40 @@ export function IntegrationsView() {
},
})
+ const createProjectMutation = useMutation({
+ mutationFn: async (name: string) => {
+ const response = await $fetch("@post/projects", {
+ body: { name },
+ })
+
+ if (response.error) {
+ throw new Error(response.error?.message || "Failed to create project")
+ }
+
+ return response.data
+ },
+ onSuccess: (newProject) => {
+ toast.success("Project created successfully!")
+ startTransition(() => {
+ setNewProjectName("")
+ setShowCreateProjectForm(false)
+ if (newProject?.containerTag && creatingProjectForConnector) {
+ setSelectedProjectForConnection((prev) => ({
+ ...prev,
+ [creatingProjectForConnector]: newProject.containerTag,
+ }))
+ }
+ setCreatingProjectForConnector(null)
+ })
+ queryClient.invalidateQueries({ queryKey: ["projects"] })
+ },
+ onError: (error) => {
+ toast.error("Failed to create project", {
+ description: error instanceof Error ? error.message : "Unknown error",
+ })
+ },
+ })
+
const createApiKeyMutation = useMutation({
mutationFn: async () => {
const res = await authClient.apiKey.create({
@@ -262,7 +390,7 @@ export function IntegrationsView() {
if (!org?.id) {
throw new Error("Organization ID is required")
}
-
+
const res = await authClient.apiKey.create({
metadata: {
organizationId: org?.id,
@@ -350,6 +478,66 @@ export function IntegrationsView() {
createRaycastApiKeyMutation.mutate()
}
+ const validateProjectName = (name: string): string | null => {
+ const trimmed = name.trim()
+ if (!trimmed) {
+ return "Project name is required"
+ }
+ if (trimmed.length < 2) {
+ return "Project name must be at least 2 characters"
+ }
+ if (trimmed.length > 50) {
+ return "Project name must be less than 50 characters"
+ }
+ // Allow alphanumeric, spaces, hyphens, and underscores
+ if (!/^[a-zA-Z0-9\s\-_]+$/.test(trimmed)) {
+ return "Project name can only contain letters, numbers, spaces, hyphens, and underscores"
+ }
+ return null
+ }
+
+ const handleCreateProject = () => {
+ const validationError = validateProjectName(newProjectName)
+ if (validationError) {
+ toast.error(validationError)
+ return
+ }
+ createProjectMutation.mutate(newProjectName.trim())
+ }
+
+ const handleCreateProjectCancel = () => {
+ setShowCreateProjectForm(false)
+ setNewProjectName("")
+ setCreatingProjectForConnector(null)
+ }
+
+ const getProjectName = (containerTag: string) => {
+ if (containerTag === selectedProject) {
+ return "Default Project"
+ }
+ const project = projects.find(
+ (p: Project) => p.containerTag === containerTag,
+ )
+ return project?.name || "Unknown Project"
+ }
+
+ const updateProjectForConnector = (provider: string, projectTag: string) => {
+ setSelectedProjectForConnection((prev) => ({
+ ...prev,
+ [provider]: projectTag,
+ }))
+ }
+
+ const filteredProjects = useMemo(
+ () =>
+ projects.filter(
+ (project: Project) =>
+ project.containerTag !== selectedProject &&
+ project.name !== "Default Project",
+ ),
+ [projects, selectedProject],
+ )
+
return (
<div className="space-y-4 sm:space-y-4 custom-scrollbar">
{/* iOS Shortcuts */}
@@ -381,9 +569,23 @@ export function IntegrationsView() {
width={20}
height={20}
/>
- {createApiKeyMutation.isPending
- ? "Creating..."
- : "Add Memory Shortcut"}
+ <motion.div
+ key={createApiKeyMutation.isPending ? "loading" : "add"}
+ initial={{ opacity: 0, scale: 0.8 }}
+ animate={{ opacity: 1, scale: 1 }}
+ exit={{ opacity: 0, scale: 0.8 }}
+ transition={{ duration: 0.2, ease: "easeInOut" }}
+ className="flex items-center gap-2"
+ >
+ {createApiKeyMutation.isPending ? (
+ <>
+ <Loader className="h-4 w-4 animate-spin" />
+ Creating...
+ </>
+ ) : (
+ "Add Memory Shortcut"
+ )}
+ </motion.div>
</Button>
<Button
variant="secondary"
@@ -397,9 +599,23 @@ export function IntegrationsView() {
width={20}
height={20}
/>
- {createApiKeyMutation.isPending
- ? "Creating..."
- : "Search Memory Shortcut"}
+ <motion.div
+ key={createApiKeyMutation.isPending ? "loading" : "search"}
+ initial={{ opacity: 0, scale: 0.8 }}
+ animate={{ opacity: 1, scale: 1 }}
+ exit={{ opacity: 0, scale: 0.8 }}
+ transition={{ duration: 0.2, ease: "easeInOut" }}
+ className="flex items-center gap-2"
+ >
+ {createApiKeyMutation.isPending ? (
+ <>
+ <Loader className="h-4 w-4 animate-spin" />
+ Creating...
+ </>
+ ) : (
+ "Search Memory Shortcut"
+ )}
+ </motion.div>
</Button>
</div>
</div>
@@ -444,9 +660,25 @@ export function IntegrationsView() {
disabled={createRaycastApiKeyMutation.isPending}
>
<KeyIcon className="h-4 w-4" />
- {createRaycastApiKeyMutation.isPending
- ? "Generating..."
- : "Get API Key"}
+ <motion.div
+ key={
+ createRaycastApiKeyMutation.isPending ? "loading" : "raycast"
+ }
+ initial={{ opacity: 0, scale: 0.8 }}
+ animate={{ opacity: 1, scale: 1 }}
+ exit={{ opacity: 0, scale: 0.8 }}
+ transition={{ duration: 0.2, ease: "easeInOut" }}
+ className="flex items-center gap-2"
+ >
+ {createRaycastApiKeyMutation.isPending ? (
+ <>
+ <Loader className="h-4 w-4 animate-spin" />
+ Generating...
+ </>
+ ) : (
+ "Get API Key"
+ )}
+ </motion.div>
</Button>
<Button
variant="secondary"
@@ -554,11 +786,7 @@ export function IntegrationsView() {
{/* Show upgrade prompt for free users */}
{!autumn.isLoading && !isProUser && (
- <motion.div
- animate={{ opacity: 1, y: 0 }}
- className="p-4 bg-accent border border-border rounded-lg mb-3"
- initial={{ opacity: 0, y: -10 }}
- >
+ <div className="p-4 bg-accent border border-border rounded-lg mb-3">
<p className="text-sm text-accent-foreground mb-2 font-medium">
🔌 Connections are a Pro feature
</p>
@@ -574,57 +802,69 @@ export function IntegrationsView() {
>
Upgrade to Pro
</Button>
- </motion.div>
+ </div>
)}
{/* All Connections with Status */}
{connectionsLoading ? (
- <div className="space-y-2">
- {Object.keys(CONNECTORS).map((_, i) => (
- <motion.div
- animate={{ opacity: 1 }}
- className="p-3 bg-accent rounded-lg"
- initial={{ opacity: 0 }}
- key={`skeleton-${Date.now()}-${i}`}
- transition={{ delay: i * 0.1 }}
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {Object.keys(CONNECTORS).map((_, _i) => (
+ <div
+ className="p-4 bg-accent rounded-lg border border-border/50"
+ key={`skeleton-${Date.now()}-${_i}`}
>
- <Skeleton className="h-12 w-full bg-muted" />
- </motion.div>
+ <Skeleton className="h-32 w-full bg-muted rounded-lg" />
+ </div>
))}
</div>
) : (
- <div className="space-y-2">
- {Object.entries(CONNECTORS).map(([provider, config], index) => {
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {Object.entries(CONNECTORS).map(([provider, config]) => {
const Icon = config.icon
const connection = connections.find(
(conn) => conn.provider === provider,
)
const isConnected = !!connection
+ const isMoreComing = provider === COMING_SOON_CONNECTOR
return (
- <motion.div
- animate={{ opacity: 1, y: 0 }}
- className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 bg-accent rounded-lg hover:bg-accent/80 transition-colors border border-border/50"
- initial={{ opacity: 0, y: 20 }}
+ <div
+ className={`p-4 rounded-lg border border-border/50 transition-all duration-200 ${
+ isMoreComing
+ ? "bg-muted/30 hover:bg-muted/50"
+ : "bg-card hover:border-border hover:shadow-sm"
+ }`}
key={provider}
- transition={{ delay: index * 0.05 }}
>
- <div className="flex items-center gap-3 flex-1">
- <Icon className="h-8 w-8" />
- <div className="flex-1 min-w-0">
- <div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
- <p className="font-medium text-card-foreground text-sm">
+ <div className="flex items-start justify-between mb-3">
+ <div className="flex items-center gap-3">
+ <div
+ className={`p-2 rounded-lg ${
+ isMoreComing ? "bg-muted" : "bg-accent"
+ }`}
+ >
+ <Icon className="h-6 w-6" />
+ </div>
+ <div>
+ <h3 className="font-semibold text-card-foreground text-sm">
{config.title}
- </p>
- {isConnected ? (
- <div className="flex items-center gap-1">
+ </h3>
+ {isMoreComing ? (
+ <div className="flex items-center gap-1 mt-1">
+ <div className="w-2 h-2 bg-muted-foreground rounded-full" />
+ <span className="text-xs text-muted-foreground font-medium">
+ Coming Soon
+ </span>
+ </div>
+ ) : isConnected ? (
+ <div className="flex items-center gap-1 mt-1">
<div className="w-2 h-2 bg-chart-2 rounded-full" />
<span className="text-xs text-chart-2 font-medium">
Connected
</span>
</div>
) : (
- <div className="hidden sm:flex items-center gap-1">
+ <div className="flex items-center gap-1 mt-1">
<div className="w-2 h-2 bg-muted-foreground rounded-full" />
<span className="text-xs text-muted-foreground font-medium">
Disconnected
@@ -632,72 +872,170 @@ export function IntegrationsView() {
</div>
)}
</div>
- <p className="text-xs text-muted-foreground mt-0.5">
- {config.description}
- </p>
- {connection?.email && (
- <p className="text-xs text-muted-foreground/70 mt-1">
- {connection.email}
- </p>
- )}
</div>
+ {isConnected && !isMoreComing && (
+ <Button
+ className="text-destructive hover:bg-destructive/10"
+ disabled={deleteConnectionMutation.isPending}
+ onClick={() =>
+ deleteConnectionMutation.mutate(connection.id)
+ }
+ size="sm"
+ variant="ghost"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ )}
</div>
- <div className="flex items-center justify-end gap-2 sm:flex-shrink-0">
- {isConnected ? (
- <motion.div
- whileHover={{ scale: 1.05 }}
- whileTap={{ scale: 0.95 }}
- >
- <Button
- className="text-destructive hover:bg-destructive/10 w-full sm:w-auto"
- disabled={deleteConnectionMutation.isPending}
- onClick={() =>
- deleteConnectionMutation.mutate(connection.id)
- }
- size="sm"
- variant="ghost"
- >
- <Trash2 className="h-4 w-4 sm:mr-2" />
- <span className="hidden sm:inline">Disconnect</span>
- </Button>
- </motion.div>
- ) : (
- <div className="flex items-center justify-between gap-2 w-full sm:w-auto">
- <div className="sm:hidden flex items-center gap-1">
- <div className="w-2 h-2 bg-muted-foreground rounded-full" />
- <span className="text-xs text-muted-foreground font-medium">
- Disconnected
- </span>
- </div>
- <motion.div
- whileHover={{ scale: 1.02 }}
- whileTap={{ scale: 0.98 }}
- className="flex-shrink-0"
- >
+ <p className="text-xs text-muted-foreground mb-4 leading-relaxed">
+ {config.description}
+ </p>
+
+ {connection?.email && !isMoreComing && (
+ <p className="text-xs text-muted-foreground/70 mb-4">
+ {connection.email}
+ </p>
+ )}
+
+ {!isConnected && !isMoreComing && (
+ <div className="flex items-center gap-2">
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
<Button
- className="min-w-[80px] disabled:cursor-not-allowed"
- disabled={
- addConnectionMutation.isPending || !isProUser
+ variant="outline"
+ size="sm"
+ className="flex-1 flex items-center gap-2 justify-between min-w-0"
+ >
+ <div className="flex items-center gap-2 min-w-0">
+ <FolderIcon className="h-4 w-4 flex-shrink-0" />
+ <span className="truncate">
+ {getProjectName(
+ selectedProjectForConnection[provider] ||
+ selectedProject,
+ )}
+ </span>
+ </div>
+ <ChevronDown className="h-3 w-3 flex-shrink-0" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-56">
+ <DropdownMenuItem
+ onClick={() =>
+ updateProjectForConnector(
+ provider,
+ selectedProject,
+ )
}
+ className="flex items-center gap-2"
+ >
+ <FolderIcon className="h-4 w-4 text-primary" />
+ <span>Default Project</span>
+ {(selectedProjectForConnection[provider] ||
+ selectedProject) === selectedProject && (
+ <Check className="h-4 w-4 ml-auto" />
+ )}
+ </DropdownMenuItem>
+
+ {filteredProjects.length > 0 ? (
+ filteredProjects.map((project: Project) => (
+ <DropdownMenuItem
+ key={project.id}
+ onClick={() =>
+ updateProjectForConnector(
+ provider,
+ project.containerTag,
+ )
+ }
+ className="flex items-center gap-2"
+ >
+ <FolderIcon className="h-4 w-4 text-muted-foreground" />
+ <span className="truncate">
+ {project.name}
+ </span>
+ {(selectedProjectForConnection[provider] ||
+ selectedProject) ===
+ project.containerTag && (
+ <Check className="h-4 w-4 ml-auto" />
+ )}
+ </DropdownMenuItem>
+ ))
+ ) : (
+ <DropdownMenuItem
+ disabled
+ className="text-muted-foreground"
+ >
+ No additional projects available
+ </DropdownMenuItem>
+ )}
+
+ <DropdownMenuItem
onClick={() => {
- addConnectionMutation.mutate(
- provider as ConnectorProvider,
- )
+ setCreatingProjectForConnector(provider)
+ setShowCreateProjectForm(true)
}}
- size="sm"
- variant="default"
+ className="flex items-center gap-2 text-muted-foreground"
>
- {addConnectionMutation.isPending &&
- addConnectionMutation.variables === provider
- ? "Connecting..."
- : "Connect"}
- </Button>
+ <Plus className="h-4 w-4" />
+ <span>Create New Project</span>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ <Button
+ className="flex-shrink-0 disabled:cursor-not-allowed w-20 justify-center"
+ disabled={
+ addConnectionMutation.isPending ||
+ connectingProvider === provider ||
+ !isProUser
+ }
+ onClick={() => {
+ addConnectionMutation.mutate(
+ provider as ConnectorProvider,
+ )
+ }}
+ size="sm"
+ variant="default"
+ >
+ <motion.div
+ key={
+ (addConnectionMutation.isPending &&
+ addConnectionMutation.variables === provider) ||
+ connectingProvider === provider
+ ? "loading"
+ : "connect"
+ }
+ initial={{ opacity: 0, scale: 0.8 }}
+ animate={{ opacity: 1, scale: 1 }}
+ exit={{ opacity: 0, scale: 0.8 }}
+ transition={{ duration: 0.2, ease: "easeInOut" }}
+ className="flex items-center justify-center"
+ >
+ {(addConnectionMutation.isPending &&
+ addConnectionMutation.variables === provider) ||
+ connectingProvider === provider ? (
+ <Loader className="h-4 w-4 animate-spin" />
+ ) : (
+ "Connect"
+ )}
</motion.div>
- </div>
- )}
- </div>
- </motion.div>
+ </Button>
+ </div>
+ )}
+
+ {isMoreComing && (
+ <div className="flex items-center justify-center">
+ <Button
+ variant="outline"
+ size="sm"
+ disabled
+ className="text-muted-foreground"
+ >
+ Coming Soon
+ </Button>
+ </div>
+ )}
+ </div>
)
})}
</div>
@@ -932,6 +1270,94 @@ export function IntegrationsView() {
</DialogContent>
</DialogPortal>
</Dialog>
+
+ <Dialog
+ key="create-project-dialog"
+ open={showCreateProjectForm}
+ onOpenChange={(open) => {
+ if (!open) {
+ handleCreateProjectCancel()
+ }
+ setShowCreateProjectForm(open)
+ }}
+ >
+ <DialogPortal>
+ <DialogContent className="bg-card border-border text-card-foreground md:max-w-md z-[100]">
+ <DialogHeader>
+ <DialogTitle className="text-card-foreground text-lg font-semibold">
+ Create New Project
+ </DialogTitle>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div className="space-y-2">
+ <label
+ htmlFor="newProjectName"
+ className="text-sm font-medium text-muted-foreground"
+ >
+ Project Name
+ </label>
+ <Input
+ id="newProjectName"
+ placeholder="Enter project name..."
+ value={newProjectName}
+ onChange={(e) => setNewProjectName(e.target.value)}
+ onKeyDown={(e) => {
+ if (
+ e.key === "Enter" &&
+ newProjectName.trim() &&
+ !validateProjectName(newProjectName) &&
+ !createProjectMutation.isPending
+ ) {
+ handleCreateProject()
+ }
+ }}
+ className="w-full"
+ autoFocus
+ disabled={createProjectMutation.isPending}
+ />
+ </div>
+
+ <div className="flex gap-2">
+ <Button
+ onClick={handleCreateProjectCancel}
+ variant="outline"
+ className="flex-1"
+ >
+ Cancel
+ </Button>
+ <Button
+ onClick={handleCreateProject}
+ disabled={
+ !newProjectName.trim() ||
+ createProjectMutation.isPending ||
+ !!validateProjectName(newProjectName)
+ }
+ className="flex-1"
+ >
+ <motion.div
+ key={createProjectMutation.isPending ? "loading" : "create"}
+ initial={{ opacity: 0, scale: 0.8 }}
+ animate={{ opacity: 1, scale: 1 }}
+ exit={{ opacity: 0, scale: 0.8 }}
+ transition={{ duration: 0.2, ease: "easeInOut" }}
+ className="flex items-center gap-2"
+ >
+ {createProjectMutation.isPending ? (
+ <>
+ <Loader className="h-4 w-4 animate-spin" />
+ Creating...
+ </>
+ ) : (
+ "Create"
+ )}
+ </motion.div>
+ </Button>
+ </div>
+ </div>
+ </DialogContent>
+ </DialogPortal>
+ </Dialog>
</div>
)
}