Spaces:
Paused
Paused
| import React, { useEffect, useState } 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 { | |
| netlifyConnection, | |
| isConnecting, | |
| isFetchingStats, | |
| updateNetlifyConnection, | |
| fetchNetlifyStats, | |
| } from '~/lib/stores/netlify'; | |
| import type { NetlifyUser } from '~/types/netlify'; | |
| export function NetlifyConnection() { | |
| const connection = useStore(netlifyConnection); | |
| const connecting = useStore(isConnecting); | |
| const fetchingStats = useStore(isFetchingStats); | |
| const [isSitesExpanded, setIsSitesExpanded] = useState(false); | |
| useEffect(() => { | |
| const fetchSites = async () => { | |
| if (connection.user && connection.token) { | |
| await fetchNetlifyStats(connection.token); | |
| } | |
| }; | |
| fetchSites(); | |
| }, [connection.user, connection.token]); | |
| const handleConnect = async (event: React.FormEvent) => { | |
| event.preventDefault(); | |
| isConnecting.set(true); | |
| try { | |
| const response = await fetch('https://api.netlify.com/api/v1/user', { | |
| headers: { | |
| Authorization: `Bearer ${connection.token}`, | |
| 'Content-Type': 'application/json', | |
| }, | |
| }); | |
| if (!response.ok) { | |
| throw new Error('Invalid token or unauthorized'); | |
| } | |
| const userData = (await response.json()) as NetlifyUser; | |
| updateNetlifyConnection({ | |
| user: userData, | |
| token: connection.token, | |
| }); | |
| await fetchNetlifyStats(connection.token); | |
| toast.success('Successfully connected to Netlify'); | |
| } catch (error) { | |
| console.error('Auth error:', error); | |
| logStore.logError('Failed to authenticate with Netlify', { error }); | |
| toast.error('Failed to connect to Netlify'); | |
| updateNetlifyConnection({ user: null, token: '' }); | |
| } finally { | |
| isConnecting.set(false); | |
| } | |
| }; | |
| const handleDisconnect = () => { | |
| updateNetlifyConnection({ user: null, token: '' }); | |
| toast.success('Disconnected from Netlify'); | |
| }; | |
| return ( | |
| <motion.div | |
| className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]" | |
| initial={{ opacity: 0, y: 20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.3 }} | |
| > | |
| <div className="p-6 space-y-6"> | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <img | |
| className="w-5 h-5" | |
| height="24" | |
| width="24" | |
| crossOrigin="anonymous" | |
| src="https://cdn.simpleicons.org/netlify" | |
| /> | |
| <h3 className="text-base font-medium text-bolt-elements-textPrimary">Netlify Connection</h3> | |
| </div> | |
| </div> | |
| {!connection.user ? ( | |
| <div className="space-y-4"> | |
| <div> | |
| <label className="block text-sm text-bolt-elements-textSecondary mb-2">Personal Access Token</label> | |
| <input | |
| type="password" | |
| value={connection.token} | |
| onChange={(e) => updateNetlifyConnection({ ...connection, token: e.target.value })} | |
| disabled={connecting} | |
| placeholder="Enter your Netlify personal access 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-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', | |
| 'focus:outline-none focus:ring-1 focus:ring-[#00AD9F]', | |
| 'disabled:opacity-50', | |
| )} | |
| /> | |
| <div className="mt-2 text-sm text-bolt-elements-textSecondary"> | |
| <a | |
| href="https://app.netlify.com/user/applications#personal-access-tokens" | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="text-[#00AD9F] hover:underline inline-flex items-center gap-1" | |
| > | |
| Get your token | |
| <div className="i-ph:arrow-square-out w-4 h-4" /> | |
| </a> | |
| </div> | |
| </div> | |
| <button | |
| onClick={handleConnect} | |
| disabled={connecting || !connection.token} | |
| className={classNames( | |
| 'px-4 py-2 rounded-lg text-sm flex items-center gap-2', | |
| 'bg-[#00AD9F] text-white', | |
| 'hover:bg-[#00968A]', | |
| 'disabled:opacity-50 disabled:cursor-not-allowed', | |
| )} | |
| > | |
| {connecting ? ( | |
| <> | |
| <div className="i-ph:spinner-gap animate-spin" /> | |
| Connecting... | |
| </> | |
| ) : ( | |
| <> | |
| <div className="i-ph:plug-charging w-4 h-4" /> | |
| Connect | |
| </> | |
| )} | |
| </button> | |
| </div> | |
| ) : ( | |
| <div className="space-y-6"> | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-3"> | |
| <button | |
| onClick={handleDisconnect} | |
| className={classNames( | |
| 'px-4 py-2 rounded-lg text-sm flex items-center gap-2', | |
| 'bg-red-500 text-white', | |
| 'hover:bg-red-600', | |
| )} | |
| > | |
| <div className="i-ph:plug w-4 h-4" /> | |
| Disconnect | |
| </button> | |
| <span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1"> | |
| <div className="i-ph:check-circle w-4 h-4 text-green-500" /> | |
| Connected to Netlify | |
| </span> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg"> | |
| <img | |
| src={connection.user.avatar_url} | |
| referrerPolicy="no-referrer" | |
| crossOrigin="anonymous" | |
| alt={connection.user.full_name} | |
| className="w-12 h-12 rounded-full border-2 border-[#00AD9F]" | |
| /> | |
| <div> | |
| <h4 className="text-sm font-medium text-bolt-elements-textPrimary">{connection.user.full_name}</h4> | |
| <p className="text-sm text-bolt-elements-textSecondary">{connection.user.email}</p> | |
| </div> | |
| </div> | |
| {fetchingStats ? ( | |
| <div className="flex items-center gap-2 text-sm text-bolt-elements-textSecondary"> | |
| <div className="i-ph:spinner-gap w-4 h-4 animate-spin" /> | |
| Fetching Netlify sites... | |
| </div> | |
| ) : ( | |
| <div> | |
| <button | |
| onClick={() => setIsSitesExpanded(!isSitesExpanded)} | |
| className="w-full bg-transparent text-left text-sm font-medium text-bolt-elements-textPrimary mb-3 flex items-center gap-2" | |
| > | |
| <div className="i-ph:buildings w-4 h-4" /> | |
| Your Sites ({connection.stats?.totalSites || 0}) | |
| <div | |
| className={classNames( | |
| 'i-ph:caret-down w-4 h-4 ml-auto transition-transform', | |
| isSitesExpanded ? 'rotate-180' : '', | |
| )} | |
| /> | |
| </button> | |
| {isSitesExpanded && connection.stats?.sites?.length ? ( | |
| <div className="grid gap-3"> | |
| {connection.stats.sites.map((site) => ( | |
| <a | |
| key={site.id} | |
| href={site.admin_url} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="block p-4 rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-[#00AD9F] dark:hover:border-[#00AD9F] transition-colors" | |
| > | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2"> | |
| <div className="i-ph:globe w-4 h-4 text-[#00AD9F]" /> | |
| {site.name} | |
| </h5> | |
| <div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary"> | |
| <a | |
| href={site.url} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="hover:text-[#00AD9F]" | |
| > | |
| {site.url} | |
| </a> | |
| {site.published_deploy && ( | |
| <> | |
| <span>•</span> | |
| <span className="flex items-center gap-1"> | |
| <div className="i-ph:clock w-3 h-3" /> | |
| {new Date(site.published_deploy.published_at).toLocaleDateString()} | |
| </span> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| {site.build_settings?.provider && ( | |
| <div className="text-xs text-bolt-elements-textSecondary px-2 py-1 rounded-md bg-[#F0F0F0] dark:bg-[#252525]"> | |
| <span className="flex items-center gap-1"> | |
| <div className="i-ph:git-branch w-3 h-3" /> | |
| {site.build_settings.provider} | |
| </span> | |
| </div> | |
| )} | |
| </div> | |
| </a> | |
| ))} | |
| </div> | |
| ) : isSitesExpanded ? ( | |
| <div className="text-sm text-bolt-elements-textSecondary flex items-center gap-2"> | |
| <div className="i-ph:info w-4 h-4" /> | |
| No sites found in your Netlify account | |
| </div> | |
| ) : null} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </motion.div> | |
| ); | |
| } | |