diff --git a/AGENTS.md b/AGENTS.md index b6086d4d..eed57f9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,18 +1,18 @@ # Build/Lint/Test Commands ## Build Commands -- `npm run build` - Production build with Remix Vite -- `npm run dev` - Development server with hot reload -- `npm run typecheck` - TypeScript type checking +- `pnpm run build` - Production build with Remix Vite +- `pnpm run dev` - Development server with hot reload +- `pnpm run typecheck` - TypeScript type checking ## Test Commands -- `npm run test` - Run all tests once with Vitest -- `npm run test:watch` - Run tests in watch mode +- `pnpm run test` - Run all tests once with Vitest +- `pnpm run test:watch` - Run tests in watch mode - `vitest --run path/to/test.spec.ts` - Run single test file ## Lint Commands -- `npm run lint` - ESLint check with caching -- `npm run lint:fix` - Auto-fix ESLint + Prettier formatting +- `pnpm run lint` - ESLint check with caching +- `pnpm run lint:fix` - Auto-fix ESLint + Prettier formatting # Code Style Guidelines diff --git a/app/components/@settings/core/ControlPanel.tsx b/app/components/@settings/core/ControlPanel.tsx index 4d7f4011..bcdc9325 100644 --- a/app/components/@settings/core/ControlPanel.tsx +++ b/app/components/@settings/core/ControlPanel.tsx @@ -442,11 +442,13 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { + + Application settings and configuration panel + 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/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/@settings/tabs/connections/components/CreateBranchDialog.tsx b/app/components/@settings/tabs/connections/components/CreateBranchDialog.tsx index c423685f..71be9e1f 100644 --- a/app/components/@settings/tabs/connections/components/CreateBranchDialog.tsx +++ b/app/components/@settings/tabs/connections/components/CreateBranchDialog.tsx @@ -43,6 +43,9 @@ export function CreateBranchDialog({ isOpen, onClose, onConfirm, repository, bra Create New Branch + + Create a new branch from the current commit +
    diff --git a/app/components/@settings/tabs/connections/components/GitHubAuthDialog.tsx b/app/components/@settings/tabs/connections/components/GitHubAuthDialog.tsx index b48bbf1b..1bec738c 100644 --- a/app/components/@settings/tabs/connections/components/GitHubAuthDialog.tsx +++ b/app/components/@settings/tabs/connections/components/GitHubAuthDialog.tsx @@ -85,12 +85,14 @@ export function GitHubAuthDialog({ isOpen, onClose }: GitHubAuthDialogProps) { >
    -

    Access Private Repositories

    + + Access Private Repositories + -

    + To access private repositories, you need to connect your GitHub account by providing a personal access token. -

    +

    Connect with GitHub Token

    diff --git a/app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx b/app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx index dd0b375c..03cf41dc 100644 --- a/app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx +++ b/app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx @@ -544,9 +544,12 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit > - + {/* Header */} -
    +
    @@ -555,9 +558,9 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit Import GitHub Repository -

    + Clone a repository from GitHub to your workspace -

    +
    {/* Auth Info Banner */} -
    +
    @@ -606,15 +609,15 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
    {/* Tabs */}
    -
    +
    -
    -
    +
    +
    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/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 && }
    {
    - + ); }; 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')} />
    -
    - -
    @@ -107,9 +146,18 @@ export function DeployDialog({ isOpen, onClose }: DeployDialogProps) { >
    {deployingTo === 'vercel' ? ( -
    +
    ) : ( - Vercel + + + )}
    @@ -117,25 +165,61 @@ 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'}
    - {/* Cloudflare - Coming Soon */} -
    + {/* Cloudflare */} +
    {/* Footer */} -
    +
    - - -
    -
    - + + -
    - -
    - -
    + +
    + + Reload Preview +
    +
    + setIsDeployDialogOpen(true)} + > +
    + + Deploy Options +
    +
    + setIsPushDialogOpen(true)} + > +
    + + Push to GitHub +
    +
    + +
    {/* Address Bar */} @@ -165,26 +208,119 @@ export const PreviewHeader = memo(
    + {/* Deployment Buttons */}
    + {/* Vercel Deploy Button */} + + {isDeployingVercel ? ( +
    + ) : ( + + + + )} + + + {/* Netlify Deploy Button */} + title={netlifyConn.user ? 'Deploy to Netlify' : 'Connect Netlify account first'} + disabled={!netlifyConn.user || isDeployingNetlify} + onClick={handleDeployToNetlify} + className="w-8 h-8" + > + {isDeployingNetlify ? ( +
    + ) : ( + + + + + + + + + + )} + + + {/* Cloudflare Deploy Button */} + + {isDeployingCloudflare ? ( +
    + ) : ( + + )} +
    + + {/* Deploy Dialog Button */} +
    diff --git a/app/components/workbench/Workbench.client.tsx b/app/components/workbench/Workbench.client.tsx index 8db6795b..05d5dd18 100644 --- a/app/components/workbench/Workbench.client.tsx +++ b/app/components/workbench/Workbench.client.tsx @@ -321,7 +321,6 @@ export const Workbench = memo( const [showDeviceFrame, setShowDeviceFrame] = useState(true); // Terminal state - const showTerminal = useStore(workbenchStore.showTerminal); // Preview-related functions const previews = useStore(workbenchStore.previews); @@ -611,9 +610,6 @@ export const Workbench = memo(
    {selectedView === 'code' && ( { - workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get()); - }} onDownloadZip={() => { workbenchStore.downloadZip(); }} @@ -621,7 +617,6 @@ export const Workbench = memo( onPushToGitHub={() => setIsPushDialogOpen(true)} isSyncing={isSyncing} setIsPushDialogOpen={setIsPushDialogOpen} - showTerminal={showTerminal} /> )} 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'; 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); diff --git a/app/lib/runtime/action-runner.ts b/app/lib/runtime/action-runner.ts index 8486c830..ea9cc871 100644 --- a/app/lib/runtime/action-runner.ts +++ b/app/lib/runtime/action-runner.ts @@ -509,7 +509,7 @@ export class ActionRunner { details?: { url?: string; error?: string; - source?: 'netlify' | 'vercel' | 'github'; + source?: 'netlify' | 'vercel' | 'github' | 'cloudflare'; }, ): void { if (!this.onDeployAlert) { diff --git a/app/lib/stores/cloudflare.ts b/app/lib/stores/cloudflare.ts new file mode 100644 index 00000000..e6896641 --- /dev/null +++ b/app/lib/stores/cloudflare.ts @@ -0,0 +1,114 @@ +import { atom } from 'nanostores'; +import type { CloudflareConnection } from '~/types/cloudflare'; +import { logStore } from './logs'; +import { toast } from 'react-toastify'; + +// Initialize with stored connection or defaults +const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('cloudflare_connection') : null; +const initialConnection: CloudflareConnection = storedConnection + ? JSON.parse(storedConnection) + : { + user: null, + token: '', + accountId: '', + stats: undefined, + }; + +export const cloudflareConnection = atom(initialConnection); +export const isConnecting = atom(false); +export const isFetchingStats = atom(false); + +export const updateCloudflareConnection = (updates: Partial) => { + const currentState = cloudflareConnection.get(); + const newState = { ...currentState, ...updates }; + cloudflareConnection.set(newState); + + // Persist to localStorage + if (typeof window !== 'undefined') { + localStorage.setItem('cloudflare_connection', JSON.stringify(newState)); + } +}; + +export async function fetchCloudflareStats(token: string, accountId: string) { + try { + isFetchingStats.set(true); + + // Fetch user account info + const userResponse = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!userResponse.ok) { + throw new Error(`Failed to fetch account: ${userResponse.status}`); + } + + const userData = (await userResponse.json()) as any; + + // Fetch Pages projects + const projectsResponse = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/pages/projects`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!projectsResponse.ok) { + throw new Error(`Failed to fetch projects: ${projectsResponse.status}`); + } + + const projectsData = (await projectsResponse.json()) as any; + const projects = projectsData.result || []; + + // Get latest deployment for each project + const projectsWithDeployments = await Promise.all( + projects.map(async (project: any) => { + try { + const deploymentsResponse = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/pages/projects/${project.name}/deployments?limit=1`, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }, + ); + + if (deploymentsResponse.ok) { + const deploymentsData = (await deploymentsResponse.json()) as any; + const latestDeployment = deploymentsData.result?.[0]; + + return { + ...project, + latest_deployment: latestDeployment, + url: latestDeployment ? `https://${project.name}.pages.dev` : undefined, + }; + } + + return project; + } catch (error) { + console.error(`Error fetching deployments for project ${project.name}:`, error); + return project; + } + }), + ); + + const currentState = cloudflareConnection.get(); + updateCloudflareConnection({ + ...currentState, + user: userData.result, + stats: { + projects: projectsWithDeployments, + totalProjects: projectsWithDeployments.length, + }, + }); + } catch (error) { + console.error('Cloudflare API Error:', error); + logStore.logError('Failed to fetch Cloudflare stats', { error }); + toast.error('Failed to fetch Cloudflare statistics'); + } finally { + isFetchingStats.set(false); + } +} diff --git a/app/routes/api.cloudflare-deploy.ts b/app/routes/api.cloudflare-deploy.ts new file mode 100644 index 00000000..1efad4fe --- /dev/null +++ b/app/routes/api.cloudflare-deploy.ts @@ -0,0 +1,241 @@ +import { type ActionFunctionArgs, json } from '@remix-run/cloudflare'; +import type { CloudflareProject } from '~/types/cloudflare'; + +// Add loader function to handle GET requests for project info +export async function loader({ request }: ActionFunctionArgs) { + const url = new URL(request.url); + const projectId = url.searchParams.get('projectId'); + const token = url.searchParams.get('token'); + const accountId = url.searchParams.get('accountId'); + + if (!projectId || !token || !accountId) { + return json({ error: 'Missing projectId, token, or accountId' }, { status: 400 }); + } + + try { + // Get project info + const projectResponse = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/pages/projects/${projectId}`, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }, + ); + + if (!projectResponse.ok) { + return json({ error: 'Failed to fetch project' }, { status: 400 }); + } + + const projectData = (await projectResponse.json()) as any; + + // Get latest deployment + const deploymentsResponse = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/pages/projects/${projectId}/deployments?limit=1`, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }, + ); + + const deploymentsData = deploymentsResponse.ok ? ((await deploymentsResponse.json()) as any) : null; + const latestDeployment = deploymentsData?.result?.[0]; + + return json({ + project: { + id: projectData.result.id, + name: projectData.result.name, + url: `https://${projectData.result.name}.pages.dev`, + }, + deploy: latestDeployment + ? { + id: latestDeployment.id, + url: latestDeployment.url, + environment: latestDeployment.environment, + created_on: latestDeployment.created_on, + } + : null, + }); + } catch (error) { + console.error('Error fetching Cloudflare deployment:', error); + return json({ error: 'Failed to fetch deployment' }, { status: 500 }); + } +} + +interface DeployRequestBody { + projectId?: string; + files: Record; + chatId: string; +} + +// Existing action function for POST requests +export async function action({ request }: ActionFunctionArgs) { + try { + const { projectId, files, token, accountId, chatId } = (await request.json()) as DeployRequestBody & { + token: string; + accountId: string; + }; + + if (!token || !accountId) { + return json({ error: 'Not connected to Cloudflare' }, { status: 401 }); + } + + let targetProjectId = projectId; + let projectInfo: CloudflareProject | undefined; + + // If no projectId provided, create a new project + if (!targetProjectId) { + const projectName = `codinit-dev-${chatId}-${Date.now()}`; + const createProjectResponse = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/pages/projects`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: projectName, + production_branch: 'main', + }), + }, + ); + + if (!createProjectResponse.ok) { + const errorData = (await createProjectResponse.json()) as any; + return json( + { + error: `Failed to create project: ${errorData.errors?.[0]?.message || 'Unknown error'}`, + }, + { status: 400 }, + ); + } + + const newProject = (await createProjectResponse.json()) as any; + targetProjectId = newProject.result.name; + projectInfo = { + id: newProject.result.id, + name: newProject.result.name, + url: `https://${newProject.result.name}.pages.dev`, + chatId, + created_on: newProject.result.created_on, + modified_on: newProject.result.modified_on, + }; + } else { + // Get existing project info + const projectResponse = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/pages/projects/${targetProjectId}`, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }, + ); + + if (projectResponse.ok) { + const existingProject = (await projectResponse.json()) as any; + projectInfo = { + id: existingProject.result.id, + name: existingProject.result.name, + url: `https://${existingProject.result.name}.pages.dev`, + chatId, + created_on: existingProject.result.created_on, + modified_on: existingProject.result.modified_on, + }; + } else { + // If project doesn't exist, create a new one + const projectName = `codinit-dev-${chatId}-${Date.now()}`; + const createProjectResponse = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/pages/projects`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: projectName, + production_branch: 'main', + }), + }, + ); + + if (!createProjectResponse.ok) { + const errorData = (await createProjectResponse.json()) as any; + return json( + { + error: `Failed to create project: ${errorData.errors?.[0]?.message || 'Unknown error'}`, + }, + { status: 400 }, + ); + } + + const newProject = (await createProjectResponse.json()) as any; + targetProjectId = newProject.result.name; + projectInfo = { + id: newProject.result.id, + name: newProject.result.name, + url: `https://${newProject.result.name}.pages.dev`, + chatId, + created_on: newProject.result.created_on, + modified_on: newProject.result.modified_on, + }; + } + } + + // Create deployment with files + const formData = new FormData(); + + // Add files to form data + for (const [filePath, content] of Object.entries(files)) { + // Skip empty files and directories + if (!content || content.trim() === '') { + continue; + } + + // Create file blob + const blob = new Blob([content], { type: 'text/plain' }); + formData.append('file', blob, filePath); + } + + const deployResponse = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/pages/projects/${targetProjectId}/deployments`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: formData, + }, + ); + + if (!deployResponse.ok) { + const errorData = (await deployResponse.json()) as any; + return json( + { + error: `Deployment failed: ${errorData.errors?.[0]?.message || 'Unknown error'}`, + }, + { status: 400 }, + ); + } + + const deployData = (await deployResponse.json()) as any; + + return json({ + project: projectInfo, + deploy: { + id: deployData.result.id, + url: deployData.result.url, + environment: deployData.result.environment, + created_on: deployData.result.created_on, + }, + }); + } catch (error) { + console.error('Cloudflare deploy error:', error); + return json({ error: error instanceof Error ? error.message : 'Deployment failed' }, { status: 500 }); + } +} diff --git a/app/routes/api.update.ts b/app/routes/api.update.ts index 3e5f9633..f03be93a 100644 --- a/app/routes/api.update.ts +++ b/app/routes/api.update.ts @@ -1,7 +1,7 @@ import { json, type ActionFunction } from '@remix-run/cloudflare'; // Current version - update this when releasing new versions -const CURRENT_VERSION = '1.0.6'; +const CURRENT_VERSION = '1.0.9'; const GITHUB_REPO = 'Gerome-Elassaad/codinit-app'; interface GitHubRelease { @@ -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 }, ); } }; 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/actions.ts b/app/types/actions.ts index 3a78b12c..0d54aa38 100644 --- a/app/types/actions.ts +++ b/app/types/actions.ts @@ -65,7 +65,7 @@ export interface DeployAlert { stage?: 'building' | 'deploying' | 'complete'; buildStatus?: 'pending' | 'running' | 'complete' | 'failed'; deployStatus?: 'pending' | 'running' | 'complete' | 'failed'; - source?: 'vercel' | 'netlify' | 'github' | 'gitlab'; + source?: 'vercel' | 'netlify' | 'github' | 'gitlab' | 'cloudflare'; } export interface LlmErrorAlertType { diff --git a/app/types/cloudflare.ts b/app/types/cloudflare.ts new file mode 100644 index 00000000..8aaacdc3 --- /dev/null +++ b/app/types/cloudflare.ts @@ -0,0 +1,24 @@ +export interface CloudflareConnection { + user: any; + token: string; + accountId: string; + stats?: { + projects: CloudflareProject[]; + totalProjects: number; + }; +} + +export interface CloudflareProject { + id: string; + name: string; + url: string; + chatId: string; + created_on: string; + modified_on: string; + latest_deployment?: { + id: string; + url: string; + environment: string; + created_on: string; + }; +} 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' }, ]; diff --git a/changelog.md b/changelog.md new file mode 100644 index 00000000..49f1d72b --- /dev/null +++ b/changelog.md @@ -0,0 +1,35 @@ +# Changelog + +## [1.0.9] - 2025-01-18 + +### New Features + +- **Cloudflare Pages Deployment**: Added Cloudflare Pages deployment integration with local SVG icons +- **Settings Dropdown**: Added settings dropdown functionality to PreviewHeader + +### UI/UX Improvements + +- **Dark Mode**: Updated colors and fixed dark mode errors +- **Preview Header**: Updated preview header with standardized button sizes and layout +- **Button Styling**: Unified button styling across components with consistent w-8 h-8 sizing +- **Icons**: Updated deployment icons to use local SVG assets and standardized icon usage +- **Code Mode Header**: Removed terminal button and updated layout + +### 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 +- **IconButton**: Fixed border centering issues in IconButton component + +### 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 +- Updated deployment icons to use local SVG assets instead of external sources +- Standardized button sizes and layouts across PreviewHeader and related components + +### 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. The UI improvements provide a more consistent and polished user experience with better dark mode support and standardized component styling. \ No newline at end of file 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 @@ + + + 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 @@ + + + diff --git a/package.json b/package.json index 35a6dce5..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" @@ -217,7 +217,7 @@ "vite-plugin-copy": "^0.1.6", "vite-plugin-optimize-css-modules": "^1.2.0", "vite-tsconfig-paths": "^4.3.2", - "vitest": "^3.1.0", + "vitest": "^3.2.4", "wrangler": "^4.42.0" }, "resolutions": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2192bf7a..205a1370 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -516,7 +516,7 @@ importers: specifier: ^4.3.2 version: 4.3.2(typescript@5.9.3)(vite@5.4.21(@types/node@22.18.12)(sass-embedded@1.93.2)(sass@1.93.2)) vitest: - specifier: ^3.1.0 + specifier: ^3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.12)(@vitest/browser@3.2.4)(jsdom@26.1.0)(sass-embedded@1.93.2)(sass@1.93.2) wrangler: specifier: ^4.42.1