feat: add one-click netlify deployment
This commit is contained in:
273
app/components/@settings/tabs/connections/NetlifyConnection.tsx
Normal file
273
app/components/@settings/tabs/connections/NetlifyConnection.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import React, { 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 { netlifyConnection, isConnecting, isFetchingStats, updateNetlifyConnection } from '~/lib/stores/netlify';
|
||||
import type { NetlifyUser, NetlifySite } from '~/types/netlify';
|
||||
|
||||
export function NetlifyConnection() {
|
||||
const connection = useStore(netlifyConnection);
|
||||
const connecting = useStore(isConnecting);
|
||||
const fetchingStats = useStore(isFetchingStats);
|
||||
|
||||
// Update the useEffect to handle the fetching state properly
|
||||
useEffect(() => {
|
||||
const fetchSites = async () => {
|
||||
if (connection.user && connection.token) {
|
||||
await fetchNetlifyStats(connection.token);
|
||||
}
|
||||
};
|
||||
fetchSites();
|
||||
}, [connection.user, connection.token]);
|
||||
|
||||
const fetchNetlifyStats = async (token: string) => {
|
||||
try {
|
||||
isFetchingStats.set(true);
|
||||
|
||||
const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!sitesResponse.ok) {
|
||||
throw new Error(`Failed to fetch sites: ${sitesResponse.status}`);
|
||||
}
|
||||
|
||||
const sites = await sitesResponse.json() as NetlifySite[];
|
||||
|
||||
const currentState = netlifyConnection.get();
|
||||
updateNetlifyConnection({
|
||||
...currentState,
|
||||
stats: {
|
||||
sites,
|
||||
totalSites: sites.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Netlify API Error:', error);
|
||||
logStore.logError('Failed to fetch Netlify stats', { error });
|
||||
toast.error('Failed to fetch Netlify statistics');
|
||||
} finally {
|
||||
isFetchingStats.set(false);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<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>
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className={classNames(
|
||||
'px-3 py-1.5 rounded-lg text-sm flex items-center gap-2',
|
||||
'text-white bg-red-800 border border-red-500',
|
||||
'hover:bg-red-50 dark:hover:bg-red-950',
|
||||
)}
|
||||
>
|
||||
<div className="i-ph:plug w-4 h-4" />
|
||||
Disconnect
|
||||
</button>
|
||||
</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>
|
||||
<h4 className="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})
|
||||
</h4>
|
||||
{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>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user