From ac80f23f0cc18032f1488acb386d291d507f745a Mon Sep 17 00:00:00 2001 From: Gerome-Elassaad Date: Wed, 19 Nov 2025 04:40:24 +1100 Subject: [PATCH 01/21] Add Cloudflare Pages deployment integration Implement comprehensive Cloudflare Pages deployment functionality including connection management, deployment hooks, UI components, and API endpoints to provide users with another hosting option alongside existing Netlify and Vercel integrations. --- .../tabs/connections/CloudflareConnection.tsx | 282 ++++++++++++++++++ .../tabs/connections/ConnectionsTab.tsx | 4 + .../chat/CloudflareDeploymentLink.client.tsx | 116 +++++++ .../deploy/CloudflareDeploy.client.tsx | 193 ++++++++++++ .../header/HeaderActionButtons.client.tsx | 51 +++- app/components/workbench/DeployDialog.tsx | 46 ++- app/components/workbench/PreviewHeader.tsx | 94 +++++- app/lib/runtime/action-runner.ts | 2 +- app/lib/stores/cloudflare.ts | 114 +++++++ app/routes/api.cloudflare-deploy.ts | 241 +++++++++++++++ app/routes/api.update.ts | 2 +- app/types/actions.ts | 2 +- app/types/cloudflare.ts | 24 ++ package.json | 2 +- pnpm-lock.yaml | 2 +- 15 files changed, 1152 insertions(+), 23 deletions(-) create mode 100644 app/components/@settings/tabs/connections/CloudflareConnection.tsx create mode 100644 app/components/chat/CloudflareDeploymentLink.client.tsx create mode 100644 app/components/deploy/CloudflareDeploy.client.tsx create mode 100644 app/lib/stores/cloudflare.ts create mode 100644 app/routes/api.cloudflare-deploy.ts create mode 100644 app/types/cloudflare.ts diff --git a/app/components/@settings/tabs/connections/CloudflareConnection.tsx b/app/components/@settings/tabs/connections/CloudflareConnection.tsx new file mode 100644 index 00000000..4a835d89 --- /dev/null +++ b/app/components/@settings/tabs/connections/CloudflareConnection.tsx @@ -0,0 +1,282 @@ +import { useState, useEffect } from 'react'; +import { motion } from 'framer-motion'; +import { toast } from 'react-toastify'; +import { useStore } from '@nanostores/react'; +import { logStore } from '~/lib/stores/logs'; +import { classNames } from '~/utils/classNames'; +import { + cloudflareConnection, + isConnecting, + isFetchingStats, + updateCloudflareConnection, + fetchCloudflareStats, +} from '~/lib/stores/cloudflare'; + +export default function CloudflareConnection() { + const connection = useStore(cloudflareConnection); + const connecting = useStore(isConnecting); + const fetchingStats = useStore(isFetchingStats); + const [isProjectsExpanded, setIsProjectsExpanded] = useState(false); + + useEffect(() => { + const fetchProjects = async () => { + if (connection.user && connection.token && connection.accountId) { + await fetchCloudflareStats(connection.token, connection.accountId); + } + }; + fetchProjects(); + }, [connection.user, connection.token, connection.accountId]); + + const handleConnect = async (event: React.FormEvent) => { + event.preventDefault(); + isConnecting.set(true); + + try { + // First verify the token and account ID + const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${connection.accountId}`, { + headers: { + Authorization: `Bearer ${connection.token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Invalid token or account ID'); + } + + const accountData = (await response.json()) as any; + updateCloudflareConnection({ + user: accountData.result, + token: connection.token, + accountId: connection.accountId, + }); + + await fetchCloudflareStats(connection.token, connection.accountId); + toast.success('Successfully connected to Cloudflare'); + } catch (error) { + console.error('Auth error:', error); + logStore.logError('Failed to authenticate with Cloudflare', { error }); + toast.error('Failed to connect to Cloudflare'); + updateCloudflareConnection({ user: null, token: '', accountId: '' }); + } finally { + isConnecting.set(false); + } + }; + + const handleDisconnect = () => { + updateCloudflareConnection({ user: null, token: '', accountId: '' }); + toast.success('Disconnected from Cloudflare'); + }; + + return ( + +
+
+
+ +

Cloudflare Connection

+
+
+ + {connection.user ? ( +
+
+
+ + +
+ Connected to Cloudflare +
+
+
+ +
+
+ Account: {connection.user.name || 'Cloudflare Account'} +
+
+ + {fetchingStats ? ( +
+
+ Fetching Cloudflare Pages projects... +
+ ) : ( +
+ + {isProjectsExpanded && connection.stats?.projects && connection.stats.projects.length > 0 ? ( +
+ {connection.stats.projects.map((project) => ( + +
+
+
+
+ {project.name} +
+
+ {project.latest_deployment ? ( + <> + + {project.latest_deployment.url} + + + +
+ {new Date(project.latest_deployment.created_on).toLocaleDateString()} +
+ + ) : ( + No deployments yet + )} +
+
+
+ + ))} +
+ ) : isProjectsExpanded ? ( +
+
+ No Pages projects found in your account +
+ ) : null} +
+ )} +
+ ) : ( +
+
+ + updateCloudflareConnection({ ...connection, token: e.target.value })} + disabled={connecting} + placeholder="Enter your Cloudflare API token" + className={classNames( + 'w-full px-3 py-2 rounded-lg text-sm', + 'bg-[#F8F8F8] dark:bg-[#1A1A1A]', + 'border border-[#E5E5E5] dark:border-[#333333]', + 'text-codinit-elements-textPrimary placeholder-codinit-elements-textTertiary', + 'focus:outline-none focus:ring-1 focus:ring-codinit-elements-borderColorActive', + 'disabled:opacity-50', + )} + /> + +
+ +
+ + updateCloudflareConnection({ ...connection, accountId: e.target.value })} + disabled={connecting} + placeholder="Enter your Cloudflare Account ID" + className={classNames( + 'w-full px-3 py-2 rounded-lg text-sm', + 'bg-[#F8F8F8] dark:bg-[#1A1A1A]', + 'border border-[#E5E5E5] dark:border-[#333333]', + 'text-codinit-elements-textPrimary placeholder-codinit-elements-textTertiary', + 'focus:outline-none focus:ring-1 focus:ring-codinit-elements-borderColorActive', + 'disabled:opacity-50', + )} + /> +
+ + Find your Account ID +
+
{' '} + → Account Home → Account ID +
+
+ + +
+ )} +
+
+ ); +} diff --git a/app/components/@settings/tabs/connections/ConnectionsTab.tsx b/app/components/@settings/tabs/connections/ConnectionsTab.tsx index 6b01a3ce..bde6505b 100644 --- a/app/components/@settings/tabs/connections/ConnectionsTab.tsx +++ b/app/components/@settings/tabs/connections/ConnectionsTab.tsx @@ -8,6 +8,7 @@ import VercelConnection from './VercelConnection'; // Use React.lazy for dynamic imports const GitHubConnection = React.lazy(() => import('./GithubConnection')); const NetlifyConnection = React.lazy(() => import('./NetlifyConnection')); +const CloudflareConnection = React.lazy(() => import('./CloudflareConnection')); // Loading fallback component const LoadingFallback = () => ( @@ -159,6 +160,9 @@ export default function ConnectionsTab() { }> + }> + + {/* Additional help text */} diff --git a/app/components/chat/CloudflareDeploymentLink.client.tsx b/app/components/chat/CloudflareDeploymentLink.client.tsx new file mode 100644 index 00000000..530d3da4 --- /dev/null +++ b/app/components/chat/CloudflareDeploymentLink.client.tsx @@ -0,0 +1,116 @@ +import { useStore } from '@nanostores/react'; +import { cloudflareConnection } from '~/lib/stores/cloudflare'; +import { chatId } from '~/lib/persistence/useChatHistory'; +import * as Tooltip from '@radix-ui/react-tooltip'; +import { useEffect, useState } from 'react'; + +export function CloudflareDeploymentLink() { + const connection = useStore(cloudflareConnection); + const currentChatId = useStore(chatId); + const [deploymentUrl, setDeploymentUrl] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + async function fetchProjectData() { + if (!connection.token || !connection.accountId || !currentChatId) { + return; + } + + // Check if we have a stored project name for this chat + const projectName = localStorage.getItem(`cloudflare-project-${currentChatId}`); + + if (!projectName) { + return; + } + + setIsLoading(true); + + try { + // Fetch project details from Cloudflare Pages API + const projectResponse = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${connection.accountId}/pages/projects/${projectName}`, + { + headers: { + Authorization: `Bearer ${connection.token}`, + 'Content-Type': 'application/json', + }, + cache: 'no-store', + }, + ); + + if (!projectResponse.ok) { + throw new Error(`Failed to fetch project: ${projectResponse.status}`); + } + + const projectData = (await projectResponse.json()) as any; + + if (projectData.result) { + // Get latest deployment + const deploymentsResponse = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${connection.accountId}/pages/projects/${projectName}/deployments?limit=1`, + { + headers: { + Authorization: `Bearer ${connection.token}`, + 'Content-Type': 'application/json', + }, + cache: 'no-store', + }, + ); + + if (deploymentsResponse.ok) { + const deploymentsData = (await deploymentsResponse.json()) as any; + + if (deploymentsData.result && deploymentsData.result.length > 0) { + const latestDeployment = deploymentsData.result[0]; + setDeploymentUrl(latestDeployment.url); + + return; + } + } + + // Fallback to constructed URL if no deployments found + setDeploymentUrl(`https://${projectName}.pages.dev`); + } + } catch (err) { + console.error('Error fetching Cloudflare deployment:', err); + } finally { + setIsLoading(false); + } + } + + fetchProjectData(); + }, [connection.token, connection.accountId, currentChatId]); + + if (!deploymentUrl) { + return null; + } + + return ( + + + + { + e.stopPropagation(); + }} + > +
+ + + + + {deploymentUrl} + + + + + + ); +} diff --git a/app/components/deploy/CloudflareDeploy.client.tsx b/app/components/deploy/CloudflareDeploy.client.tsx new file mode 100644 index 00000000..2ac650f1 --- /dev/null +++ b/app/components/deploy/CloudflareDeploy.client.tsx @@ -0,0 +1,193 @@ +import { toast } from 'react-toastify'; +import { useStore } from '@nanostores/react'; +import { cloudflareConnection } from '~/lib/stores/cloudflare'; +import { workbenchStore } from '~/lib/stores/workbench'; +import { webcontainer } from '~/lib/webcontainer'; +import { path } from '~/utils/path'; +import { useState } from 'react'; +import type { ActionCallbackData } from '~/lib/runtime/message-parser'; +import { chatId } from '~/lib/persistence/useChatHistory'; + +export function useCloudflareDeploy() { + const [isDeploying, setIsDeploying] = useState(false); + const cloudflareConn = useStore(cloudflareConnection); + const currentChatId = useStore(chatId); + + const handleCloudflareDeploy = async () => { + if (!cloudflareConn.user || !cloudflareConn.token || !cloudflareConn.accountId) { + toast.error('Please connect to Cloudflare first in the settings tab!'); + return false; + } + + if (!currentChatId) { + toast.error('No active chat found'); + return false; + } + + try { + setIsDeploying(true); + + const artifact = workbenchStore.firstArtifact; + + if (!artifact) { + throw new Error('No active project found'); + } + + // Create a deployment artifact for visual feedback + const deploymentId = `deploy-cloudflare-project`; + workbenchStore.addArtifact({ + id: deploymentId, + messageId: deploymentId, + title: 'Cloudflare Pages Deployment', + type: 'standalone', + }); + + const deployArtifact = workbenchStore.artifacts.get()[deploymentId]; + + // Notify that build is starting + deployArtifact.runner.handleDeployAction('building', 'running', { source: 'cloudflare' }); + + const actionId = 'build-' + Date.now(); + const actionData: ActionCallbackData = { + messageId: 'cloudflare build', + artifactId: artifact.id, + actionId, + action: { + type: 'build' as const, + content: 'npm run build', + }, + }; + + // Add the action first + artifact.runner.addAction(actionData); + + // Then run it + await artifact.runner.runAction(actionData); + + if (!artifact.runner.buildOutput) { + // Notify that build failed + deployArtifact.runner.handleDeployAction('building', 'failed', { + error: 'Build failed. Check the terminal for details.', + source: 'cloudflare', + }); + throw new Error('Build failed'); + } + + // Notify that build succeeded and deployment is starting + deployArtifact.runner.handleDeployAction('deploying', 'running', { source: 'cloudflare' }); + + // Get the build files + const container = await webcontainer; + + // Remove /home/project from buildPath if it exists + const buildPath = artifact.runner.buildOutput.path.replace('/home/project', ''); + + // Check if the build path exists + let finalBuildPath = buildPath; + + // List of common output directories to check if the specified build path doesn't exist + const commonOutputDirs = [buildPath, '/dist', '/build', '/out', '/output', '/.next', '/public']; + + // Verify the build path exists, or try to find an alternative + let buildPathExists = false; + + for (const dir of commonOutputDirs) { + try { + await container.fs.readdir(dir); + finalBuildPath = dir; + buildPathExists = true; + console.log(`Using build directory: ${finalBuildPath}`); + break; + } catch (error) { + console.log(`Directory ${dir} doesn't exist, trying next option. ${error}`); + continue; + } + } + + if (!buildPathExists) { + throw new Error('Could not find build output directory. Please check your build configuration.'); + } + + // Get all files recursively + async function getAllFiles(dirPath: string): Promise> { + const files: Record = {}; + const entries = await container.fs.readdir(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + + if (entry.isFile()) { + const content = await container.fs.readFile(fullPath, 'utf-8'); + + // Remove build path prefix from the path + const deployPath = fullPath.replace(finalBuildPath, ''); + files[deployPath] = content; + } else if (entry.isDirectory()) { + const subFiles = await getAllFiles(fullPath); + Object.assign(files, subFiles); + } + } + + return files; + } + + const fileContents = await getAllFiles(finalBuildPath); + + // Use chatId instead of artifact.id + const existingProjectId = localStorage.getItem(`cloudflare-project-${currentChatId}`); + + const response = await fetch('/api/cloudflare-deploy', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + projectId: existingProjectId || undefined, + files: fileContents, + token: cloudflareConn.token, + accountId: cloudflareConn.accountId, + chatId: currentChatId, + }), + }); + + const data = (await response.json()) as any; + + if (!response.ok || !data.deploy || !data.project) { + console.error('Invalid deploy response:', data); + + // Notify that deployment failed + deployArtifact.runner.handleDeployAction('deploying', 'failed', { + error: data.error || 'Invalid deployment response', + source: 'cloudflare', + }); + throw new Error(data.error || 'Invalid deployment response'); + } + + // Store the project ID if it's a new site + if (data.project) { + localStorage.setItem(`cloudflare-project-${currentChatId}`, data.project.name); + } + + // Notify that deployment completed successfully + deployArtifact.runner.handleDeployAction('complete', 'complete', { + url: data.deploy.url, + source: 'cloudflare', + }); + + return true; + } catch (err) { + console.error('Cloudflare deploy error:', err); + toast.error(err instanceof Error ? err.message : 'Cloudflare Pages deployment failed'); + + return false; + } finally { + setIsDeploying(false); + } + }; + + return { + isDeploying, + handleCloudflareDeploy, + isConnected: !!cloudflareConn.user, + }; +} diff --git a/app/components/header/HeaderActionButtons.client.tsx b/app/components/header/HeaderActionButtons.client.tsx index 0468079c..884d796d 100644 --- a/app/components/header/HeaderActionButtons.client.tsx +++ b/app/components/header/HeaderActionButtons.client.tsx @@ -3,14 +3,17 @@ import useViewport from '~/lib/hooks'; import { chatStore } from '~/lib/stores/chat'; import { netlifyConnection } from '~/lib/stores/netlify'; import { vercelConnection } from '~/lib/stores/vercel'; +import { cloudflareConnection } from '~/lib/stores/cloudflare'; import { workbenchStore } from '~/lib/stores/workbench'; import { classNames } from '~/utils/classNames'; import { useState } from 'react'; import { streamingState } from '~/lib/stores/streaming'; import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client'; import { VercelDeploymentLink } from '~/components/chat/VercelDeploymentLink.client'; +import { CloudflareDeploymentLink } from '~/components/chat/CloudflareDeploymentLink.client'; import { useVercelDeploy } from '~/components/deploy/VercelDeploy.client'; import { useNetlifyDeploy } from '~/components/deploy/NetlifyDeploy.client'; +import { useCloudflareDeploy } from '~/components/deploy/CloudflareDeploy.client'; interface HeaderActionButtonsProps {} @@ -19,15 +22,17 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { const { showChat } = useStore(chatStore); const netlifyConn = useStore(netlifyConnection); const vercelConn = useStore(vercelConnection); + const cloudflareConn = useStore(cloudflareConnection); const [activePreviewIndex] = useState(0); const previews = useStore(workbenchStore.previews); const activePreview = previews[activePreviewIndex]; - const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | null>(null); + const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | 'cloudflare' | null>(null); const isSmallViewport = useViewport(1024); const canHideChat = showWorkbench || !showChat; const isStreaming = useStore(streamingState); const { handleVercelDeploy } = useVercelDeploy(); const { handleNetlifyDeploy } = useNetlifyDeploy(); + const { handleCloudflareDeploy } = useCloudflareDeploy(); const onVercelDeploy = async () => { setDeployingTo('vercel'); @@ -49,6 +54,16 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { } }; + const onCloudflareDeploy = async () => { + setDeployingTo('cloudflare'); + + try { + await handleCloudflareDeploy(); + } finally { + setDeployingTo(null); + } + }; + const isDeploying = deployingTo !== null; return ( @@ -104,15 +119,31 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { {vercelConn.user && }
- - cloudflare + + {deployingTo === 'cloudflare' ? ( +
+ ) : ( + cloudflare + )} + {cloudflareConn.user && }
(null); + const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | 'cloudflare' | null>(null); const { handleVercelDeploy } = useVercelDeploy(); const { handleNetlifyDeploy } = useNetlifyDeploy(); + const { handleCloudflareDeploy } = useCloudflareDeploy(); const onVercelDeploy = async () => { setDeployingTo('vercel'); @@ -42,6 +46,17 @@ export function DeployDialog({ isOpen, onClose }: DeployDialogProps) { } }; + const onCloudflareDeploy = async () => { + setDeployingTo('cloudflare'); + + try { + await handleCloudflareDeploy(); + onClose(); + } finally { + setDeployingTo(null); + } + }; + const isDeploying = deployingTo !== null; if (!isOpen) { @@ -122,16 +137,33 @@ export function DeployDialog({ isOpen, onClose }: DeployDialogProps) {
- {/* Cloudflare - Coming Soon */} -
+ {/* Cloudflare */} +
{/* Footer */} diff --git a/app/components/workbench/PreviewHeader.tsx b/app/components/workbench/PreviewHeader.tsx index fa3602f7..bbe31554 100644 --- a/app/components/workbench/PreviewHeader.tsx +++ b/app/components/workbench/PreviewHeader.tsx @@ -6,6 +6,12 @@ import { workbenchStore, type WorkbenchViewType } from '~/lib/stores/workbench'; import { expoUrlAtom } from '~/lib/stores/qrCodeStore'; import { ExpoQrModal } from '~/components/workbench/ExpoQrModal'; import { DeployDialog } from './DeployDialog'; +import { useVercelDeploy } from '~/components/deploy/VercelDeploy.client'; +import { useNetlifyDeploy } from '~/components/deploy/NetlifyDeploy.client'; +import { useCloudflareDeploy } from '~/components/deploy/CloudflareDeploy.client'; +import { netlifyConnection } from '~/lib/stores/netlify'; +import { vercelConnection } from '~/lib/stores/vercel'; +import { cloudflareConnection } from '~/lib/stores/cloudflare'; interface PreviewHeaderProps { previews: any[]; @@ -57,11 +63,34 @@ export const PreviewHeader = memo( const [isExpoQrModalOpen, setIsExpoQrModalOpen] = useState(false); const [isDeployDialogOpen, setIsDeployDialogOpen] = useState(false); + // Deployment hooks + const { isDeploying: isDeployingVercel, handleVercelDeploy } = useVercelDeploy(); + const { isDeploying: isDeployingNetlify, handleNetlifyDeploy } = useNetlifyDeploy(); + const { isDeploying: isDeployingCloudflare, handleCloudflareDeploy } = useCloudflareDeploy(); + + // Connection states + const netlifyConn = useStore(netlifyConnection); + const vercelConn = useStore(vercelConnection); + const cloudflareConn = useStore(cloudflareConnection); + const activePreview = previews[activePreviewIndex]; const setSelectedView = (view: WorkbenchViewType) => { workbenchStore.currentView.set(view); }; + // Deployment handlers + const handleDeployToVercel = async () => { + await handleVercelDeploy(); + }; + + const handleDeployToNetlify = async () => { + await handleNetlifyDeploy(); + }; + + const handleDeployToCloudflare = async () => { + await handleCloudflareDeploy(); + }; + return (
{/* Toggle Buttons Section */} @@ -171,13 +200,76 @@ export const PreviewHeader = memo( className="text-codinit-elements-item-contentDefault bg-transparent rounded-md disabled:cursor-not-allowed enabled:hover:text-codinit-elements-item-contentActive enabled:hover:bg-codinit-elements-item-backgroundActive p-1 relative w-8 h-8" />
+ + {/* Deployment Buttons */} +
+ {/* Vercel Deploy Button */} + + + {/* Netlify Deploy Button */} + + + {/* Cloudflare Deploy Button */} + +
+ + {/* Deploy Dialog Button */} + @@ -237,7 +237,7 @@ export const PreviewHeader = memo( {isDeployingNetlify ? (
) : ( - Netlify + Netlify )} diff --git a/icons/vercel-icon-light.svg b/icons/vercel-icon-light.svg new file mode 100644 index 00000000..72948d01 --- /dev/null +++ b/icons/vercel-icon-light.svg @@ -0,0 +1,3 @@ + + + From 5fab42740f3b7a43d031c36ece1bbee001d53886 Mon Sep 17 00:00:00 2001 From: Gerome-Elassaad Date: Wed, 19 Nov 2025 05:02:07 +1100 Subject: [PATCH 03/21] Update Cloudflare deployment icon to use local SVG asset Replace external CDN reference with local cloudflare.svg for consistent branding and improved performance. --- app/components/workbench/PreviewHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/workbench/PreviewHeader.tsx b/app/components/workbench/PreviewHeader.tsx index c19449b5..bf4b01f4 100644 --- a/app/components/workbench/PreviewHeader.tsx +++ b/app/components/workbench/PreviewHeader.tsx @@ -256,7 +256,7 @@ export const PreviewHeader = memo( {isDeployingCloudflare ? (
) : ( - Cloudflare + Cloudflare )}
From 6828b3153733e36c72967d5a15011e7e1ed64de0 Mon Sep 17 00:00:00 2001 From: Gerome-Elassaad Date: Wed, 19 Nov 2025 05:07:05 +1100 Subject: [PATCH 04/21] Fix deployment icons in PreviewHeader and DeployDialog Convert deployment buttons to use IconButton components and inline SVG icons instead of img tags for better performance and reliability. Remove complex button styling and use consistent icon button pattern. --- app/components/workbench/DeployDialog.tsx | 56 +++++++++++- app/components/workbench/PreviewHeader.tsx | 101 ++++++++++++++------- 2 files changed, 121 insertions(+), 36 deletions(-) diff --git a/app/components/workbench/DeployDialog.tsx b/app/components/workbench/DeployDialog.tsx index d3df147e..0889721c 100644 --- a/app/components/workbench/DeployDialog.tsx +++ b/app/components/workbench/DeployDialog.tsx @@ -96,7 +96,31 @@ export function DeployDialog({ isOpen, onClose }: DeployDialogProps) { {deployingTo === 'netlify' ? (
) : ( - Netlify + + + + + + + + + )}
@@ -124,7 +148,16 @@ export function DeployDialog({ isOpen, onClose }: DeployDialogProps) { {deployingTo === 'vercel' ? (
) : ( - Vercel + + + )}
@@ -152,7 +185,24 @@ export function DeployDialog({ isOpen, onClose }: DeployDialogProps) { {deployingTo === 'cloudflare' ? (
) : ( - Cloudflare + )}
diff --git a/app/components/workbench/PreviewHeader.tsx b/app/components/workbench/PreviewHeader.tsx index bf4b01f4..282f5e54 100644 --- a/app/components/workbench/PreviewHeader.tsx +++ b/app/components/workbench/PreviewHeader.tsx @@ -204,61 +204,96 @@ export const PreviewHeader = memo( {/* Deployment Buttons */}
{/* Vercel Deploy Button */} - + {/* Netlify Deploy Button */} - + {/* Cloudflare Deploy Button */} - +
{/* Deploy Dialog Button */} From f3161938d2f2502148666458a5f5acbb329c6339 Mon Sep 17 00:00:00 2001 From: Gerome-Elassaad Date: Wed, 19 Nov 2025 05:09:22 +1100 Subject: [PATCH 05/21] Simplify left-side buttons in PreviewHeader Convert toggle buttons (Preview, Code, Database, Settings) to use IconButton components, removing theme-incompatible styling and ensuring consistent behavior across light and dark modes. --- app/components/workbench/PreviewHeader.tsx | 41 +++++----------------- 1 file changed, 8 insertions(+), 33 deletions(-) diff --git a/app/components/workbench/PreviewHeader.tsx b/app/components/workbench/PreviewHeader.tsx index 282f5e54..a6135034 100644 --- a/app/components/workbench/PreviewHeader.tsx +++ b/app/components/workbench/PreviewHeader.tsx @@ -96,42 +96,17 @@ export const PreviewHeader = memo( {/* Toggle Buttons Section */}
- - - + /> + setSelectedView('code')} /> +
- +
From 372edfe0fb5f07d2d9bdd9e9042c78a2b69b0e73 Mon Sep 17 00:00:00 2001 From: Gerome-Elassaad Date: Wed, 19 Nov 2025 05:13:12 +1100 Subject: [PATCH 06/21] Fix IconButton border centering and complete CodeModeHeader updates Update IconButton to conditionally apply padding only when no explicit width/height is set, ensuring proper centering of hover effects. Complete the CodeModeHeader button conversions and remove unused showTerminal variable. --- app/components/ui/IconButton.tsx | 5 +- app/components/workbench/CodeModeHeader.tsx | 55 +++---------------- app/components/workbench/Workbench.client.tsx | 2 - 3 files changed, 12 insertions(+), 50 deletions(-) diff --git a/app/components/ui/IconButton.tsx b/app/components/ui/IconButton.tsx index a20cc117..0acd7988 100644 --- a/app/components/ui/IconButton.tsx +++ b/app/components/ui/IconButton.tsx @@ -46,7 +46,10 @@ export const IconButton = memo( - - - + /> + setSelectedView('code')} /> +
- + setIsPushDialogOpen(true)} isSyncing={isSyncing} setIsPushDialogOpen={setIsPushDialogOpen} - showTerminal={showTerminal} /> )} From dfe6b91cea0a00507b0775245be5504f2dedb11b Mon Sep 17 00:00:00 2001 From: Gerome-Elassaad Date: Wed, 19 Nov 2025 05:14:53 +1100 Subject: [PATCH 07/21] Standardize all button sizes to w-8 h-8 Update DeployDialog icons and CodeModeHeader settings button to use consistent w-8 h-8 sizing, matching all other header buttons for uniform appearance across the application. --- app/components/workbench/CodeModeHeader.tsx | 2 +- app/components/workbench/DeployDialog.tsx | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/components/workbench/CodeModeHeader.tsx b/app/components/workbench/CodeModeHeader.tsx index c2d40490..3c1f7fab 100644 --- a/app/components/workbench/CodeModeHeader.tsx +++ b/app/components/workbench/CodeModeHeader.tsx @@ -42,7 +42,7 @@ export const CodeModeHeader = memo(
- +
{deployingTo === 'netlify' ? ( -
+
) : (
{deployingTo === 'vercel' ? ( -
+
) : (
{deployingTo === 'cloudflare' ? ( -
+
) : ( Date: Wed, 19 Nov 2025 05:15:57 +1100 Subject: [PATCH 08/21] Fix settings icon button dropdown functionality Replace IconButton with regular button element for dropdown trigger to ensure proper Radix UI asChild prop compatibility and dropdown menu functionality. --- app/components/workbench/CodeModeHeader.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/components/workbench/CodeModeHeader.tsx b/app/components/workbench/CodeModeHeader.tsx index 3c1f7fab..b4ab1025 100644 --- a/app/components/workbench/CodeModeHeader.tsx +++ b/app/components/workbench/CodeModeHeader.tsx @@ -42,7 +42,13 @@ export const CodeModeHeader = memo(
- + Date: Wed, 19 Nov 2025 05:17:09 +1100 Subject: [PATCH 09/21] Add settings dropdown functionality to PreviewHeader Implement dropdown menu for the settings button in preview mode with options for Reload Preview, Deploy Options, and Push to GitHub, providing quick access to common preview-related actions. --- app/components/workbench/PreviewHeader.tsx | 46 +++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/app/components/workbench/PreviewHeader.tsx b/app/components/workbench/PreviewHeader.tsx index a6135034..d53ce151 100644 --- a/app/components/workbench/PreviewHeader.tsx +++ b/app/components/workbench/PreviewHeader.tsx @@ -12,6 +12,7 @@ import { useCloudflareDeploy } from '~/components/deploy/CloudflareDeploy.client import { netlifyConnection } from '~/lib/stores/netlify'; import { vercelConnection } from '~/lib/stores/vercel'; import { cloudflareConnection } from '~/lib/stores/cloudflare'; +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; interface PreviewHeaderProps { previews: any[]; @@ -106,7 +107,50 @@ export const PreviewHeader = memo(
- + + + + + + +
+ + Reload Preview +
+
+ setIsDeployDialogOpen(true)} + > +
+ + Deploy Options +
+
+ setIsPushDialogOpen(true)} + > +
+ + Push to GitHub +
+
+
+
From aa976a9ae6f8c40d596536497823ada846ff1fcd Mon Sep 17 00:00:00 2001 From: Gerome-Elassaad Date: Wed, 19 Nov 2025 05:19:22 +1100 Subject: [PATCH 10/21] Standardize More Deploy and Publish button sizes Convert More Deploy and Publish buttons to icon buttons with w-8 h-8 sizing to match deployment icon buttons. Replace text labels with appropriate icons (plus and upload) and add tooltips for better UX. --- app/components/workbench/PreviewHeader.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/components/workbench/PreviewHeader.tsx b/app/components/workbench/PreviewHeader.tsx index d53ce151..a4ec3b3f 100644 --- a/app/components/workbench/PreviewHeader.tsx +++ b/app/components/workbench/PreviewHeader.tsx @@ -317,20 +317,22 @@ export const PreviewHeader = memo( {/* Deploy Dialog Button */}
From 4ac107acc362dab53b2303b849065256fa4bff34 Mon Sep 17 00:00:00 2001 From: Gerome-Elassaad Date: Wed, 19 Nov 2025 05:20:04 +1100 Subject: [PATCH 11/21] updated icons --- icons/cloudflare.svg | 1 + icons/vercel-icon-dark.svg | 3 +++ 2 files changed, 4 insertions(+) create mode 100644 icons/cloudflare.svg create mode 100644 icons/vercel-icon-dark.svg diff --git a/icons/cloudflare.svg b/icons/cloudflare.svg new file mode 100644 index 00000000..3ea6120c --- /dev/null +++ b/icons/cloudflare.svg @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/icons/vercel-icon-dark.svg b/icons/vercel-icon-dark.svg new file mode 100644 index 00000000..4e3b0e93 --- /dev/null +++ b/icons/vercel-icon-dark.svg @@ -0,0 +1,3 @@ + + + From 254ed851d92127a66bd8153c09d6578a4ea348f5 Mon Sep 17 00:00:00 2001 From: Gerome-Elassaad Date: Wed, 19 Nov 2025 05:20:48 +1100 Subject: [PATCH 12/21] Unify left-side button layout in PreviewHeader Consolidate all left-side buttons (Preview, Code, Database, Settings) into a single container with consistent gap-1 spacing, ensuring uniform size and spacing for all toggle buttons. --- app/components/workbench/PreviewHeader.tsx | 108 ++++++++++----------- 1 file changed, 52 insertions(+), 56 deletions(-) diff --git a/app/components/workbench/PreviewHeader.tsx b/app/components/workbench/PreviewHeader.tsx index a4ec3b3f..1166024f 100644 --- a/app/components/workbench/PreviewHeader.tsx +++ b/app/components/workbench/PreviewHeader.tsx @@ -95,63 +95,59 @@ export const PreviewHeader = memo( return (
{/* Toggle Buttons Section */} -
-
- setSelectedView('preview')} - /> - setSelectedView('code')} /> - -
-
- - - - - + setSelectedView('preview')} + /> + setSelectedView('code')} /> + + + +
+ + + + + +
+ + Reload Preview +
+
+ setIsDeployDialogOpen(true)} + > +
+ + Deploy Options +
+
+ setIsPushDialogOpen(true)} + > +
+ + Push to GitHub +
+
+
+
{/* Address Bar */} From 652a1f9c2b6f19be2ca5b71f75f843bae95e8c34 Mon Sep 17 00:00:00 2001 From: Gerome-Elassaad Date: Wed, 19 Nov 2025 05:21:55 +1100 Subject: [PATCH 13/21] Ensure consistent button styling in PreviewHeader Update settings button to use the exact same CSS classes as IconButton components, including focus states and disabled styling, for perfect visual consistency. --- app/components/workbench/PreviewHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/workbench/PreviewHeader.tsx b/app/components/workbench/PreviewHeader.tsx index 1166024f..362a739d 100644 --- a/app/components/workbench/PreviewHeader.tsx +++ b/app/components/workbench/PreviewHeader.tsx @@ -108,7 +108,7 @@ export const PreviewHeader = memo(
diff --git a/app/components/workbench/Workbench.client.tsx b/app/components/workbench/Workbench.client.tsx index cfcf4d6d..05d5dd18 100644 --- a/app/components/workbench/Workbench.client.tsx +++ b/app/components/workbench/Workbench.client.tsx @@ -610,9 +610,6 @@ export const Workbench = memo(
{selectedView === 'code' && ( { - workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get()); - }} onDownloadZip={() => { workbenchStore.downloadZip(); }} From 5d333e6ab4d33cdcb853fba0aca20f9d33a472ce Mon Sep 17 00:00:00 2001 From: Gerome-Elassaad Date: Wed, 19 Nov 2025 05:32:13 +1100 Subject: [PATCH 15/21] updated preview header --- app/components/workbench/CodeModeHeader.tsx | 6 ------ app/components/workbench/PreviewHeader.tsx | 8 -------- 2 files changed, 14 deletions(-) diff --git a/app/components/workbench/CodeModeHeader.tsx b/app/components/workbench/CodeModeHeader.tsx index d9efb6c9..658bf789 100644 --- a/app/components/workbench/CodeModeHeader.tsx +++ b/app/components/workbench/CodeModeHeader.tsx @@ -87,12 +87,6 @@ export const CodeModeHeader = memo(
-
- -
-
-
+
+
Ready to import? -
+
-

+

Search GitHub

@@ -747,13 +751,15 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit setSearchResults([]); }} iconClassName="text-accent-500" - className="py-3 bg-white dark:bg-codinit-elements-background-depth-4 border border-codinit-elements-borderColor dark:border-codinit-elements-borderColor-dark text-codinit-elements-textPrimary dark:text-codinit-elements-textPrimary-dark focus:outline-none focus:ring-2 focus:ring-accent-500 shadow-sm" + className="py-3 border border-codinit-elements-borderColor text-codinit-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-accent-500 shadow-sm" + style={{ backgroundColor: 'var(--codinit-elements-bg-depth-4)' }} loading={isLoading} />
setFilters({})} - className="px-3 py-2 rounded-lg bg-white dark:bg-codinit-elements-background-depth-4 text-codinit-elements-textSecondary hover:text-codinit-elements-textPrimary border border-codinit-elements-borderColor dark:border-codinit-elements-borderColor-dark shadow-sm" + className="px-3 py-2 rounded-lg text-codinit-elements-textSecondary hover:text-codinit-elements-textPrimary border border-codinit-elements-borderColor shadow-sm" + style={{ backgroundColor: 'var(--codinit-elements-bg-depth-4)' }} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} title="Clear filters" @@ -828,7 +834,7 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
-
+
-
+
handleFilterChange('stars', e.target.value)} - className="w-full pl-8 px-3 py-2 text-sm rounded-lg bg-white dark:bg-codinit-elements-background-depth-4 border border-codinit-elements-borderColor dark:border-codinit-elements-borderColor-dark focus:outline-none focus:ring-2 focus:ring-accent-500" + className="w-full pl-8 px-3 py-2 text-sm rounded-lg border border-codinit-elements-borderColor focus:outline-none focus:ring-2 focus:ring-accent-500" + style={{ backgroundColor: 'var(--codinit-elements-bg-depth-4)' }} />
-
+
handleFilterChange('forks', e.target.value)} - className="w-full pl-8 px-3 py-2 text-sm rounded-lg bg-white dark:bg-codinit-elements-background-depth-4 border border-codinit-elements-borderColor dark:border-codinit-elements-borderColor-dark focus:outline-none focus:ring-2 focus:ring-accent-500" + className="w-full pl-8 px-3 py-2 text-sm rounded-lg border border-codinit-elements-borderColor focus:outline-none focus:ring-2 focus:ring-accent-500" + style={{ backgroundColor: 'var(--codinit-elements-bg-depth-4)' }} />
-
+

@@ -953,13 +962,17 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit handleColorChange(role.key, e.target.value)} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" tabIndex={-1} @@ -108,7 +146,7 @@ export const ColorSchemeDialog: React.FC = ({ setDesignS {role.description}

- {palette[role.key]} + {palette[mode][role.key]}
@@ -274,15 +312,100 @@ export const ColorSchemeDialog: React.FC = ({ setDesignS
); + const renderStylingSection = () => ( +
+

+
+ Design Styling +

+ +
+ {/* Border Radius */} +
+ +
+ {borderRadiusOptions.map((option) => ( + + ))} +
+
+ + {/* Shadow */} +
+ +
+ {shadowOptions.map((option) => ( + + ))} +
+
+ + {/* Spacing */} +
+ +
+ {spacingOptions.map((option) => ( + + ))} +
+
+
+
+ ); + return (
- setIsDialogOpen(!isDialogOpen)}> -
-
- - -
+ +
Design Palette & Features @@ -299,6 +422,7 @@ export const ColorSchemeDialog: React.FC = ({ setDesignS { key: 'colors', label: 'Colors', icon: 'i-ph:palette' }, { key: 'typography', label: 'Typography', icon: 'i-ph:text-aa' }, { key: 'features', label: 'Features', icon: 'i-ph:magic-wand' }, + { key: 'styling', label: 'Styling', icon: 'i-ph:gear' }, ].map((tab) => (
{/* Action Buttons */} diff --git a/app/components/ui/WorkspaceModal.tsx b/app/components/ui/WorkspaceModal.tsx index 0819b133..ab53a2c3 100644 --- a/app/components/ui/WorkspaceModal.tsx +++ b/app/components/ui/WorkspaceModal.tsx @@ -198,6 +198,7 @@ export function WorkspaceModal({ isOpen, onClose, children }: WorkspaceModalProp className="w-[90vw] h-[90vh] max-w-7xl" > + Workspace settings and configuration {/* Sidebar */}
diff --git a/app/components/workbench/DeployDialog.tsx b/app/components/workbench/DeployDialog.tsx index 56babce4..6356919f 100644 --- a/app/components/workbench/DeployDialog.tsx +++ b/app/components/workbench/DeployDialog.tsx @@ -128,7 +128,7 @@ export function DeployDialog({ isOpen, onClose }: DeployDialogProps) { {deployingTo === 'netlify' ? 'Deploying...' : 'Deploy to Netlify'}
- {!netlifyConn.user ? 'Connect your Netlify account first' : 'Fast, global CDN deployment'} + {!netlifyConn.user ? 'Add your Netlify API key first in settings' : 'Fast, global CDN deployment'}
@@ -165,7 +165,7 @@ export function DeployDialog({ isOpen, onClose }: DeployDialogProps) { {deployingTo === 'vercel' ? 'Deploying...' : 'Deploy to Vercel'}
- {!vercelConn.user ? 'Connect your Vercel account first' : 'Optimized for frontend frameworks'} + {!vercelConn.user ? 'Add your Vercel API key first in settings' : 'Optimized for frontend frameworks'}
@@ -210,14 +210,16 @@ export function DeployDialog({ isOpen, onClose }: DeployDialogProps) { {deployingTo === 'cloudflare' ? 'Deploying...' : 'Deploy to Cloudflare'}
- {!cloudflareConn.user ? 'Connect your Cloudflare account first' : 'Global CDN with edge computing'} + {!cloudflareConn.user + ? 'Add your Clouflare API key first in settings' + : 'Global CDN with edge computing'}
{/* Footer */} -
+
diff --git a/app/styles/components/dialog.scss b/app/styles/components/dialog.scss new file mode 100644 index 00000000..7b69e076 --- /dev/null +++ b/app/styles/components/dialog.scss @@ -0,0 +1,45 @@ +@use '../variables.scss'; + +:root { + /* Secondary color scale for dialogs and popovers */ + --secondary-1: hsl(var(--secondary)); + --secondary-2: hsl(var(--secondary) / 0.8); + --secondary-3: hsl(var(--secondary) / 0.6); + --secondary-4: hsl(var(--secondary) / 0.4); + --secondary-5: hsl(var(--secondary) / 0.3); + --secondary-6: hsl(var(--secondary) / 0.2); + --secondary-7: hsl(var(--secondary) / 0.15); + --secondary-8: hsl(var(--secondary) / 0.1); + --secondary-9: hsl(var(--secondary) / 0.05); + --secondary-10: hsl(var(--secondary) / 0.03); + --secondary-11: hsl(var(--secondary-foreground) / 0.8); + --secondary-12: hsl(var(--secondary-foreground)); + + /* Secondary alpha variants */ + --secondary-a1: hsl(var(--secondary) / 0.05); + --secondary-a2: hsl(var(--secondary) / 0.1); + --secondary-a3: hsl(var(--secondary) / 0.15); + --secondary-a4: hsl(var(--secondary) / 0.2); + --secondary-a5: hsl(var(--secondary) / 0.3); + --secondary-a6: hsl(var(--secondary) / 0.4); + --secondary-a7: hsl(var(--secondary) / 0.5); + --secondary-a8: hsl(var(--secondary) / 0.6); + --secondary-a9: hsl(var(--secondary) / 0.7); + --secondary-a10: hsl(var(--secondary) / 0.8); + --secondary-a11: hsl(var(--secondary) / 0.9); + --secondary-a12: hsl(var(--secondary) / 0.95); +} + +/* Dialog specific styles */ +.dialog-overlay { + background-color: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); +} + +.dialog-content { + background-color: var(--secondary-1); + border: 1px solid var(--secondary-a4); + box-shadow: + 0 20px 25px -5px rgba(0, 0, 0, 0.1), + 0 10px 10px -5px rgba(0, 0, 0, 0.04); +} diff --git a/app/styles/index.scss b/app/styles/index.scss index abb18316..0e958a57 100644 --- a/app/styles/index.scss +++ b/app/styles/index.scss @@ -6,6 +6,7 @@ @use 'components/code.scss'; @use 'components/editor.scss'; @use 'components/toast.scss'; +@use 'components/dialog.scss'; @use '../../app/components/chat/BaseChat.module.scss'; @use '../../app/components/chat/CodeBlock.module.scss'; @use '../../app/components/chat/Markdown.module.scss'; diff --git a/app/types/design-scheme.ts b/app/types/design-scheme.ts index 2f9587cd..28f6c07f 100644 --- a/app/types/design-scheme.ts +++ b/app/types/design-scheme.ts @@ -1,25 +1,52 @@ export interface DesignScheme { - palette: { [key: string]: string }; // Changed from string[] to object + palette: { + light: { [key: string]: string }; + dark: { [key: string]: string }; + }; features: string[]; font: string[]; + mode: 'light' | 'dark'; + borderRadius: string; + shadow: string; + spacing: string; + theme?: string; } export const defaultDesignScheme: DesignScheme = { palette: { - primary: '#9E7FFF', - secondary: '#38bdf8', - accent: '#f472b6', - background: '#171717', - surface: '#262626', - text: '#FFFFFF', - textSecondary: '#A3A3A3', - border: '#2F2F2F', - success: '#10b981', - warning: '#f59e0b', - error: '#ef4444', + light: { + primary: '#7c3aed', + secondary: '#06b6d4', + accent: '#ec4899', + background: '#ffffff', + surface: '#f8fafc', + text: '#0f172a', + textSecondary: '#64748b', + border: '#e2e8f0', + success: '#10b981', + warning: '#f59e0b', + error: '#ef4444', + }, + dark: { + primary: '#9E7FFF', + secondary: '#38bdf8', + accent: '#f472b6', + background: '#171717', + surface: '#262626', + text: '#FFFFFF', + textSecondary: '#A3A3A3', + border: '#2F2F2F', + success: '#10b981', + warning: '#f59e0b', + error: '#ef4444', + }, }, features: ['rounded'], - font: ['sans-serif'], + font: ['Inter'], + mode: 'dark', + borderRadius: 'md', + shadow: 'sm', + spacing: 'normal', }; export const paletteRoles = [ @@ -85,9 +112,76 @@ export const designFeatures = [ ]; export const designFonts = [ - { key: 'sans-serif', label: 'Sans Serif', preview: 'Aa' }, - { key: 'serif', label: 'Serif', preview: 'Aa' }, - { key: 'monospace', label: 'Monospace', preview: 'Aa' }, - { key: 'cursive', label: 'Cursive', preview: 'Aa' }, - { key: 'fantasy', label: 'Fantasy', preview: 'Aa' }, + { key: 'Inter', label: 'Inter', preview: 'Aa' }, + { key: 'Roboto', label: 'Roboto', preview: 'Aa' }, + { key: 'Open Sans', label: 'Open Sans', preview: 'Aa' }, + { key: 'Montserrat', label: 'Montserrat', preview: 'Aa' }, + { key: 'Poppins', label: 'Poppins', preview: 'Aa' }, + { key: 'Lato', label: 'Lato', preview: 'Aa' }, + { key: 'JetBrains Mono', label: 'JetBrains Mono', preview: 'Aa' }, + { key: 'Raleway', label: 'Raleway', preview: 'Aa' }, + { key: 'Lora', label: 'Lora', preview: 'Aa' }, +]; + +export const borderRadiusOptions = [ + { key: 'none', label: 'None' }, + { key: 'sm', label: 'Small' }, + { key: 'md', label: 'Medium' }, + { key: 'lg', label: 'Large' }, + { key: 'xl', label: 'Extra Large' }, + { key: 'full', label: 'Full' }, +]; + +export const shadowOptions = [ + { key: 'none', label: 'None' }, + { key: 'sm', label: 'Small' }, + { key: 'md', label: 'Medium' }, + { key: 'lg', label: 'Large' }, + { key: 'xl', label: 'Extra Large' }, +]; + +export const spacingOptions = [ + { key: 'tight', label: 'Tight' }, + { key: 'normal', label: 'Normal' }, + { key: 'relaxed', label: 'Relaxed' }, + { key: 'loose', label: 'Loose' }, +]; + +export const presetThemes = [ + { key: 'minimal', label: 'Minimal', image: '/style_presets/minimal.webp' }, + { key: 'modern', label: 'Modern', image: '/style_presets/modern.webp' }, + { key: 'carbon', label: 'Carbon', image: '/style_presets/carbon.webp' }, + { key: 'material', label: 'Material', image: '/style_presets/material.webp' }, + { key: 'flat', label: 'Flat', image: '/style_presets/flat.webp' }, + { key: 'neobrutalism', label: 'Neobrutalism', image: '/style_presets/neobrutalism.webp' }, + { key: 'glassmorphism', label: 'Glassmorphism', image: '/style_presets/glassmorphism.webp' }, + { key: 'claymorphism', label: 'Claymorphism', image: '/style_presets/claymorphism.webp' }, + { key: 'retro', label: 'Retro', image: '/style_presets/retro.webp' }, + { key: 'neumorphism', label: 'Neumorphism', image: '/style_presets/neumorphism.webp' }, + { key: 'cyberpunk', label: 'Cyberpunk', image: '/style_presets/cyberpunk.webp' }, +]; + +export const predefinedColors = [ + { key: 'red', label: 'Red', value: '#ef4444' }, + { key: 'orange', label: 'Orange', value: '#f97316' }, + { key: 'amber', label: 'Amber', value: '#f59e0b' }, + { key: 'yellow', label: 'Yellow', value: '#eab308' }, + { key: 'lime', label: 'Lime', value: '#84cc16' }, + { key: 'green', label: 'Green', value: '#22c55e' }, + { key: 'emerald', label: 'Emerald', value: '#10b981' }, + { key: 'teal', label: 'Teal', value: '#14b8a6' }, + { key: 'cyan', label: 'Cyan', value: '#06b6d4' }, + { key: 'sky', label: 'Sky', value: '#0ea5e9' }, + { key: 'blue', label: 'Blue', value: '#3b82f6' }, + { key: 'indigo', label: 'Indigo', value: '#6366f1' }, + { key: 'violet', label: 'Violet', value: '#8b5cf6' }, + { key: 'purple', label: 'Purple', value: '#a855f7' }, + { key: 'fuchsia', label: 'Fuchsia', value: '#d946ef' }, + { key: 'pink', label: 'Pink', value: '#ec4899' }, + { key: 'rose', label: 'Rose', value: '#f43f5e' }, + { key: 'slate', label: 'Slate', value: '#64748b' }, + { key: 'gray', label: 'Gray', value: '#6b7280' }, + { key: 'zinc', label: 'Zinc', value: '#71717a' }, + { key: 'neutral', label: 'Neutral', value: '#737373' }, + { key: 'stone', label: 'Stone', value: '#78716c' }, ]; From 09755cdc3c0d2c006a503beffbe7ee688919ba2c Mon Sep 17 00:00:00 2001 From: Gerome-Elassaad Date: Wed, 19 Nov 2025 06:56:29 +1100 Subject: [PATCH 17/21] Fix API route to handle 429 rate limiting status code Handle both 403 and 429 HTTP status codes as rate limiting in the update API route --- app/routes/api.update.ts | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/app/routes/api.update.ts b/app/routes/api.update.ts index a15f6566..66c7c503 100644 --- a/app/routes/api.update.ts +++ b/app/routes/api.update.ts @@ -43,6 +43,23 @@ export const action: ActionFunction = async ({ request }) => { }); } + // Handle rate limiting + if (response.status === 403 || response.status === 429) { + const resetTime = response.headers.get('X-RateLimit-Reset'); + const resetDate = resetTime ? new Date(parseInt(resetTime) * 1000) : null; + console.log('🚫 Rate limited by GitHub API'); + + return json( + { + updateAvailable: false, + currentVersion: CURRENT_VERSION, + error: 'Rate limited', + message: `GitHub API rate limit exceeded. ${resetDate ? `Resets at ${resetDate.toLocaleTimeString()}` : 'Please try again later.'}`, + }, + { status: 429 }, + ); + } + throw new Error(`GitHub API returned ${response.status}`); } @@ -64,13 +81,19 @@ export const action: ActionFunction = async ({ request }) => { }); } catch (error) { console.error('💥 Error checking for updates:', error); + + // Determine error type for better user experience + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + const isRateLimit = errorMessage.includes('403') || errorMessage.includes('rate limit'); + const isNetworkError = errorMessage.includes('fetch') || errorMessage.includes('network'); + return json( { - error: 'Failed to check for updates', + error: isRateLimit ? 'Rate limited' : isNetworkError ? 'Network error' : 'Failed to check for updates', currentVersion: CURRENT_VERSION, - message: error instanceof Error ? error.message : 'Unknown error occurred', + message: errorMessage, }, - { status: 500 }, + { status: isRateLimit ? 429 : 500 }, ); } }; From e8f6c81b28ee8dc4c578db817a3f87f4bb5d13ed Mon Sep 17 00:00:00 2001 From: Gerome-Elassaad Date: Wed, 19 Nov 2025 06:56:47 +1100 Subject: [PATCH 18/21] Fix linting error in useUpdateCheck hook Add missing blank line before error handling code to satisfy ESLint rules --- app/lib/hooks/useUpdateCheck.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/app/lib/hooks/useUpdateCheck.ts b/app/lib/hooks/useUpdateCheck.ts index 72c3df28..a4c45e44 100644 --- a/app/lib/hooks/useUpdateCheck.ts +++ b/app/lib/hooks/useUpdateCheck.ts @@ -70,7 +70,22 @@ export const useUpdateCheck = () => { } } catch (error) { console.error('💥 Failed to check for updates:', error); - setError('Failed to check for updates'); + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + + // Provide more user-friendly error messages + if (errorMessage.includes('500')) { + setError('Update service temporarily unavailable. Please try again later.'); + } else if (errorMessage.includes('rate limit') || errorMessage.includes('Rate limited')) { + setError( + 'GitHub API rate limit exceeded. Update checks are limited to 60 per hour for unauthenticated requests.', + ); + } else if (errorMessage.includes('network') || errorMessage.includes('fetch')) { + setError('Network error while checking for updates. Please check your internet connection.'); + } else { + setError(`Failed to check for updates: ${errorMessage}`); + } + setHasUpdate(false); } finally { setIsLoading(false); From a5f6caec3b75f9668fc11d87e6e8ae0d949aeb8a Mon Sep 17 00:00:00 2001 From: Gerome-Elassaad Date: Wed, 19 Nov 2025 06:57:03 +1100 Subject: [PATCH 19/21] Fix client-side update check to handle 429 rate limiting - Parse API response JSON before checking HTTP status codes - Handle 429 status as rate limiting in fallback GitHub API calls - Update error type detection to recognize 429 as rate limiting --- app/lib/api/updates.ts | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/app/lib/api/updates.ts b/app/lib/api/updates.ts index 89e2bb1d..6ce723b9 100644 --- a/app/lib/api/updates.ts +++ b/app/lib/api/updates.ts @@ -63,20 +63,47 @@ export const checkForUpdates = async (): Promise => { }, }); + const apiData = (await apiResponse.json()) as ApiUpdateResponse; + + // Check for HTTP errors after parsing JSON if (!apiResponse.ok) { - throw new Error(`API request failed: ${apiResponse.status}`); + // For rate limiting (429), we still have valid JSON data to process + if (apiResponse.status !== 429) { + throw new Error(`API request failed: ${apiResponse.status}`); + } } - const apiData = (await apiResponse.json()) as ApiUpdateResponse; + // If the API route successfully got update info, use it + if (!apiData.error && apiData.updateAvailable !== undefined) { + return { + available: apiData.updateAvailable, + version: apiData.latestVersion || apiData.currentVersion, + currentVersion: apiData.currentVersion, + releaseNotes: apiData.releaseNotes, + releaseUrl: apiData.releaseUrl, + publishedAt: apiData.publishedAt, + }; + } + // If API returned an error, handle it if (apiData.error) { - throw new Error(apiData.message || 'API returned an error'); + const errorType = apiData.error === 'Rate limited' ? 'rate_limit' : 'network'; + return { + available: false, + version: apiData.currentVersion, + currentVersion: apiData.currentVersion, + error: { + type: errorType as 'rate_limit' | 'network', + message: apiData.message || 'Failed to check for updates', + }, + }; } + // Fallback: API didn't provide update info, try direct GitHub call const currentVersion = apiData.currentVersion; // Fetch the latest release from GitHub - const response = await fetch(`https://api.github.com/repos/gerome-elassaad/codinit-app/releases/latest`, { + const response = await fetch(`https://api.github.com/repos/Gerome-Elassaad/codinit-app/releases/latest`, { headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'CodinIT-App', @@ -94,7 +121,7 @@ export const checkForUpdates = async (): Promise => { } // Check for rate limiting - if (response.status === 403) { + if (response.status === 403 || response.status === 429) { const resetTime = response.headers.get('X-RateLimit-Reset'); return { available: false, @@ -138,7 +165,7 @@ export const checkForUpdates = async (): Promise => { if (isNetworkError) { errorType = 'network'; - } else if (errorMessage.toLowerCase().includes('rate limit')) { + } else if (errorMessage.toLowerCase().includes('rate limit') || errorMessage.toLowerCase().includes('429')) { errorType = 'rate_limit'; } else if (errorMessage.toLowerCase().includes('auth') || errorMessage.toLowerCase().includes('403')) { errorType = 'auth'; From 60bcbf997606c6494580bfbf082c88a4e0156eb9 Mon Sep 17 00:00:00 2001 From: Gerome-Elassaad Date: Wed, 19 Nov 2025 06:57:44 +1100 Subject: [PATCH 20/21] chore: release version 1.0.9 --- changelog.md | 19 +++++++++++++++++++ package.json | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 changelog.md diff --git a/changelog.md b/changelog.md new file mode 100644 index 00000000..47c5a7e8 --- /dev/null +++ b/changelog.md @@ -0,0 +1,19 @@ +# Changelog + +## [1.0.9] - 2025-01-18 + +### Bug Fixes + +- **Update API**: Fixed handling of 429 HTTP status codes for rate limiting in the update check API route +- **Client-side Updates**: Improved client-side update checking to properly parse API responses and handle rate limiting errors +- **Code Quality**: Fixed ESLint linting error in the useUpdateCheck hook + +### Technical Details + +- Modified `app/routes/api.update.ts` to handle both 403 and 429 status codes as rate limiting +- Updated `app/lib/api/updates.ts` to parse JSON responses before checking HTTP status and recognize 429 as rate limiting +- Fixed formatting issue in `app/lib/hooks/useUpdateCheck.ts` to comply with ESLint rules + +### Impact + +These changes resolve the "API request failed: 429" error that users were experiencing when GitHub's API rate limiting returned a 429 status code instead of the expected 403. \ No newline at end of file diff --git a/package.json b/package.json index 4dbed236..f0d0ca61 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "license": "MIT", "sideEffects": false, "type": "module", - "version": "1.0.8", + "version": "1.0.9", "author": { "name": "Gerome-Elassaad", "email": "gerome.e24@gmail.com" From dbc7dae3361b0d7959086233c95370883268ec74 Mon Sep 17 00:00:00 2001 From: Gerome-Elassaad Date: Wed, 19 Nov 2025 07:01:53 +1100 Subject: [PATCH 21/21] docs: update changelog with all changes since v1.0.8 --- .../ControlPanelDialog/ControlPanelDialog.tsx | 71 +++++++++ .../components/ControlPanelContent.tsx | 143 ++++++++++++++++++ .../components/ControlPanelSidebar.tsx | 74 +++++++++ .../hooks/useControlPanelDialog.ts | 46 ++++++ .../core/ControlPanelDialog/index.ts | 1 + app/components/@settings/index.ts | 1 + .../@settings/tabs/update/UpdateTab.tsx | 28 +++- app/components/sidebar/Menu.client.tsx | 4 +- app/routes/api.update.ts | 2 +- changelog.md | 18 ++- 10 files changed, 379 insertions(+), 9 deletions(-) create mode 100644 app/components/@settings/core/ControlPanelDialog/ControlPanelDialog.tsx create mode 100644 app/components/@settings/core/ControlPanelDialog/components/ControlPanelContent.tsx create mode 100644 app/components/@settings/core/ControlPanelDialog/components/ControlPanelSidebar.tsx create mode 100644 app/components/@settings/core/ControlPanelDialog/hooks/useControlPanelDialog.ts create mode 100644 app/components/@settings/core/ControlPanelDialog/index.ts diff --git a/app/components/@settings/core/ControlPanelDialog/ControlPanelDialog.tsx b/app/components/@settings/core/ControlPanelDialog/ControlPanelDialog.tsx new file mode 100644 index 00000000..69124c99 --- /dev/null +++ b/app/components/@settings/core/ControlPanelDialog/ControlPanelDialog.tsx @@ -0,0 +1,71 @@ +import { motion } from 'framer-motion'; +import * as RadixDialog from '@radix-ui/react-dialog'; +import { classNames } from '~/utils/classNames'; +import type { TabType } from '~/components/@settings/core/types'; +import { ControlPanelSidebar } from './components/ControlPanelSidebar'; +import { ControlPanelContent } from './components/ControlPanelContent'; +import { useControlPanelDialog } from './hooks/useControlPanelDialog'; + +interface ControlPanelDialogProps { + isOpen: boolean; + onClose: () => void; + initialTab?: TabType; +} + +export function ControlPanelDialog({ isOpen, onClose, initialTab = 'settings' }: ControlPanelDialogProps) { + const { activeTab, setActiveTab, visibleTabs } = useControlPanelDialog(initialTab); + + return ( + + + + + + +
+ + + {/* Close button */} + + + + + {/* Sidebar */} + + + {/* Main Content */} + + + +
+
+
+ ); +} diff --git a/app/components/@settings/core/ControlPanelDialog/components/ControlPanelContent.tsx b/app/components/@settings/core/ControlPanelDialog/components/ControlPanelContent.tsx new file mode 100644 index 00000000..9c54c108 --- /dev/null +++ b/app/components/@settings/core/ControlPanelDialog/components/ControlPanelContent.tsx @@ -0,0 +1,143 @@ +import { Suspense, lazy } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { classNames } from '~/utils/classNames'; +import { TAB_LABELS } from '~/components/@settings/core/constants'; +import type { TabType } from '~/components/@settings/core/types'; + +// Lazy load all tab components +const ProfileTab = lazy(() => + import('~/components/@settings/tabs/profile/ProfileTab').then((module) => ({ default: module.default })), +); +const SettingsTab = lazy(() => + import('~/components/@settings/tabs/settings/SettingsTab').then((module) => ({ default: module.default })), +); +const NotificationsTab = lazy(() => + import('~/components/@settings/tabs/notifications/NotificationsTab').then((module) => ({ default: module.default })), +); +const FeaturesTab = lazy(() => + import('~/components/@settings/tabs/features/FeaturesTab').then((module) => ({ default: module.default })), +); +const DataTab = lazy(() => + import('~/components/@settings/tabs/data/DataTab').then((module) => ({ default: module.DataTab })), +); +const CloudProvidersTab = lazy(() => + import('~/components/@settings/tabs/providers/cloud/CloudProvidersTab').then((module) => ({ + default: module.default, + })), +); +const LocalProvidersTab = lazy(() => + import('~/components/@settings/tabs/providers/local/LocalProvidersTab').then((module) => ({ + default: module.default, + })), +); +const ServiceStatusTab = lazy(() => + import('~/components/@settings/tabs/providers/status/ServiceStatusTab').then((module) => ({ + default: module.default, + })), +); +const ConnectionsTab = lazy(() => + import('~/components/@settings/tabs/connections/ConnectionsTab').then((module) => ({ default: module.default })), +); +const DebugTab = lazy(() => + import('~/components/@settings/tabs/debug/DebugTab').then((module) => ({ default: module.default })), +); +const EventLogsTab = lazy(() => + import('~/components/@settings/tabs/event-logs/EventLogsTab').then((module) => ({ default: module.EventLogsTab })), +); +const UpdateTab = lazy(() => + import('~/components/@settings/tabs/update/UpdateTab').then((module) => ({ default: module.default })), +); +const TaskManagerTab = lazy(() => + import('~/components/@settings/tabs/task-manager/TaskManagerTab').then((module) => ({ default: module.default })), +); + +interface ControlPanelContentProps { + activeTab: TabType; +} + +function LoadingFallback() { + return ( +
+
+
+ Loading... +
+
+ ); +} + +function TabContent({ tab }: { tab: TabType }) { + switch (tab) { + case 'profile': + return ; + case 'settings': + return ; + case 'notifications': + return ; + case 'features': + return ; + case 'data': + return ; + case 'cloud-providers': + return ; + case 'local-providers': + return ; + case 'service-status': + return ; + case 'connection': + return ; + case 'debug': + return ; + case 'event-logs': + return ; + case 'update': + return ; + case 'task-manager': + return ; + default: + return ( +
+
+
+

Tab not found

+
+
+ ); + } +} + +export function ControlPanelContent({ activeTab }: ControlPanelContentProps) { + return ( +
+ {/* Header */} +
+
+

{TAB_LABELS[activeTab]}

+
+
+ + {/* Content */} +
+ + + }> + + + + +
+
+ ); +} diff --git a/app/components/@settings/core/ControlPanelDialog/components/ControlPanelSidebar.tsx b/app/components/@settings/core/ControlPanelDialog/components/ControlPanelSidebar.tsx new file mode 100644 index 00000000..0baa74d1 --- /dev/null +++ b/app/components/@settings/core/ControlPanelDialog/components/ControlPanelSidebar.tsx @@ -0,0 +1,74 @@ +import { classNames } from '~/utils/classNames'; +import { TAB_ICONS, TAB_LABELS } from '~/components/@settings/core/constants'; +import type { TabType, TabVisibilityConfig } from '~/components/@settings/core/types'; + +interface ControlPanelSidebarProps { + activeTab: TabType; + onTabChange: (tab: TabType) => void; + tabs: TabVisibilityConfig[]; +} + +export function ControlPanelSidebar({ activeTab, onTabChange, tabs }: ControlPanelSidebarProps) { + // Group tabs into primary and secondary sections + const primaryTabs = tabs.slice(0, 8); // First 8 tabs as primary + const secondaryTabs = tabs.slice(8); // Rest as secondary + + const renderTabButton = (tab: TabVisibilityConfig) => { + const isActive = activeTab === tab.id; + const iconClass = TAB_ICONS[tab.id]; + const label = TAB_LABELS[tab.id]; + + return ( +
  • + +
  • + ); + }; + + return ( + + ); +} diff --git a/app/components/@settings/core/ControlPanelDialog/hooks/useControlPanelDialog.ts b/app/components/@settings/core/ControlPanelDialog/hooks/useControlPanelDialog.ts new file mode 100644 index 00000000..74bd7cbf --- /dev/null +++ b/app/components/@settings/core/ControlPanelDialog/hooks/useControlPanelDialog.ts @@ -0,0 +1,46 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useStore } from '@nanostores/react'; +import { tabConfigurationStore, developerModeStore } from '~/lib/stores/settings'; +import type { TabType, TabVisibilityConfig } from '~/components/@settings/core/types'; + +export function useControlPanelDialog(initialTab: TabType = 'settings') { + const [activeTab, setActiveTab] = useState(initialTab); + const tabConfiguration = useStore(tabConfigurationStore); + const isDeveloperMode = useStore(developerModeStore); + + // Get visible tabs based on current configuration + const visibleTabs = useMemo(() => { + const currentWindow = isDeveloperMode ? 'developer' : 'user'; + const tabsArray = currentWindow === 'developer' ? tabConfiguration.developerTabs : tabConfiguration.userTabs; + + return tabsArray + .filter((tab: TabVisibilityConfig) => tab.visible) + .sort((a: TabVisibilityConfig, b: TabVisibilityConfig) => a.order - b.order); + }, [tabConfiguration, isDeveloperMode]); + + // Ensure active tab is valid when configuration changes + useEffect(() => { + if (!visibleTabs.find((tab: TabVisibilityConfig) => tab.id === activeTab)) { + const firstVisibleTab = visibleTabs[0]; + + if (firstVisibleTab) { + setActiveTab(firstVisibleTab.id); + } + } + }, [visibleTabs, activeTab]); + + // Reset to initial tab when dialog opens + + useEffect(() => { + if (visibleTabs.find((tab: TabVisibilityConfig) => tab.id === initialTab)) { + setActiveTab(initialTab); + } + }, [initialTab, visibleTabs]); + + return { + activeTab, + setActiveTab, + visibleTabs, + isDeveloperMode, + }; +} diff --git a/app/components/@settings/core/ControlPanelDialog/index.ts b/app/components/@settings/core/ControlPanelDialog/index.ts new file mode 100644 index 00000000..77f1a56a --- /dev/null +++ b/app/components/@settings/core/ControlPanelDialog/index.ts @@ -0,0 +1 @@ +export { ControlPanelDialog } from './ControlPanelDialog'; diff --git a/app/components/@settings/index.ts b/app/components/@settings/index.ts index 862c33ef..3b98f1ce 100644 --- a/app/components/@settings/index.ts +++ b/app/components/@settings/index.ts @@ -1,5 +1,6 @@ // Core exports export { ControlPanel } from './core/ControlPanel'; +export { ControlPanelDialog } from './core/ControlPanelDialog'; export type { TabType, TabVisibilityConfig } from './core/types'; // Constants diff --git a/app/components/@settings/tabs/update/UpdateTab.tsx b/app/components/@settings/tabs/update/UpdateTab.tsx index 58965215..1185feac 100644 --- a/app/components/@settings/tabs/update/UpdateTab.tsx +++ b/app/components/@settings/tabs/update/UpdateTab.tsx @@ -170,10 +170,7 @@ const UpdateTab = () => { )}