feat: github fix and ui improvements (#1685)
* feat: Add reusable UI components and fix GitHub repository display * style: Fix linting issues in UI components * fix: Add close icon to GitHub Connection Required dialog * fix: Add CloseButton component to fix white background issue in dialog close icons * Fix close button styling in dialog components to address ghost white issue in dark mode * fix: update icon color to tertiary for consistency The icon color was changed from `text-bolt-elements-icon-info` to `text-bolt-elements-icon-tertiary` * fix: improve repository selection dialog tab styling for dark mode - Update tab menu styling to prevent white background in dark mode - Use explicit color values for better dark/light mode compatibility - Improve hover and active states for better visual hierarchy - Remove unused Tabs imports --------- Co-authored-by: KevIsDev <zennerd404@gmail.com>
This commit is contained in:
@@ -912,7 +912,7 @@ export default function GitHubConnection() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="i-ph:git-branch w-4 h-4 text-bolt-elements-icon-info dark:text-bolt-elements-icon-info" />
|
<div className="i-ph:git-branch w-4 h-4 text-bolt-elements-icon-tertiary" />
|
||||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-bolt-elements-item-contentAccent transition-colors">
|
<h5 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-bolt-elements-item-contentAccent transition-colors">
|
||||||
{repo.name}
|
{repo.name}
|
||||||
</h5>
|
</h5>
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import Cookies from 'js-cookie';
|
||||||
|
import type { GitHubUserResponse } from '~/types/GitHub';
|
||||||
|
|
||||||
|
interface GitHubAuthDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GitHubAuthDialog({ isOpen, onClose }: GitHubAuthDialogProps) {
|
||||||
|
const [token, setToken] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [tokenType, setTokenType] = useState<'classic' | 'fine-grained'>('classic');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!token.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://api.github.com/user', {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/vnd.github.v3+json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const userData = (await response.json()) as GitHubUserResponse;
|
||||||
|
|
||||||
|
// Save connection data
|
||||||
|
const connectionData = {
|
||||||
|
token,
|
||||||
|
tokenType,
|
||||||
|
user: {
|
||||||
|
login: userData.login,
|
||||||
|
avatar_url: userData.avatar_url,
|
||||||
|
name: userData.name || userData.login,
|
||||||
|
},
|
||||||
|
connected_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem('github_connection', JSON.stringify(connectionData));
|
||||||
|
|
||||||
|
// Set cookies for API requests
|
||||||
|
Cookies.set('githubToken', token);
|
||||||
|
Cookies.set('githubUsername', userData.login);
|
||||||
|
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
|
||||||
|
|
||||||
|
toast.success(`Successfully connected as ${userData.login}`);
|
||||||
|
setToken('');
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
if (response.status === 401) {
|
||||||
|
toast.error('Invalid GitHub token. Please check and try again.');
|
||||||
|
} else {
|
||||||
|
toast.error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error connecting to GitHub:', error);
|
||||||
|
toast.error('Failed to connect to GitHub. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<Dialog.Content className="bg-white dark:bg-[#1A1A1A] rounded-lg shadow-xl max-w-sm w-full mx-4 overflow-hidden">
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<h2 className="text-lg font-semibold text-[#111111] dark:text-white">Access Private Repositories</h2>
|
||||||
|
|
||||||
|
<p className="text-sm text-[#666666] dark:text-[#999999]">
|
||||||
|
To access private repositories, you need to connect your GitHub account by providing a personal access
|
||||||
|
token.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-[#F9F9F9] dark:bg-[#252525] p-4 rounded-lg space-y-3">
|
||||||
|
<h3 className="text-base font-medium text-[#111111] dark:text-white">Connect with GitHub Token</h3>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-[#666666] dark:text-[#999999] mb-1">
|
||||||
|
GitHub Personal Access Token
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
placeholder="ghp_xxxxxxxxxxxxxxxxxxxx"
|
||||||
|
className="w-full px-3 py-1.5 rounded-lg border border-[#E5E5E5] dark:border-[#333333] bg-white dark:bg-[#1A1A1A] text-[#111111] dark:text-white placeholder-[#999999] text-sm"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 text-xs text-[#666666] dark:text-[#999999]">
|
||||||
|
Get your token at{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/settings/tokens"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-purple-500 hover:underline"
|
||||||
|
>
|
||||||
|
github.com/settings/tokens
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-sm text-[#666666] dark:text-[#999999]">Token Type</label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={tokenType === 'classic'}
|
||||||
|
onChange={() => setTokenType('classic')}
|
||||||
|
className="w-3.5 h-3.5 accent-purple-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-[#111111] dark:text-white">Classic</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={tokenType === 'fine-grained'}
|
||||||
|
onChange={() => setTokenType('fine-grained')}
|
||||||
|
className="w-3.5 h-3.5 accent-purple-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-[#111111] dark:text-white">Fine-grained</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full py-2 bg-purple-500 hover:bg-purple-600 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Connecting...' : 'Connect to GitHub'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-900/20 p-3 rounded-lg space-y-1.5">
|
||||||
|
<h3 className="text-sm text-amber-800 dark:text-amber-300 font-medium flex items-center gap-1.5">
|
||||||
|
<span className="i-ph:warning-circle w-4 h-4" />
|
||||||
|
Accessing Private Repositories
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-400">
|
||||||
|
Important things to know about accessing private repositories:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-4 text-xs text-amber-700 dark:text-amber-400 space-y-0.5">
|
||||||
|
<li>You must be granted access to the repository by its owner</li>
|
||||||
|
<li>Your GitHub token must have the 'repo' scope</li>
|
||||||
|
<li>For organization repositories, you may need additional permissions</li>
|
||||||
|
<li>No token can give you access to repositories you don't have permission for</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[#E5E5E5] dark:border-[#333333] p-3 flex justify-end">
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-1.5 bg-transparent bg-[#F5F5F5] hover:bg-[#E5E5E5] dark:bg-[#252525] dark:hover:bg-[#333333] rounded-lg text-[#111111] dark:text-white transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</Dialog.Close>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import * as Dialog from '@radix-ui/react-dialog';
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { Octokit } from '@octokit/rest';
|
||||||
|
|
||||||
|
// Internal imports
|
||||||
import { getLocalStorage } from '~/lib/persistence';
|
import { getLocalStorage } from '~/lib/persistence';
|
||||||
import { classNames } from '~/utils/classNames';
|
import { classNames } from '~/utils/classNames';
|
||||||
import type { GitHubUserResponse } from '~/types/GitHub';
|
import type { GitHubUserResponse } from '~/types/GitHub';
|
||||||
@@ -10,7 +13,9 @@ import { workbenchStore } from '~/lib/stores/workbench';
|
|||||||
import { extractRelativePath } from '~/utils/diff';
|
import { extractRelativePath } from '~/utils/diff';
|
||||||
import { formatSize } from '~/utils/formatSize';
|
import { formatSize } from '~/utils/formatSize';
|
||||||
import type { FileMap, File } from '~/lib/stores/files';
|
import type { FileMap, File } from '~/lib/stores/files';
|
||||||
import { Octokit } from '@octokit/rest';
|
|
||||||
|
// UI Components
|
||||||
|
import { Badge, EmptyState, StatusIndicator, SearchInput } from '~/components/ui';
|
||||||
|
|
||||||
interface PushToGitHubDialogProps {
|
interface PushToGitHubDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -37,6 +42,8 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [user, setUser] = useState<GitHubUserResponse | null>(null);
|
const [user, setUser] = useState<GitHubUserResponse | null>(null);
|
||||||
const [recentRepos, setRecentRepos] = useState<GitHubRepo[]>([]);
|
const [recentRepos, setRecentRepos] = useState<GitHubRepo[]>([]);
|
||||||
|
const [filteredRepos, setFilteredRepos] = useState<GitHubRepo[]>([]);
|
||||||
|
const [repoSearchQuery, setRepoSearchQuery] = useState('');
|
||||||
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
|
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
|
||||||
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
|
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
|
||||||
const [createdRepoUrl, setCreatedRepoUrl] = useState('');
|
const [createdRepoUrl, setCreatedRepoUrl] = useState('');
|
||||||
@@ -58,7 +65,34 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
|||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const fetchRecentRepos = async (token: string) => {
|
/*
|
||||||
|
* Filter repositories based on search query
|
||||||
|
* const debouncedSetRepoSearchQuery = useDebouncedCallback((value: string) => setRepoSearchQuery(value), 300);
|
||||||
|
*/
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (recentRepos.length === 0) {
|
||||||
|
setFilteredRepos([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!repoSearchQuery.trim()) {
|
||||||
|
setFilteredRepos(recentRepos);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = repoSearchQuery.toLowerCase().trim();
|
||||||
|
const filtered = recentRepos.filter(
|
||||||
|
(repo) =>
|
||||||
|
repo.name.toLowerCase().includes(query) ||
|
||||||
|
(repo.description && repo.description.toLowerCase().includes(query)) ||
|
||||||
|
(repo.language && repo.language.toLowerCase().includes(query)),
|
||||||
|
);
|
||||||
|
|
||||||
|
setFilteredRepos(filtered);
|
||||||
|
}, [recentRepos, repoSearchQuery]);
|
||||||
|
|
||||||
|
const fetchRecentRepos = useCallback(async (token: string) => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
logStore.logError('No GitHub token available');
|
logStore.logError('No GitHub token available');
|
||||||
toast.error('GitHub authentication required');
|
toast.error('GitHub authentication required');
|
||||||
@@ -68,19 +102,32 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsFetchingRepos(true);
|
setIsFetchingRepos(true);
|
||||||
|
console.log('Fetching GitHub repositories with token:', token.substring(0, 5) + '...');
|
||||||
|
|
||||||
const response = await fetch(
|
// Fetch ALL repos by paginating through all pages
|
||||||
'https://api.github.com/user/repos?sort=updated&per_page=5&type=all&affiliation=owner,organization_member',
|
let allRepos: GitHubRepo[] = [];
|
||||||
{
|
let page = 1;
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const requestUrl = `https://api.github.com/user/repos?sort=updated&per_page=100&page=${page}&affiliation=owner,organization_member`;
|
||||||
|
const response = await fetch(requestUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/vnd.github.v3+json',
|
Accept: 'application/vnd.github.v3+json',
|
||||||
Authorization: `Bearer ${token.trim()}`,
|
Authorization: `Bearer ${token.trim()}`,
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
let errorData: { message?: string } = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
errorData = await response.json();
|
||||||
|
console.error('Error response data:', errorData);
|
||||||
|
} catch (e) {
|
||||||
|
errorData = { message: 'Could not parse error response' };
|
||||||
|
console.error('Could not parse error response:', e);
|
||||||
|
}
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
toast.error('GitHub token expired. Please reconnect your account.');
|
toast.error('GitHub token expired. Please reconnect your account.');
|
||||||
@@ -92,29 +139,52 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
|||||||
localStorage.removeItem('github_connection');
|
localStorage.removeItem('github_connection');
|
||||||
setUser(null);
|
setUser(null);
|
||||||
}
|
}
|
||||||
|
} else if (response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0') {
|
||||||
|
// Rate limit exceeded
|
||||||
|
const resetTime = response.headers.get('x-ratelimit-reset');
|
||||||
|
const resetDate = resetTime ? new Date(parseInt(resetTime) * 1000).toLocaleTimeString() : 'soon';
|
||||||
|
toast.error(`GitHub API rate limit exceeded. Limit resets at ${resetDate}`);
|
||||||
} else {
|
} else {
|
||||||
logStore.logError('Failed to fetch GitHub repositories', {
|
logStore.logError('Failed to fetch GitHub repositories', {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
error: errorData,
|
error: errorData,
|
||||||
});
|
});
|
||||||
toast.error(`Failed to fetch repositories: ${response.statusText}`);
|
toast.error(`Failed to fetch repositories: ${errorData.message || response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const repos = (await response.json()) as GitHubRepo[];
|
const repos = (await response.json()) as GitHubRepo[];
|
||||||
setRecentRepos(repos);
|
allRepos = allRepos.concat(repos);
|
||||||
|
|
||||||
|
if (repos.length < 100) {
|
||||||
|
hasMore = false;
|
||||||
|
} else {
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('Error parsing JSON response:', parseError);
|
||||||
|
logStore.logError('Failed to parse GitHub repositories response', { parseError });
|
||||||
|
toast.error('Failed to parse repository data');
|
||||||
|
setRecentRepos([]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setRecentRepos(allRepos);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Exception while fetching GitHub repositories:', error);
|
||||||
logStore.logError('Failed to fetch GitHub repositories', { error });
|
logStore.logError('Failed to fetch GitHub repositories', { error });
|
||||||
toast.error('Failed to fetch recent repositories');
|
toast.error('Failed to fetch recent repositories');
|
||||||
} finally {
|
} finally {
|
||||||
setIsFetchingRepos(false);
|
setIsFetchingRepos(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const connection = getLocalStorage('github_connection');
|
const connection = getLocalStorage('github_connection');
|
||||||
@@ -186,7 +256,7 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setRepoName('');
|
setRepoName('');
|
||||||
@@ -210,27 +280,46 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
|||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="w-[90vw] md:w-[600px] max-h-[85vh] overflow-y-auto"
|
className="w-[90vw] md:w-[600px] max-h-[85vh] overflow-y-auto"
|
||||||
>
|
>
|
||||||
<Dialog.Content className="bg-white dark:bg-[#1E1E1E] rounded-lg border border-[#E5E5E5] dark:border-[#333333] shadow-xl">
|
<Dialog.Content
|
||||||
|
className="bg-white dark:bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark shadow-xl"
|
||||||
|
aria-describedby="success-dialog-description"
|
||||||
|
>
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 text-green-500">
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-green-500/10 flex items-center justify-center text-green-500">
|
||||||
<div className="i-ph:check-circle w-5 h-5" />
|
<div className="i-ph:check-circle w-5 h-5" />
|
||||||
<h3 className="text-lg font-medium">Successfully pushed to GitHub</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<Dialog.Close
|
<div>
|
||||||
onClick={handleClose}
|
<h3 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||||
className="p-2 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400"
|
Successfully pushed to GitHub
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
id="success-dialog-description"
|
||||||
|
className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark"
|
||||||
>
|
>
|
||||||
<div className="i-ph:x w-5 h-5" />
|
Your code is now available on GitHub
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="p-2 rounded-lg transition-all duration-200 ease-in-out bg-transparent text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textPrimary-dark hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3 focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark"
|
||||||
|
>
|
||||||
|
<span className="i-ph:x block w-5 h-5" aria-hidden="true" />
|
||||||
|
<span className="sr-only">Close dialog</span>
|
||||||
|
</button>
|
||||||
</Dialog.Close>
|
</Dialog.Close>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg p-3 text-left">
|
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg p-4 text-left border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||||
<p className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
|
<p className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark mb-2 flex items-center gap-2">
|
||||||
|
<span className="i-ph:github-logo w-4 h-4 text-purple-500" />
|
||||||
Repository URL
|
Repository URL
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="flex-1 text-sm bg-bolt-elements-background dark:bg-bolt-elements-background-dark px-3 py-2 rounded border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark font-mono">
|
<code className="flex-1 text-sm bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-4 px-3 py-2 rounded border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark font-mono">
|
||||||
{createdRepoUrl}
|
{createdRepoUrl}
|
||||||
</code>
|
</code>
|
||||||
<motion.button
|
<motion.button
|
||||||
@@ -238,27 +327,28 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
|||||||
navigator.clipboard.writeText(createdRepoUrl);
|
navigator.clipboard.writeText(createdRepoUrl);
|
||||||
toast.success('URL copied to clipboard');
|
toast.success('URL copied to clipboard');
|
||||||
}}
|
}}
|
||||||
className="p-2 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textSecondary-dark dark:hover:text-bolt-elements-textPrimary-dark"
|
className="p-2 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textSecondary-dark dark:hover:text-bolt-elements-textPrimary-dark bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-4 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark"
|
||||||
whileHover={{ scale: 1.1 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.9 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
>
|
>
|
||||||
<div className="i-ph:copy w-4 h-4" />
|
<div className="i-ph:copy w-4 h-4" />
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg p-3">
|
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg p-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||||
<p className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
|
<p className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark mb-2 flex items-center gap-2">
|
||||||
|
<span className="i-ph:files w-4 h-4 text-purple-500" />
|
||||||
Pushed Files ({pushedFiles.length})
|
Pushed Files ({pushedFiles.length})
|
||||||
</p>
|
</p>
|
||||||
<div className="max-h-[200px] overflow-y-auto custom-scrollbar">
|
<div className="max-h-[200px] overflow-y-auto custom-scrollbar pr-2">
|
||||||
{pushedFiles.map((file) => (
|
{pushedFiles.map((file) => (
|
||||||
<div
|
<div
|
||||||
key={file.path}
|
key={file.path}
|
||||||
className="flex items-center justify-between py-1 text-sm text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
|
className="flex items-center justify-between py-1.5 text-sm text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark border-b border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 last:border-0"
|
||||||
>
|
>
|
||||||
<span className="font-mono truncate flex-1">{file.path}</span>
|
<span className="font-mono truncate flex-1 text-xs">{file.path}</span>
|
||||||
<span className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark ml-2">
|
<span className="text-xs px-2 py-0.5 rounded-full bg-bolt-elements-background-depth-3 dark:bg-bolt-elements-background-depth-4 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark ml-2">
|
||||||
{formatSize(file.size)}
|
{formatSize(file.size)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -283,7 +373,7 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
|||||||
navigator.clipboard.writeText(createdRepoUrl);
|
navigator.clipboard.writeText(createdRepoUrl);
|
||||||
toast.success('URL copied to clipboard');
|
toast.success('URL copied to clipboard');
|
||||||
}}
|
}}
|
||||||
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm inline-flex items-center gap-2"
|
className="px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 text-sm inline-flex items-center gap-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark"
|
||||||
whileHover={{ scale: 1.02 }}
|
whileHover={{ scale: 1.02 }}
|
||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
>
|
>
|
||||||
@@ -292,7 +382,7 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
|||||||
</motion.button>
|
</motion.button>
|
||||||
<motion.button
|
<motion.button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm"
|
className="px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 text-sm border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark"
|
||||||
whileHover={{ scale: 1.02 }}
|
whileHover={{ scale: 1.02 }}
|
||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
>
|
>
|
||||||
@@ -321,29 +411,57 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
|||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="w-[90vw] md:w-[500px]"
|
className="w-[90vw] md:w-[500px]"
|
||||||
>
|
>
|
||||||
<Dialog.Content className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl">
|
<Dialog.Content
|
||||||
<div className="text-center space-y-4">
|
className="bg-white dark:bg-bolt-elements-background-depth-1 rounded-lg p-6 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark shadow-xl"
|
||||||
|
aria-describedby="connection-required-description"
|
||||||
|
>
|
||||||
|
<div className="relative text-center space-y-4">
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="absolute right-0 top-0 p-2 rounded-lg transition-all duration-200 ease-in-out bg-transparent text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textPrimary-dark hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3 focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark"
|
||||||
|
>
|
||||||
|
<span className="i-ph:x block w-5 h-5" aria-hidden="true" />
|
||||||
|
<span className="sr-only">Close dialog</span>
|
||||||
|
</button>
|
||||||
|
</Dialog.Close>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.8 }}
|
initial={{ scale: 0.8 }}
|
||||||
animate={{ scale: 1 }}
|
animate={{ scale: 1 }}
|
||||||
transition={{ delay: 0.1 }}
|
transition={{ delay: 0.1 }}
|
||||||
className="mx-auto w-12 h-12 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500"
|
className="mx-auto w-16 h-16 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500"
|
||||||
>
|
>
|
||||||
<div className="i-ph:github-logo w-6 h-6" />
|
<div className="i-ph:github-logo w-8 h-8" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">GitHub Connection Required</h3>
|
<h3 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
GitHub Connection Required
|
||||||
Please connect your GitHub account in Settings {'>'} Connections to push your code to GitHub.
|
</h3>
|
||||||
|
<p
|
||||||
|
id="connection-required-description"
|
||||||
|
className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark max-w-md mx-auto"
|
||||||
|
>
|
||||||
|
To push your code to GitHub, you need to connect your GitHub account in Settings {'>'} Connections
|
||||||
|
first.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="pt-2 flex justify-center gap-3">
|
||||||
<motion.button
|
<motion.button
|
||||||
className="px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600 inline-flex items-center gap-2"
|
className="px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark text-sm hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark"
|
||||||
whileHover={{ scale: 1.02 }}
|
whileHover={{ scale: 1.02 }}
|
||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
>
|
>
|
||||||
<div className="i-ph:x-circle" />
|
|
||||||
Close
|
Close
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
<motion.a
|
||||||
|
href="/settings/connections"
|
||||||
|
className="px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600 inline-flex items-center gap-2"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<div className="i-ph:gear" />
|
||||||
|
Go to Settings
|
||||||
|
</motion.a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -365,7 +483,10 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
|||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="w-[90vw] md:w-[500px]"
|
className="w-[90vw] md:w-[500px]"
|
||||||
>
|
>
|
||||||
<Dialog.Content className="bg-white dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl">
|
<Dialog.Content
|
||||||
|
className="bg-white dark:bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark shadow-xl"
|
||||||
|
aria-describedby="push-dialog-description"
|
||||||
|
>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -374,130 +495,189 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
|||||||
transition={{ delay: 0.1 }}
|
transition={{ delay: 0.1 }}
|
||||||
className="w-10 h-10 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500"
|
className="w-10 h-10 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500"
|
||||||
>
|
>
|
||||||
<div className="i-ph:git-branch w-5 h-5" />
|
<div className="i-ph:github-logo w-5 h-5" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<div>
|
<div>
|
||||||
<Dialog.Title className="text-lg font-medium text-gray-900 dark:text-white">
|
<Dialog.Title className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||||
Push to GitHub
|
Push to GitHub
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p
|
||||||
|
id="push-dialog-description"
|
||||||
|
className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark"
|
||||||
|
>
|
||||||
Push your code to a new or existing GitHub repository
|
Push your code to a new or existing GitHub repository
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog.Close
|
<Dialog.Close asChild>
|
||||||
className="ml-auto p-2 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400"
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
|
className="ml-auto p-2 rounded-lg transition-all duration-200 ease-in-out bg-transparent text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textPrimary-dark hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3 focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark"
|
||||||
>
|
>
|
||||||
<div className="i-ph:x w-5 h-5" />
|
<span className="i-ph:x block w-5 h-5" aria-hidden="true" />
|
||||||
|
<span className="sr-only">Close dialog</span>
|
||||||
|
</button>
|
||||||
</Dialog.Close>
|
</Dialog.Close>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-6 p-3 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg">
|
<div className="flex items-center gap-3 mb-6 p-4 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||||
|
<div className="relative">
|
||||||
<img src={user.avatar_url} alt={user.login} className="w-10 h-10 rounded-full" />
|
<img src={user.avatar_url} alt={user.login} className="w-10 h-10 rounded-full" />
|
||||||
|
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-purple-500 flex items-center justify-center text-white">
|
||||||
|
<div className="i-ph:github-logo w-3 h-3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{user.name || user.login}</p>
|
<p className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">@{user.login}</p>
|
{user.name || user.login}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||||
|
@{user.login}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="repoName" className="text-sm text-gray-600 dark:text-gray-400">
|
<label
|
||||||
|
htmlFor="repoName"
|
||||||
|
className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark"
|
||||||
|
>
|
||||||
Repository Name
|
Repository Name
|
||||||
</label>
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||||
|
<span className="i-ph:git-branch w-4 h-4" />
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
id="repoName"
|
id="repoName"
|
||||||
type="text"
|
type="text"
|
||||||
value={repoName}
|
value={repoName}
|
||||||
onChange={(e) => setRepoName(e.target.value)}
|
onChange={(e) => setRepoName(e.target.value)}
|
||||||
placeholder="my-awesome-project"
|
placeholder="my-awesome-project"
|
||||||
className="w-full px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-[#E5E5E5] dark:border-[#1A1A1A] text-gray-900 dark:text-white placeholder-gray-400"
|
className="w-full pl-10 px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark placeholder-bolt-elements-textTertiary dark:placeholder-bolt-elements-textTertiary-dark focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{recentRepos.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm text-gray-600 dark:text-gray-400">Recent Repositories</label>
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="space-y-2">
|
<label className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||||
{recentRepos.map((repo) => (
|
Recent Repositories
|
||||||
|
</label>
|
||||||
|
<span className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||||
|
{filteredRepos.length} of {recentRepos.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2">
|
||||||
|
<SearchInput
|
||||||
|
placeholder="Search repositories..."
|
||||||
|
value={repoSearchQuery}
|
||||||
|
onChange={(e) => setRepoSearchQuery(e.target.value)}
|
||||||
|
onClear={() => setRepoSearchQuery('')}
|
||||||
|
className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recentRepos.length === 0 && !isFetchingRepos ? (
|
||||||
|
<EmptyState
|
||||||
|
icon="i-ph:github-logo"
|
||||||
|
title="No repositories found"
|
||||||
|
description="We couldn't find any repositories in your GitHub account."
|
||||||
|
variant="compact"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-[200px] overflow-y-auto pr-2 custom-scrollbar">
|
||||||
|
{filteredRepos.length === 0 && repoSearchQuery.trim() !== '' ? (
|
||||||
|
<EmptyState
|
||||||
|
icon="i-ph:magnifying-glass"
|
||||||
|
title="No matching repositories"
|
||||||
|
description="Try a different search term"
|
||||||
|
variant="compact"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
filteredRepos.map((repo) => (
|
||||||
<motion.button
|
<motion.button
|
||||||
key={repo.full_name}
|
key={repo.full_name}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setRepoName(repo.name)}
|
onClick={() => setRepoName(repo.name)}
|
||||||
className="w-full p-3 text-left rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 transition-colors group"
|
className="w-full p-3 text-left rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 transition-colors group border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark hover:border-purple-500/30"
|
||||||
whileHover={{ scale: 1.01 }}
|
whileHover={{ scale: 1.01 }}
|
||||||
whileTap={{ scale: 0.99 }}
|
whileTap={{ scale: 0.99 }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="i-ph:git-branch w-4 h-4 text-purple-500" />
|
<div className="i-ph:git-branch w-4 h-4 text-purple-500" />
|
||||||
<span className="text-sm font-medium text-gray-900 dark:text-white group-hover:text-purple-500">
|
<span className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark group-hover:text-purple-500">
|
||||||
{repo.name}
|
{repo.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{repo.private && (
|
{repo.private && (
|
||||||
<span className="text-xs px-2 py-1 rounded-full bg-purple-500/10 text-purple-500">
|
<Badge variant="primary" size="sm" icon="i-ph:lock w-3 h-3">
|
||||||
Private
|
Private
|
||||||
</span>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{repo.description && (
|
{repo.description && (
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
<p className="mt-1 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark line-clamp-2">
|
||||||
{repo.description}
|
{repo.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="mt-2 flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500">
|
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||||
{repo.language && (
|
{repo.language && (
|
||||||
<span className="flex items-center gap-1">
|
<Badge variant="subtle" size="sm" icon="i-ph:code w-3 h-3">
|
||||||
<div className="i-ph:code w-3 h-3" />
|
|
||||||
{repo.language}
|
{repo.language}
|
||||||
</span>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<span className="flex items-center gap-1">
|
<Badge variant="subtle" size="sm" icon="i-ph:star w-3 h-3">
|
||||||
<div className="i-ph:star w-3 h-3" />
|
|
||||||
{repo.stargazers_count.toLocaleString()}
|
{repo.stargazers_count.toLocaleString()}
|
||||||
</span>
|
</Badge>
|
||||||
<span className="flex items-center gap-1">
|
<Badge variant="subtle" size="sm" icon="i-ph:git-fork w-3 h-3">
|
||||||
<div className="i-ph:git-fork w-3 h-3" />
|
|
||||||
{repo.forks_count.toLocaleString()}
|
{repo.forks_count.toLocaleString()}
|
||||||
</span>
|
</Badge>
|
||||||
<span className="flex items-center gap-1">
|
<Badge variant="subtle" size="sm" icon="i-ph:clock w-3 h-3">
|
||||||
<div className="i-ph:clock w-3 h-3" />
|
|
||||||
{new Date(repo.updated_at).toLocaleDateString()}
|
{new Date(repo.updated_at).toLocaleDateString()}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
))}
|
))
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{isFetchingRepos && (
|
{isFetchingRepos && (
|
||||||
<div className="flex items-center justify-center py-4 text-gray-500 dark:text-gray-400">
|
<div className="flex items-center justify-center py-4">
|
||||||
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4 mr-2" />
|
<StatusIndicator status="loading" pulse={true} label="Loading repositories..." />
|
||||||
Loading repositories...
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="p-3 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="private"
|
id="private"
|
||||||
checked={isPrivate}
|
checked={isPrivate}
|
||||||
onChange={(e) => setIsPrivate(e.target.checked)}
|
onChange={(e) => setIsPrivate(e.target.checked)}
|
||||||
className="rounded border-[#E5E5E5] dark:border-[#1A1A1A] text-purple-500 focus:ring-purple-500 dark:bg-[#0A0A0A]"
|
className="rounded border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-purple-500 focus:ring-purple-500 dark:bg-bolt-elements-background-depth-3"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="private" className="text-sm text-gray-600 dark:text-gray-400">
|
<label
|
||||||
|
htmlFor="private"
|
||||||
|
className="text-sm text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
|
||||||
|
>
|
||||||
Make repository private
|
Make repository private
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark mt-2 ml-6">
|
||||||
|
Private repositories are only visible to you and people you share them with
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="pt-4 flex gap-2">
|
<div className="pt-4 flex gap-2">
|
||||||
<motion.button
|
<motion.button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm"
|
className="px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 text-sm border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark"
|
||||||
whileHover={{ scale: 1.02 }}
|
whileHover={{ scale: 1.02 }}
|
||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
>
|
>
|
||||||
@@ -515,12 +695,12 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
|||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
<div className="i-ph:spinner-gap animate-spin w-4 h-4" />
|
||||||
Pushing...
|
Pushing...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="i-ph:git-branch w-4 h-4" />
|
<div className="i-ph:github-logo w-4 h-4" />
|
||||||
Push to GitHub
|
Push to GitHub
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import type { GitHubRepoInfo } from '~/types/GitHub';
|
||||||
|
|
||||||
|
interface RepositoryCardProps {
|
||||||
|
repo: GitHubRepoInfo;
|
||||||
|
onSelect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export function RepositoryCard({ repo, onSelect }: RepositoryCardProps) {
|
||||||
|
// Use a consistent styling for all repository cards
|
||||||
|
const getCardStyle = () => {
|
||||||
|
return 'from-bolt-elements-background-depth-1 to-bolt-elements-background-depth-1 dark:from-bolt-elements-background-depth-2-dark dark:to-bolt-elements-background-depth-2-dark';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format the date in a more readable format
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffTime = Math.abs(now.getTime() - date.getTime());
|
||||||
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffDays <= 1) {
|
||||||
|
return 'Today';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffDays <= 2) {
|
||||||
|
return 'Yesterday';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffDays <= 7) {
|
||||||
|
return `${diffDays} days ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffDays <= 30) {
|
||||||
|
return `${Math.floor(diffDays / 7)} weeks ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardStyle = useMemo(() => getCardStyle(), []);
|
||||||
|
|
||||||
|
// const formattedDate = useMemo(() => formatDate(repo.updated_at), [repo.updated_at]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={`p-5 rounded-xl bg-gradient-to-br ${cardStyle} border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark hover:border-purple-500/40 transition-all duration-300 shadow-sm hover:shadow-md`}
|
||||||
|
whileHover={{
|
||||||
|
scale: 1.02,
|
||||||
|
y: -2,
|
||||||
|
transition: { type: 'spring', stiffness: 400, damping: 17 },
|
||||||
|
}}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3 gap-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-bolt-elements-background-depth-1/80 dark:bg-bolt-elements-background-depth-4/80 backdrop-blur-sm flex items-center justify-center text-purple-500 shadow-sm">
|
||||||
|
<span className="i-ph:git-branch w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark text-base">
|
||||||
|
{repo.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark flex items-center gap-1">
|
||||||
|
<span className="i-ph:user w-3 h-3" />
|
||||||
|
{repo.full_name.split('/')[0]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<motion.button
|
||||||
|
onClick={onSelect}
|
||||||
|
className="px-4 py-2 h-9 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 min-w-[100px] justify-center text-sm shadow-sm hover:shadow-md"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
<span className="i-ph:git-pull-request w-3.5 h-3.5" />
|
||||||
|
Import
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{repo.description && (
|
||||||
|
<div className="mb-4 bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm p-3 rounded-lg border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30">
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark line-clamp-2">
|
||||||
|
{repo.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{repo.private && (
|
||||||
|
<span className="flex items-center gap-1 px-2 py-1 rounded-lg bg-purple-500/10 text-purple-600 dark:text-purple-400 text-xs">
|
||||||
|
<span className="i-ph:lock w-3 h-3" />
|
||||||
|
Private
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{repo.language && (
|
||||||
|
<span className="flex items-center gap-1 px-2 py-1 rounded-lg bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark text-xs border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30">
|
||||||
|
<span className="i-ph:code w-3 h-3" />
|
||||||
|
{repo.language}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-1 px-2 py-1 rounded-lg bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark text-xs border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30">
|
||||||
|
<span className="i-ph:star w-3 h-3" />
|
||||||
|
{repo.stargazers_count.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
{repo.forks_count > 0 && (
|
||||||
|
<span className="flex items-center gap-1 px-2 py-1 rounded-lg bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark text-xs border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30">
|
||||||
|
<span className="i-ph:git-fork w-3 h-3" />
|
||||||
|
{repo.forks_count.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 pt-3 border-t border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-1 text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||||
|
<span className="i-ph:clock w-3 h-3" />
|
||||||
|
Updated {formatDate(repo.updated_at)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{repo.topics && repo.topics.length > 0 && (
|
||||||
|
<span className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||||
|
{repo.topics.slice(0, 1).map((topic) => (
|
||||||
|
<span
|
||||||
|
key={topic}
|
||||||
|
className="px-1.5 py-0.5 rounded-full bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-4/50 text-xs"
|
||||||
|
>
|
||||||
|
{topic}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{repo.topics.length > 1 && <span className="ml-1">+{repo.topics.length - 1}</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { createContext } from 'react';
|
||||||
|
|
||||||
|
// Create a context to share the setShowAuthDialog function with child components
|
||||||
|
export interface RepositoryDialogContextType {
|
||||||
|
setShowAuthDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default context value with a no-op function
|
||||||
|
export const RepositoryDialogContext = createContext<RepositoryDialogContextType>({
|
||||||
|
// This is intentionally empty as it will be overridden by the provider
|
||||||
|
setShowAuthDialog: () => {
|
||||||
|
// No operation
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import React, { useContext } from 'react';
|
||||||
|
import type { GitHubRepoInfo } from '~/types/GitHub';
|
||||||
|
import { EmptyState, StatusIndicator } from '~/components/ui';
|
||||||
|
import { RepositoryCard } from './RepositoryCard';
|
||||||
|
import { RepositoryDialogContext } from './RepositoryDialogContext';
|
||||||
|
|
||||||
|
interface RepositoryListProps {
|
||||||
|
repos: GitHubRepoInfo[];
|
||||||
|
isLoading: boolean;
|
||||||
|
onSelect: (repo: GitHubRepoInfo) => void;
|
||||||
|
activeTab: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepositoryList({ repos, isLoading, onSelect, activeTab }: RepositoryListProps) {
|
||||||
|
// Access the parent component's setShowAuthDialog function
|
||||||
|
const { setShowAuthDialog } = useContext(RepositoryDialogContext);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||||
|
<StatusIndicator status="loading" pulse={true} size="lg" label="Loading repositories..." className="mb-2" />
|
||||||
|
<p className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||||
|
This may take a moment
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (repos.length === 0) {
|
||||||
|
if (activeTab === 'my-repos') {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon="i-ph:folder-simple-dashed"
|
||||||
|
title="No repositories found"
|
||||||
|
description="Connect your GitHub account or create a new repository to get started"
|
||||||
|
actionLabel="Connect GitHub Account"
|
||||||
|
onAction={() => setShowAuthDialog(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon="i-ph:magnifying-glass"
|
||||||
|
title="No repositories found"
|
||||||
|
description="Try searching with different keywords or filters"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{repos.map((repo) => (
|
||||||
|
<RepositoryCard key={repo.full_name} repo={repo} onSelect={() => onSelect(repo)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,11 +4,18 @@ import { toast } from 'react-toastify';
|
|||||||
import * as Dialog from '@radix-ui/react-dialog';
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
import { classNames } from '~/utils/classNames';
|
import { classNames } from '~/utils/classNames';
|
||||||
import { getLocalStorage } from '~/lib/persistence';
|
import { getLocalStorage } from '~/lib/persistence';
|
||||||
import { motion } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { formatSize } from '~/utils/formatSize';
|
|
||||||
import { Input } from '~/components/ui/Input';
|
|
||||||
import Cookies from 'js-cookie';
|
import Cookies from 'js-cookie';
|
||||||
|
|
||||||
|
// Import UI components
|
||||||
|
import { Input, SearchInput, Badge, FilterChip } from '~/components/ui';
|
||||||
|
|
||||||
|
// Import the components we've extracted
|
||||||
|
import { RepositoryList } from './RepositoryList';
|
||||||
|
import { StatsDialog } from './StatsDialog';
|
||||||
|
import { GitHubAuthDialog } from './GitHubAuthDialog';
|
||||||
|
import { RepositoryDialogContext } from './RepositoryDialogContext';
|
||||||
|
|
||||||
interface GitHubTreeResponse {
|
interface GitHubTreeResponse {
|
||||||
tree: Array<{
|
tree: Array<{
|
||||||
path: string;
|
path: string;
|
||||||
@@ -29,278 +36,6 @@ interface SearchFilters {
|
|||||||
forks?: number;
|
forks?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StatsDialogProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
stats: RepositoryStats;
|
|
||||||
isLargeRepo?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatsDialog({ isOpen, onClose, onConfirm, stats, isLargeRepo }: StatsDialogProps) {
|
|
||||||
return (
|
|
||||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
|
|
||||||
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="w-[90vw] md:w-[500px]"
|
|
||||||
>
|
|
||||||
<Dialog.Content className="bg-white dark:bg-[#1E1E1E] rounded-lg border border-[#E5E5E5] dark:border-[#333333] shadow-xl">
|
|
||||||
<div className="p-6 space-y-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium text-[#111111] dark:text-white">Repository Overview</h3>
|
|
||||||
<div className="mt-4 space-y-2">
|
|
||||||
<p className="text-sm text-[#666666] dark:text-[#999999]">Repository Statistics:</p>
|
|
||||||
<div className="space-y-2 text-sm text-[#111111] dark:text-white">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="i-ph:files text-purple-500 w-4 h-4" />
|
|
||||||
<span>Total Files: {stats.totalFiles}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="i-ph:database text-purple-500 w-4 h-4" />
|
|
||||||
<span>Total Size: {formatSize(stats.totalSize)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="i-ph:code text-purple-500 w-4 h-4" />
|
|
||||||
<span>
|
|
||||||
Languages:{' '}
|
|
||||||
{Object.entries(stats.languages)
|
|
||||||
.sort(([, a], [, b]) => b - a)
|
|
||||||
.slice(0, 3)
|
|
||||||
.map(([lang, size]) => `${lang} (${formatSize(size)})`)
|
|
||||||
.join(', ')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{stats.hasPackageJson && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="i-ph:package text-purple-500 w-4 h-4" />
|
|
||||||
<span>Has package.json</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{stats.hasDependencies && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="i-ph:tree-structure text-purple-500 w-4 h-4" />
|
|
||||||
<span>Has dependencies</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isLargeRepo && (
|
|
||||||
<div className="mt-4 p-3 bg-yellow-50 dark:bg-yellow-500/10 rounded-lg text-sm flex items-start gap-2">
|
|
||||||
<span className="i-ph:warning text-yellow-600 dark:text-yellow-500 w-4 h-4 flex-shrink-0 mt-0.5" />
|
|
||||||
<div className="text-yellow-800 dark:text-yellow-500">
|
|
||||||
This repository is quite large ({formatSize(stats.totalSize)}). Importing it might take a while
|
|
||||||
and could impact performance.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="border-t border-[#E5E5E5] dark:border-[#333333] p-4 flex justify-end gap-3 bg-[#F9F9F9] dark:bg-[#252525] rounded-b-lg">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#333333] text-[#666666] hover:text-[#111111] dark:text-[#999999] dark:hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onConfirm}
|
|
||||||
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-colors"
|
|
||||||
>
|
|
||||||
OK
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GitHubAuthDialog({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
|
||||||
const [token, setToken] = useState('');
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [tokenType, setTokenType] = useState<'classic' | 'fine-grained'>('classic');
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!token.trim()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('https://api.github.com/user', {
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/vnd.github.v3+json',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const userData = (await response.json()) as GitHubUserResponse;
|
|
||||||
|
|
||||||
// Save connection data
|
|
||||||
const connectionData = {
|
|
||||||
token,
|
|
||||||
tokenType,
|
|
||||||
user: {
|
|
||||||
login: userData.login,
|
|
||||||
avatar_url: userData.avatar_url,
|
|
||||||
name: userData.name || userData.login,
|
|
||||||
},
|
|
||||||
connected_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
localStorage.setItem('github_connection', JSON.stringify(connectionData));
|
|
||||||
|
|
||||||
// Set cookies for API requests
|
|
||||||
Cookies.set('githubToken', token);
|
|
||||||
Cookies.set('githubUsername', userData.login);
|
|
||||||
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
|
|
||||||
|
|
||||||
toast.success(`Successfully connected as ${userData.login}`);
|
|
||||||
onClose();
|
|
||||||
} else {
|
|
||||||
if (response.status === 401) {
|
|
||||||
toast.error('Invalid GitHub token. Please check and try again.');
|
|
||||||
} else {
|
|
||||||
toast.error(`GitHub API error: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error connecting to GitHub:', error);
|
|
||||||
toast.error('Failed to connect to GitHub. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
|
|
||||||
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
>
|
|
||||||
<Dialog.Content className="bg-white dark:bg-[#1A1A1A] rounded-lg shadow-xl max-w-sm w-full mx-4 overflow-hidden">
|
|
||||||
<div className="p-4 space-y-3">
|
|
||||||
<h2 className="text-lg font-semibold text-[#111111] dark:text-white">Access Private Repositories</h2>
|
|
||||||
|
|
||||||
<p className="text-sm text-[#666666] dark:text-[#999999]">
|
|
||||||
To access private repositories, you need to connect your GitHub account by providing a personal access
|
|
||||||
token.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="bg-[#F9F9F9] dark:bg-[#252525] p-4 rounded-lg space-y-3">
|
|
||||||
<h3 className="text-base font-medium text-[#111111] dark:text-white">Connect with GitHub Token</h3>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-[#666666] dark:text-[#999999] mb-1">
|
|
||||||
GitHub Personal Access Token
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={token}
|
|
||||||
onChange={(e) => setToken(e.target.value)}
|
|
||||||
placeholder="ghp_xxxxxxxxxxxxxxxxxxxx"
|
|
||||||
className="w-full px-3 py-1.5 rounded-lg border border-[#E5E5E5] dark:border-[#333333] bg-white dark:bg-[#1A1A1A] text-[#111111] dark:text-white placeholder-[#999999] text-sm"
|
|
||||||
/>
|
|
||||||
<div className="mt-1 text-xs text-[#666666] dark:text-[#999999]">
|
|
||||||
Get your token at{' '}
|
|
||||||
<a
|
|
||||||
href="https://github.com/settings/tokens"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-purple-500 hover:underline"
|
|
||||||
>
|
|
||||||
github.com/settings/tokens
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="block text-sm text-[#666666] dark:text-[#999999]">Token Type</label>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
checked={tokenType === 'classic'}
|
|
||||||
onChange={() => setTokenType('classic')}
|
|
||||||
className="w-3.5 h-3.5 accent-purple-500"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-[#111111] dark:text-white">Classic</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
checked={tokenType === 'fine-grained'}
|
|
||||||
onChange={() => setTokenType('fine-grained')}
|
|
||||||
className="w-3.5 h-3.5 accent-purple-500"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-[#111111] dark:text-white">Fine-grained</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="w-full py-2 bg-purple-500 hover:bg-purple-600 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
|
||||||
>
|
|
||||||
{isSubmitting ? 'Connecting...' : 'Connect to GitHub'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-amber-50 dark:bg-amber-900/20 p-3 rounded-lg space-y-1.5">
|
|
||||||
<h3 className="text-sm text-amber-800 dark:text-amber-300 font-medium flex items-center gap-1.5">
|
|
||||||
<span className="i-ph:warning-circle w-4 h-4" />
|
|
||||||
Accessing Private Repositories
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-amber-700 dark:text-amber-400">
|
|
||||||
Important things to know about accessing private repositories:
|
|
||||||
</p>
|
|
||||||
<ul className="list-disc pl-4 text-xs text-amber-700 dark:text-amber-400 space-y-0.5">
|
|
||||||
<li>You must be granted access to the repository by its owner</li>
|
|
||||||
<li>Your GitHub token must have the 'repo' scope</li>
|
|
||||||
<li>For organization repositories, you may need additional permissions</li>
|
|
||||||
<li>No token can give you access to repositories you don't have permission for</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-[#E5E5E5] dark:border-[#333333] p-3 flex justify-end">
|
|
||||||
<Dialog.Close asChild>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-1.5 bg-[#F5F5F5] hover:bg-[#E5E5E5] dark:bg-[#252525] dark:hover:bg-[#333333] rounded-lg text-[#111111] dark:text-white transition-colors text-sm"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</Dialog.Close>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: RepositorySelectionDialogProps) {
|
export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: RepositorySelectionDialogProps) {
|
||||||
const [selectedRepository, setSelectedRepository] = useState<GitHubRepoInfo | null>(null);
|
const [selectedRepository, setSelectedRepository] = useState<GitHubRepoInfo | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -798,7 +533,7 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<RepositoryDialogContext.Provider value={{ setShowAuthDialog }}>
|
||||||
<Dialog.Root
|
<Dialog.Root
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
@@ -809,15 +544,26 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
|
|||||||
>
|
>
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" />
|
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" />
|
||||||
<Dialog.Content className="fixed top-[50%] left-[50%] -translate-x-1/2 -translate-y-1/2 w-[90vw] md:w-[600px] max-h-[85vh] overflow-hidden bg-white dark:bg-[#1A1A1A] rounded-xl shadow-xl z-[51] border border-[#E5E5E5] dark:border-[#333333]">
|
<Dialog.Content className="fixed top-[50%] left-[50%] -translate-x-1/2 -translate-y-1/2 w-[90vw] md:w-[650px] max-h-[85vh] overflow-hidden bg-white dark:bg-bolt-elements-background-depth-1 rounded-xl shadow-xl z-[51] border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||||
<div className="p-4 border-b border-[#E5E5E5] dark:border-[#333333] flex items-center justify-between">
|
{/* Header */}
|
||||||
|
<div className="p-5 border-b border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500/20 to-blue-500/10 flex items-center justify-center text-purple-500 shadow-sm">
|
||||||
|
<span className="i-ph:github-logo w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
<Dialog.Title className="text-lg font-semibold text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
<Dialog.Title className="text-lg font-semibold text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||||
Import GitHub Repository
|
Import GitHub Repository
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||||
|
Clone a repository from GitHub to your workspace
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Dialog.Close
|
<Dialog.Close
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'p-2 rounded-lg transition-all duration-200 ease-in-out',
|
'p-2 rounded-lg transition-all duration-200 ease-in-out bg-transparent',
|
||||||
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary',
|
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary',
|
||||||
'dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textPrimary-dark',
|
'dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textPrimary-dark',
|
||||||
'hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3',
|
'hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3',
|
||||||
@@ -829,148 +575,389 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
|
|||||||
</Dialog.Close>
|
</Dialog.Close>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 border-b border-[#E5E5E5] dark:border-[#333333] flex items-center justify-between">
|
{/* Auth Info Banner */}
|
||||||
|
<div className="p-4 border-b border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark flex items-center justify-between bg-gradient-to-r from-bolt-elements-background-depth-2 to-bolt-elements-background-depth-1 dark:from-bolt-elements-background-depth-3 dark:to-bolt-elements-background-depth-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="i-ph:info text-blue-500" />
|
<span className="i-ph:info text-blue-500" />
|
||||||
<span className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
<span className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||||
Need to access private repositories?
|
Need to access private repositories?
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<motion.button
|
||||||
onClick={() => setShowAuthDialog(true)}
|
onClick={() => setShowAuthDialog(true)}
|
||||||
className="px-3 py-1.5 rounded-lg bg-purple-500 hover:bg-purple-600 text-white text-sm transition-colors flex items-center gap-1.5"
|
className="px-3 py-1.5 rounded-lg bg-purple-500 hover:bg-purple-600 text-white text-sm transition-colors flex items-center gap-1.5 shadow-sm"
|
||||||
|
whileHover={{ scale: 1.02, boxShadow: '0 4px 8px rgba(124, 58, 237, 0.2)' }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
>
|
>
|
||||||
<span className="i-ph:key" />
|
<span className="i-ph:github-logo w-4 h-4" />
|
||||||
Connect GitHub Account
|
Connect GitHub Account
|
||||||
</button>
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4">
|
{/* Content */}
|
||||||
<div className="flex gap-2 mb-4">
|
<div className="p-5">
|
||||||
<TabButton active={activeTab === 'my-repos'} onClick={() => setActiveTab('my-repos')}>
|
{/* Tabs */}
|
||||||
<span className="i-ph:book-bookmark" />
|
<div className="mb-6">
|
||||||
|
<div className="bg-[#f0f0f0] dark:bg-[#1e1e1e] rounded-lg overflow-hidden border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||||
|
<div className="flex">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('my-repos')}
|
||||||
|
className={classNames(
|
||||||
|
'flex-1 py-3 px-4 text-center text-sm font-medium transition-colors',
|
||||||
|
activeTab === 'my-repos'
|
||||||
|
? 'bg-[#e6e6e6] dark:bg-[#2a2a2a] text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark'
|
||||||
|
: 'bg-[#f0f0f0] dark:bg-[#1e1e1e] text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-[#e6e6e6] dark:hover:bg-[#2a2a2a]/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
My Repos
|
My Repos
|
||||||
</TabButton>
|
</button>
|
||||||
<TabButton active={activeTab === 'search'} onClick={() => setActiveTab('search')}>
|
<button
|
||||||
<span className="i-ph:magnifying-glass" />
|
onClick={() => setActiveTab('search')}
|
||||||
|
className={classNames(
|
||||||
|
'flex-1 py-3 px-4 text-center text-sm font-medium transition-colors',
|
||||||
|
activeTab === 'search'
|
||||||
|
? 'bg-[#e6e6e6] dark:bg-[#2a2a2a] text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark'
|
||||||
|
: 'bg-[#f0f0f0] dark:bg-[#1e1e1e] text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-[#e6e6e6] dark:hover:bg-[#2a2a2a]/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
Search
|
Search
|
||||||
</TabButton>
|
</button>
|
||||||
<TabButton active={activeTab === 'url'} onClick={() => setActiveTab('url')}>
|
<button
|
||||||
<span className="i-ph:link" />
|
onClick={() => setActiveTab('url')}
|
||||||
URL
|
className={classNames(
|
||||||
</TabButton>
|
'flex-1 py-3 px-4 text-center text-sm font-medium transition-colors',
|
||||||
|
activeTab === 'url'
|
||||||
|
? 'bg-[#e6e6e6] dark:bg-[#2a2a2a] text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark'
|
||||||
|
: 'bg-[#f0f0f0] dark:bg-[#1e1e1e] text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-[#e6e6e6] dark:hover:bg-[#2a2a2a]/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
From URL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === 'url' ? (
|
{activeTab === 'url' ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
|
<div className="bg-gradient-to-br from-bolt-elements-background-depth-1 to-bolt-elements-background-depth-1 dark:from-bolt-elements-background-depth-2-dark dark:to-bolt-elements-background-depth-2-dark p-5 rounded-xl border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||||
|
<h3 className="text-base font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark mb-3 flex items-center gap-2">
|
||||||
|
<span className="i-ph:link-simple w-4 h-4 text-purple-500" />
|
||||||
|
Repository URL
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-purple-500">
|
||||||
|
<span className="i-ph:github-logo w-5 h-5" />
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter GitHub repository URL"
|
placeholder="Enter GitHub repository URL (e.g., https://github.com/user/repo)"
|
||||||
value={customUrl}
|
value={customUrl}
|
||||||
onChange={(e) => setCustomUrl(e.target.value)}
|
onChange={(e) => setCustomUrl(e.target.value)}
|
||||||
className="w-full"
|
className="w-full pl-10 py-3 border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="mt-3 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark bg-white/50 dark:bg-bolt-elements-background-depth-4/50 p-3 rounded-lg border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 backdrop-blur-sm">
|
||||||
|
<p className="flex items-start gap-2">
|
||||||
|
<span className="i-ph:info w-3.5 h-3.5 flex-shrink-0 mt-0.5 text-blue-500" />
|
||||||
|
<span>
|
||||||
|
You can paste any GitHub repository URL, including specific branches or tags.
|
||||||
|
<br />
|
||||||
|
<span className="text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||||
|
Example: https://github.com/username/repository/tree/branch-name
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||||
|
<div className="h-px flex-grow bg-bolt-elements-borderColor dark:bg-bolt-elements-borderColor-dark"></div>
|
||||||
|
<span>Ready to import?</span>
|
||||||
|
<div className="h-px flex-grow bg-bolt-elements-borderColor dark:bg-bolt-elements-borderColor-dark"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
onClick={handleImport}
|
onClick={handleImport}
|
||||||
disabled={!customUrl}
|
disabled={!customUrl}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'w-full h-10 px-4 py-2 rounded-lg text-white transition-all duration-200 flex items-center gap-2 justify-center',
|
'w-full h-12 px-4 py-2 rounded-xl text-white transition-all duration-200 flex items-center gap-2 justify-center',
|
||||||
customUrl
|
customUrl
|
||||||
? 'bg-purple-500 hover:bg-purple-600'
|
? 'bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 shadow-md'
|
||||||
: 'bg-gray-300 dark:bg-gray-700 cursor-not-allowed',
|
: 'bg-gray-300 dark:bg-gray-700 cursor-not-allowed',
|
||||||
)}
|
)}
|
||||||
|
whileHover={customUrl ? { scale: 1.02, boxShadow: '0 4px 12px rgba(124, 58, 237, 0.3)' } : {}}
|
||||||
|
whileTap={customUrl ? { scale: 0.98 } : {}}
|
||||||
>
|
>
|
||||||
|
<span className="i-ph:git-pull-request w-5 h-5" />
|
||||||
Import Repository
|
Import Repository
|
||||||
</button>
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{activeTab === 'search' && (
|
{activeTab === 'search' && (
|
||||||
<div className="space-y-4 mb-4">
|
<div className="space-y-5 mb-5">
|
||||||
|
<div className="bg-gradient-to-br from-blue-500/5 to-cyan-500/5 p-5 rounded-xl border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||||
|
<h3 className="text-base font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark mb-3 flex items-center gap-2">
|
||||||
|
<span className="i-ph:magnifying-glass w-4 h-4 text-blue-500" />
|
||||||
|
Search GitHub
|
||||||
|
</h3>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<div className="flex-1">
|
||||||
type="text"
|
<SearchInput
|
||||||
placeholder="Search repositories..."
|
placeholder="Search GitHub repositories..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearchQuery(e.target.value);
|
setSearchQuery(e.target.value);
|
||||||
|
|
||||||
|
if (e.target.value.length > 2) {
|
||||||
handleSearch(e.target.value);
|
handleSearch(e.target.value);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="flex-1 px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] text-bolt-elements-textPrimary"
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && searchQuery.length > 2) {
|
||||||
|
handleSearch(searchQuery);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClear={() => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setSearchResults([]);
|
||||||
|
}}
|
||||||
|
iconClassName="text-blue-500"
|
||||||
|
className="py-3 bg-white dark:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark focus:outline-none focus:ring-2 focus:ring-blue-500 shadow-sm"
|
||||||
|
loading={isLoading}
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
onClick={() => setFilters({})}
|
|
||||||
className="px-3 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
|
|
||||||
>
|
|
||||||
<span className="i-ph:funnel-simple" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<motion.button
|
||||||
|
onClick={() => setFilters({})}
|
||||||
|
className="px-3 py-2 rounded-lg bg-white dark:bg-bolt-elements-background-depth-4 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark shadow-sm"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
title="Clear filters"
|
||||||
|
>
|
||||||
|
<span className="i-ph:funnel-simple w-4 h-4" />
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
|
||||||
|
Filters
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active filters */}
|
||||||
|
{(filters.language || filters.stars || filters.forks) && (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
<AnimatePresence>
|
||||||
|
{filters.language && (
|
||||||
|
<FilterChip
|
||||||
|
label="Language"
|
||||||
|
value={filters.language}
|
||||||
|
icon="i-ph:code"
|
||||||
|
active
|
||||||
|
onRemove={() => {
|
||||||
|
const newFilters = { ...filters };
|
||||||
|
delete newFilters.language;
|
||||||
|
setFilters(newFilters);
|
||||||
|
|
||||||
|
if (searchQuery.length > 2) {
|
||||||
|
handleSearch(searchQuery);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{filters.stars && (
|
||||||
|
<FilterChip
|
||||||
|
label="Stars"
|
||||||
|
value={`>${filters.stars}`}
|
||||||
|
icon="i-ph:star"
|
||||||
|
active
|
||||||
|
onRemove={() => {
|
||||||
|
const newFilters = { ...filters };
|
||||||
|
delete newFilters.stars;
|
||||||
|
setFilters(newFilters);
|
||||||
|
|
||||||
|
if (searchQuery.length > 2) {
|
||||||
|
handleSearch(searchQuery);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{filters.forks && (
|
||||||
|
<FilterChip
|
||||||
|
label="Forks"
|
||||||
|
value={`>${filters.forks}`}
|
||||||
|
icon="i-ph:git-fork"
|
||||||
|
active
|
||||||
|
onRemove={() => {
|
||||||
|
const newFilters = { ...filters };
|
||||||
|
delete newFilters.forks;
|
||||||
|
setFilters(newFilters);
|
||||||
|
|
||||||
|
if (searchQuery.length > 2) {
|
||||||
|
handleSearch(searchQuery);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<div className="relative col-span-3 md:col-span-1">
|
||||||
|
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||||
|
<span className="i-ph:code w-3.5 h-3.5" />
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Filter by language..."
|
placeholder="Language (e.g., javascript)"
|
||||||
value={filters.language || ''}
|
value={filters.language || ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setFilters({ ...filters, language: e.target.value });
|
setFilters({ ...filters, language: e.target.value });
|
||||||
|
|
||||||
|
if (searchQuery.length > 2) {
|
||||||
handleSearch(searchQuery);
|
handleSearch(searchQuery);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
|
className="w-full pl-8 px-3 py-2 text-sm rounded-lg bg-white dark:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
placeholder="Min stars..."
|
|
||||||
value={filters.stars || ''}
|
|
||||||
onChange={(e) => handleFilterChange('stars', e.target.value)}
|
|
||||||
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||||
|
<span className="i-ph:star w-3.5 h-3.5" />
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Min forks..."
|
placeholder="Min stars"
|
||||||
|
value={filters.stars || ''}
|
||||||
|
onChange={(e) => handleFilterChange('stars', e.target.value)}
|
||||||
|
className="w-full pl-8 px-3 py-2 text-sm rounded-lg bg-white dark:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||||
|
<span className="i-ph:git-fork w-3.5 h-3.5" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="Min forks"
|
||||||
value={filters.forks || ''}
|
value={filters.forks || ''}
|
||||||
onChange={(e) => handleFilterChange('forks', e.target.value)}
|
onChange={(e) => handleFilterChange('forks', e.target.value)}
|
||||||
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
|
className="w-full pl-8 px-3 py-2 text-sm rounded-lg bg-white dark:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark bg-white/50 dark:bg-bolt-elements-background-depth-4/50 p-3 rounded-lg border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 backdrop-blur-sm">
|
||||||
|
<p className="flex items-start gap-2">
|
||||||
|
<span className="i-ph:info w-3.5 h-3.5 flex-shrink-0 mt-0.5 text-blue-500" />
|
||||||
|
<span>
|
||||||
|
Search for repositories by name, description, or topics. Use filters to narrow down
|
||||||
|
results.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
|
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
|
||||||
{selectedRepository ? (
|
{selectedRepository ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-5 bg-gradient-to-br from-purple-500/5 to-blue-500/5 p-5 rounded-xl border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between">
|
||||||
<button
|
<div className="flex items-center gap-3">
|
||||||
|
<motion.button
|
||||||
onClick={() => setSelectedRepository(null)}
|
onClick={() => setSelectedRepository(null)}
|
||||||
className="p-1.5 rounded-lg hover:bg-[#F5F5F5] dark:hover:bg-[#252525]"
|
className="p-2 rounded-lg hover:bg-white dark:hover:bg-bolt-elements-background-depth-4 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary shadow-sm"
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
>
|
>
|
||||||
<span className="i-ph:arrow-left w-4 h-4" />
|
<span className="i-ph:arrow-left w-4 h-4" />
|
||||||
</button>
|
</motion.button>
|
||||||
<h3 className="font-medium">{selectedRepository.full_name}</h3>
|
<div>
|
||||||
|
<h3 className="font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark text-lg">
|
||||||
|
{selectedRepository.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark flex items-center gap-1">
|
||||||
|
<span className="i-ph:user w-3 h-3" />
|
||||||
|
{selectedRepository.full_name.split('/')[0]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedRepository.private && (
|
||||||
|
<Badge variant="primary" size="md" icon="i-ph:lock w-3 h-3">
|
||||||
|
Private
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedRepository.description && (
|
||||||
|
<div className="bg-white/50 dark:bg-bolt-elements-background-depth-4/50 p-3 rounded-lg border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 backdrop-blur-sm">
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||||
|
{selectedRepository.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{selectedRepository.language && (
|
||||||
|
<Badge variant="subtle" size="md" icon="i-ph:code w-3 h-3">
|
||||||
|
{selectedRepository.language}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="subtle" size="md" icon="i-ph:star w-3 h-3">
|
||||||
|
{selectedRepository.stargazers_count.toLocaleString()}
|
||||||
|
</Badge>
|
||||||
|
{selectedRepository.forks_count > 0 && (
|
||||||
|
<Badge variant="subtle" size="md" icon="i-ph:git-fork w-3 h-3">
|
||||||
|
{selectedRepository.forks_count.toLocaleString()}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-3 border-t border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="i-ph:git-branch w-4 h-4 text-purple-500" />
|
||||||
|
<label className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||||
|
Select Branch
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm text-bolt-elements-textSecondary">Select Branch</label>
|
|
||||||
<select
|
<select
|
||||||
value={selectedBranch}
|
value={selectedBranch}
|
||||||
onChange={(e) => setSelectedBranch(e.target.value)}
|
onChange={(e) => setSelectedBranch(e.target.value)}
|
||||||
className="w-full px-3 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark"
|
className="w-full px-3 py-3 rounded-lg bg-white dark:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark focus:outline-none focus:ring-2 focus:ring-purple-500 shadow-sm"
|
||||||
>
|
>
|
||||||
{branches.map((branch) => (
|
{branches.map((branch) => (
|
||||||
<option
|
<option
|
||||||
key={branch.name}
|
key={branch.name}
|
||||||
value={branch.name}
|
value={branch.name}
|
||||||
className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
|
className="bg-white dark:bg-bolt-elements-background-depth-4 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
|
||||||
>
|
>
|
||||||
{branch.name} {branch.default ? '(default)' : ''}
|
{branch.name} {branch.default ? '(default)' : ''}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button
|
|
||||||
onClick={handleImport}
|
|
||||||
className="w-full h-10 px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 justify-center"
|
|
||||||
>
|
|
||||||
Import Selected Branch
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||||
|
<div className="h-px flex-grow bg-bolt-elements-borderColor/30 dark:bg-bolt-elements-borderColor-dark/30"></div>
|
||||||
|
<span>Ready to import?</span>
|
||||||
|
<div className="h-px flex-grow bg-bolt-elements-borderColor/30 dark:bg-bolt-elements-borderColor-dark/30"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
onClick={handleImport}
|
||||||
|
className="w-full h-12 px-4 py-2 rounded-xl bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white transition-all duration-200 flex items-center gap-2 justify-center shadow-md"
|
||||||
|
whileHover={{ scale: 1.02, boxShadow: '0 4px 12px rgba(124, 58, 237, 0.3)' }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<span className="i-ph:git-pull-request w-5 h-5" />
|
||||||
|
Import {selectedRepository.name}
|
||||||
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<RepositoryList
|
<RepositoryList
|
||||||
@@ -1001,91 +988,6 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
</>
|
</RepositoryDialogContext.Provider>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className={classNames(
|
|
||||||
'px-4 py-2 h-10 rounded-lg transition-all duration-200 flex items-center gap-2 min-w-[120px] justify-center',
|
|
||||||
active
|
|
||||||
? 'bg-purple-500 text-white hover:bg-purple-600'
|
|
||||||
: 'bg-[#F5F5F5] dark:bg-[#252525] text-bolt-elements-textPrimary dark:text-white hover:bg-[#E5E5E5] dark:hover:bg-[#333333] border border-[#E5E5E5] dark:border-[#333333]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RepositoryList({
|
|
||||||
repos,
|
|
||||||
isLoading,
|
|
||||||
onSelect,
|
|
||||||
activeTab,
|
|
||||||
}: {
|
|
||||||
repos: GitHubRepoInfo[];
|
|
||||||
isLoading: boolean;
|
|
||||||
onSelect: (repo: GitHubRepoInfo) => void;
|
|
||||||
activeTab: string;
|
|
||||||
}) {
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-8 text-bolt-elements-textSecondary">
|
|
||||||
<span className="i-ph:spinner animate-spin mr-2" />
|
|
||||||
Loading repositories...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (repos.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-bolt-elements-textSecondary">
|
|
||||||
<span className="i-ph:folder-simple-dashed w-12 h-12 mb-2 opacity-50" />
|
|
||||||
<p>{activeTab === 'my-repos' ? 'No repositories found' : 'Search for repositories'}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return repos.map((repo) => <RepositoryCard key={repo.full_name} repo={repo} onSelect={() => onSelect(repo)} />);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RepositoryCard({ repo, onSelect }: { repo: GitHubRepoInfo; onSelect: () => void }) {
|
|
||||||
return (
|
|
||||||
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 transition-colors">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="i-ph:git-branch text-bolt-elements-textTertiary" />
|
|
||||||
<h3 className="font-medium text-bolt-elements-textPrimary dark:text-white">{repo.name}</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onSelect}
|
|
||||||
className="px-4 py-2 h-10 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 min-w-[120px] justify-center"
|
|
||||||
>
|
|
||||||
<span className="i-ph:download-simple w-4 h-4" />
|
|
||||||
Import
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{repo.description && <p className="text-sm text-bolt-elements-textSecondary mb-3">{repo.description}</p>}
|
|
||||||
<div className="flex items-center gap-4 text-sm text-bolt-elements-textTertiary">
|
|
||||||
{repo.language && (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<span className="i-ph:code" />
|
|
||||||
{repo.language}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<span className="i-ph:star" />
|
|
||||||
{repo.stargazers_count.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<span className="i-ph:clock" />
|
|
||||||
{new Date(repo.updated_at).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import type { RepositoryStats } from '~/types/GitHub';
|
||||||
|
import { formatSize } from '~/utils/formatSize';
|
||||||
|
import { RepositoryStats as RepoStats } from '~/components/ui';
|
||||||
|
|
||||||
|
interface StatsDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
stats: RepositoryStats;
|
||||||
|
isLargeRepo?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsDialog({ isOpen, onClose, onConfirm, stats, isLargeRepo }: StatsDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="w-[90vw] md:w-[500px]"
|
||||||
|
>
|
||||||
|
<Dialog.Content className="bg-white dark:bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark shadow-xl">
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500">
|
||||||
|
<span className="i-ph:git-branch w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||||
|
Repository Overview
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||||
|
Review repository details before importing
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 p-4 rounded-lg">
|
||||||
|
<RepoStats stats={stats} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLargeRepo && (
|
||||||
|
<div className="p-3 bg-yellow-50 dark:bg-yellow-500/10 rounded-lg text-sm flex items-start gap-2">
|
||||||
|
<span className="i-ph:warning text-yellow-600 dark:text-yellow-500 w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="text-yellow-800 dark:text-yellow-500">
|
||||||
|
This repository is quite large ({formatSize(stats.totalSize)}). Importing it might take a while
|
||||||
|
and could impact performance.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark p-4 flex justify-end gap-3 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-b-lg">
|
||||||
|
<motion.button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 rounded-lg bg-bolt-elements-background-depth-3 dark:bg-bolt-elements-background-depth-4 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textSecondary-dark dark:hover:text-bolt-elements-textPrimary-dark transition-colors"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</motion.button>
|
||||||
|
<motion.button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-colors"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
Import Repository
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
|||||||
import { classNames } from '~/utils/classNames';
|
import { classNames } from '~/utils/classNames';
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-bolt-elements-ring focus:ring-offset-2',
|
'inline-flex items-center gap-1 transition-colors focus:outline-none focus:ring-2 focus:ring-bolt-elements-ring focus:ring-offset-2',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -15,18 +15,39 @@ const badgeVariants = cva(
|
|||||||
'border-transparent bg-bolt-elements-background text-bolt-elements-textSecondary hover:bg-bolt-elements-background/80',
|
'border-transparent bg-bolt-elements-background text-bolt-elements-textSecondary hover:bg-bolt-elements-background/80',
|
||||||
destructive: 'border-transparent bg-red-500/10 text-red-500 hover:bg-red-500/20',
|
destructive: 'border-transparent bg-red-500/10 text-red-500 hover:bg-red-500/20',
|
||||||
outline: 'text-bolt-elements-textPrimary',
|
outline: 'text-bolt-elements-textPrimary',
|
||||||
|
primary: 'bg-purple-500/10 text-purple-600 dark:text-purple-400',
|
||||||
|
success: 'bg-green-500/10 text-green-600 dark:text-green-400',
|
||||||
|
warning: 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400',
|
||||||
|
danger: 'bg-red-500/10 text-red-600 dark:text-red-400',
|
||||||
|
info: 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
|
||||||
|
subtle:
|
||||||
|
'border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 bg-white/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'rounded-full px-2.5 py-0.5 text-xs font-semibold',
|
||||||
|
sm: 'rounded-full px-1.5 py-0.5 text-xs',
|
||||||
|
md: 'rounded-md px-2 py-1 text-xs font-medium',
|
||||||
|
lg: 'rounded-md px-2.5 py-1.5 text-sm',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'default',
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
function Badge({ className, variant, size, icon, children, ...props }: BadgeProps) {
|
||||||
return <div className={classNames(badgeVariants({ variant }), className)} {...props} />;
|
return (
|
||||||
|
<div className={classNames(badgeVariants({ variant, size }), className)} {...props}>
|
||||||
|
{icon && <span className={icon} />}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants };
|
export { Badge, badgeVariants };
|
||||||
|
|||||||
101
app/components/ui/Breadcrumbs.tsx
Normal file
101
app/components/ui/Breadcrumbs.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { classNames } from '~/utils/classNames';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
interface BreadcrumbItem {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
icon?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BreadcrumbsProps {
|
||||||
|
items: BreadcrumbItem[];
|
||||||
|
className?: string;
|
||||||
|
separator?: string;
|
||||||
|
maxItems?: number;
|
||||||
|
renderItem?: (item: BreadcrumbItem, index: number, isLast: boolean) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Breadcrumbs({
|
||||||
|
items,
|
||||||
|
className,
|
||||||
|
separator = 'i-ph:caret-right',
|
||||||
|
maxItems = 0,
|
||||||
|
renderItem,
|
||||||
|
}: BreadcrumbsProps) {
|
||||||
|
const displayItems =
|
||||||
|
maxItems > 0 && items.length > maxItems
|
||||||
|
? [
|
||||||
|
...items.slice(0, 1),
|
||||||
|
{ label: '...', onClick: undefined, href: undefined },
|
||||||
|
...items.slice(-Math.max(1, maxItems - 2)),
|
||||||
|
]
|
||||||
|
: items;
|
||||||
|
|
||||||
|
const defaultRenderItem = (item: BreadcrumbItem, index: number, isLast: boolean) => {
|
||||||
|
const content = (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{item.icon && <span className={classNames(item.icon, 'w-3.5 h-3.5')} />}
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
isLast
|
||||||
|
? 'font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark'
|
||||||
|
: 'text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary-dark',
|
||||||
|
item.onClick || item.href ? 'cursor-pointer' : '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (item.href && !isLast) {
|
||||||
|
return (
|
||||||
|
<motion.a href={item.href} className="hover:underline" whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
||||||
|
{content}
|
||||||
|
</motion.a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.onClick && !isLast) {
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
onClick={item.onClick}
|
||||||
|
className="hover:underline"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className={classNames('flex items-center', className)} aria-label="Breadcrumbs">
|
||||||
|
<ol className="flex items-center gap-1.5">
|
||||||
|
{displayItems.map((item, index) => {
|
||||||
|
const isLast = index === displayItems.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={index} className="flex items-center">
|
||||||
|
{renderItem ? renderItem(item, index, isLast) : defaultRenderItem(item, index, isLast)}
|
||||||
|
{!isLast && (
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
separator,
|
||||||
|
'w-3 h-3 mx-1 text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
app/components/ui/CloseButton.tsx
Normal file
49
app/components/ui/CloseButton.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { classNames } from '~/utils/classNames';
|
||||||
|
|
||||||
|
interface CloseButtonProps {
|
||||||
|
onClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CloseButton component
|
||||||
|
*
|
||||||
|
* A button with an X icon used for closing dialogs, modals, etc.
|
||||||
|
* The button has a transparent background and only shows a background on hover.
|
||||||
|
*/
|
||||||
|
export function CloseButton({ onClick, className, size = 'md' }: CloseButtonProps) {
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'p-1',
|
||||||
|
md: 'p-2',
|
||||||
|
lg: 'p-3',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconSizeClasses = {
|
||||||
|
sm: 'w-3 h-3',
|
||||||
|
md: 'w-4 h-4',
|
||||||
|
lg: 'w-5 h-5',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={classNames(
|
||||||
|
'text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textSecondary-dark',
|
||||||
|
'rounded-lg hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3',
|
||||||
|
'transition-colors duration-200',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
|
||||||
|
sizeClasses[size],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<div className={classNames('i-ph:x', iconSizeClasses[size])} />
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
app/components/ui/CodeBlock.tsx
Normal file
103
app/components/ui/CodeBlock.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { classNames } from '~/utils/classNames';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { FileIcon } from './FileIcon';
|
||||||
|
import { Tooltip } from './Tooltip';
|
||||||
|
|
||||||
|
interface CodeBlockProps {
|
||||||
|
code: string;
|
||||||
|
language?: string;
|
||||||
|
filename?: string;
|
||||||
|
showLineNumbers?: boolean;
|
||||||
|
highlightLines?: number[];
|
||||||
|
maxHeight?: string;
|
||||||
|
className?: string;
|
||||||
|
onCopy?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeBlock({
|
||||||
|
code,
|
||||||
|
language,
|
||||||
|
filename,
|
||||||
|
showLineNumbers = true,
|
||||||
|
highlightLines = [],
|
||||||
|
maxHeight = '400px',
|
||||||
|
className,
|
||||||
|
onCopy,
|
||||||
|
}: CodeBlockProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
navigator.clipboard.writeText(code);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
onCopy?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = code.split('\n');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'rounded-lg overflow-hidden border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark',
|
||||||
|
'bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 bg-bolt-elements-background-depth-3 dark:bg-bolt-elements-background-depth-4 border-b border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{filename && (
|
||||||
|
<>
|
||||||
|
<FileIcon filename={filename} size="sm" />
|
||||||
|
<span className="text-xs font-medium text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||||
|
{filename}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{language && !filename && (
|
||||||
|
<span className="text-xs font-medium text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark uppercase">
|
||||||
|
{language}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Tooltip content={copied ? 'Copied!' : 'Copy code'}>
|
||||||
|
<motion.button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="p-1.5 rounded-md text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textSecondary-dark hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3 transition-colors"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
{copied ? <span className="i-ph:check w-4 h-4 text-green-500" /> : <span className="i-ph:copy w-4 h-4" />}
|
||||||
|
</motion.button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Code content */}
|
||||||
|
<div className={classNames('overflow-auto', 'font-mono text-sm', 'custom-scrollbar')} style={{ maxHeight }}>
|
||||||
|
<table className="min-w-full border-collapse">
|
||||||
|
<tbody>
|
||||||
|
{lines.map((line, index) => (
|
||||||
|
<tr
|
||||||
|
key={index}
|
||||||
|
className={classNames(
|
||||||
|
highlightLines.includes(index + 1) ? 'bg-purple-500/10 dark:bg-purple-500/20' : '',
|
||||||
|
'hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showLineNumbers && (
|
||||||
|
<td className="py-1 pl-4 pr-2 text-right select-none text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark border-r border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
|
||||||
|
<span className="inline-block min-w-[1.5rem] text-xs">{index + 1}</span>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td className="py-1 pl-4 pr-4 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark whitespace-pre">
|
||||||
|
{line || ' '}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
app/components/ui/EmptyState.tsx
Normal file
154
app/components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { classNames } from '~/utils/classNames';
|
||||||
|
import { Button } from './Button';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
// Variant-specific styles
|
||||||
|
const VARIANT_STYLES = {
|
||||||
|
default: {
|
||||||
|
container: 'py-8 p-6',
|
||||||
|
icon: {
|
||||||
|
container: 'w-12 h-12 mb-3',
|
||||||
|
size: 'w-6 h-6',
|
||||||
|
},
|
||||||
|
title: 'text-base',
|
||||||
|
description: 'text-sm mt-1',
|
||||||
|
actions: 'mt-4',
|
||||||
|
buttonSize: 'default' as const,
|
||||||
|
},
|
||||||
|
compact: {
|
||||||
|
container: 'py-4 p-4',
|
||||||
|
icon: {
|
||||||
|
container: 'w-10 h-10 mb-2',
|
||||||
|
size: 'w-5 h-5',
|
||||||
|
},
|
||||||
|
title: 'text-sm',
|
||||||
|
description: 'text-xs mt-0.5',
|
||||||
|
actions: 'mt-3',
|
||||||
|
buttonSize: 'sm' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
/** Icon class name */
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
/** Title text */
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
/** Optional description text */
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
/** Primary action button label */
|
||||||
|
actionLabel?: string;
|
||||||
|
|
||||||
|
/** Primary action button callback */
|
||||||
|
onAction?: () => void;
|
||||||
|
|
||||||
|
/** Secondary action button label */
|
||||||
|
secondaryActionLabel?: string;
|
||||||
|
|
||||||
|
/** Secondary action button callback */
|
||||||
|
onSecondaryAction?: () => void;
|
||||||
|
|
||||||
|
/** Additional class name */
|
||||||
|
className?: string;
|
||||||
|
|
||||||
|
/** Component size variant */
|
||||||
|
variant?: 'default' | 'compact';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EmptyState component
|
||||||
|
*
|
||||||
|
* A component for displaying empty states with optional actions.
|
||||||
|
*/
|
||||||
|
export function EmptyState({
|
||||||
|
icon = 'i-ph:folder-simple-dashed',
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
|
secondaryActionLabel,
|
||||||
|
onSecondaryAction,
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
// Get styles based on variant
|
||||||
|
const styles = VARIANT_STYLES[variant];
|
||||||
|
|
||||||
|
// Animation variants for buttons
|
||||||
|
const buttonAnimation = {
|
||||||
|
whileHover: { scale: 1.02 },
|
||||||
|
whileTap: { scale: 0.98 },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'flex flex-col items-center justify-center',
|
||||||
|
'text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark',
|
||||||
|
'bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg',
|
||||||
|
styles.container,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'rounded-full bg-bolt-elements-background-depth-3 dark:bg-bolt-elements-background-depth-4 flex items-center justify-center',
|
||||||
|
styles.icon.container,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
icon,
|
||||||
|
styles.icon.size,
|
||||||
|
'text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<p className={classNames('font-medium', styles.title)}>{title}</p>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{description && (
|
||||||
|
<p
|
||||||
|
className={classNames(
|
||||||
|
'text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark text-center max-w-xs',
|
||||||
|
styles.description,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
{(actionLabel || secondaryActionLabel) && (
|
||||||
|
<div className={classNames('flex items-center gap-2', styles.actions)}>
|
||||||
|
{actionLabel && onAction && (
|
||||||
|
<motion.div {...buttonAnimation}>
|
||||||
|
<Button
|
||||||
|
onClick={onAction}
|
||||||
|
variant="default"
|
||||||
|
size={styles.buttonSize}
|
||||||
|
className="bg-purple-500 hover:bg-purple-600 text-white"
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{secondaryActionLabel && onSecondaryAction && (
|
||||||
|
<motion.div {...buttonAnimation}>
|
||||||
|
<Button onClick={onSecondaryAction} variant="outline" size={styles.buttonSize}>
|
||||||
|
{secondaryActionLabel}
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
346
app/components/ui/FileIcon.tsx
Normal file
346
app/components/ui/FileIcon.tsx
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { classNames } from '~/utils/classNames';
|
||||||
|
|
||||||
|
interface FileIconProps {
|
||||||
|
filename: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileIcon({ filename, size = 'md', className }: FileIconProps) {
|
||||||
|
const getFileExtension = (filename: string): string => {
|
||||||
|
return filename.split('.').pop()?.toLowerCase() || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIconForExtension = (extension: string): string => {
|
||||||
|
// Code files
|
||||||
|
if (['js', 'jsx', 'ts', 'tsx'].includes(extension)) {
|
||||||
|
return 'i-ph:file-js';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['html', 'htm', 'xhtml'].includes(extension)) {
|
||||||
|
return 'i-ph:file-html';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['css', 'scss', 'sass', 'less'].includes(extension)) {
|
||||||
|
return 'i-ph:file-css';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['json', 'jsonc'].includes(extension)) {
|
||||||
|
return 'i-ph:brackets-curly';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['md', 'markdown'].includes(extension)) {
|
||||||
|
return 'i-ph:file-text';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['py', 'pyc', 'pyd', 'pyo'].includes(extension)) {
|
||||||
|
return 'i-ph:file-py';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['java', 'class', 'jar'].includes(extension)) {
|
||||||
|
return 'i-ph:file-java';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['php'].includes(extension)) {
|
||||||
|
return 'i-ph:file-php';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['rb', 'ruby'].includes(extension)) {
|
||||||
|
return 'i-ph:file-rs';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['c', 'cpp', 'h', 'hpp', 'cc'].includes(extension)) {
|
||||||
|
return 'i-ph:file-cpp';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['go'].includes(extension)) {
|
||||||
|
return 'i-ph:file-rs';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['rs', 'rust'].includes(extension)) {
|
||||||
|
return 'i-ph:file-rs';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['swift'].includes(extension)) {
|
||||||
|
return 'i-ph:file-swift';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['kt', 'kotlin'].includes(extension)) {
|
||||||
|
return 'i-ph:file-kotlin';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['dart'].includes(extension)) {
|
||||||
|
return 'i-ph:file-dart';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config files
|
||||||
|
if (['yml', 'yaml'].includes(extension)) {
|
||||||
|
return 'i-ph:file-cloud';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['xml', 'svg'].includes(extension)) {
|
||||||
|
return 'i-ph:file-xml';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['toml'].includes(extension)) {
|
||||||
|
return 'i-ph:file-text';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['ini', 'conf', 'config'].includes(extension)) {
|
||||||
|
return 'i-ph:file-text';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['env', 'env.local', 'env.development', 'env.production'].includes(extension)) {
|
||||||
|
return 'i-ph:file-lock';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document files
|
||||||
|
if (['pdf'].includes(extension)) {
|
||||||
|
return 'i-ph:file-pdf';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['doc', 'docx'].includes(extension)) {
|
||||||
|
return 'i-ph:file-doc';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['xls', 'xlsx'].includes(extension)) {
|
||||||
|
return 'i-ph:file-xls';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['ppt', 'pptx'].includes(extension)) {
|
||||||
|
return 'i-ph:file-ppt';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['txt'].includes(extension)) {
|
||||||
|
return 'i-ph:file-text';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image files
|
||||||
|
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'ico', 'tiff'].includes(extension)) {
|
||||||
|
return 'i-ph:file-image';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio/Video files
|
||||||
|
if (['mp3', 'wav', 'ogg', 'flac', 'aac'].includes(extension)) {
|
||||||
|
return 'i-ph:file-audio';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'mkv'].includes(extension)) {
|
||||||
|
return 'i-ph:file-video';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive files
|
||||||
|
if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(extension)) {
|
||||||
|
return 'i-ph:file-zip';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special files
|
||||||
|
if (filename === 'package.json') {
|
||||||
|
return 'i-ph:package';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filename === 'tsconfig.json') {
|
||||||
|
return 'i-ph:file-ts';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filename === 'README.md') {
|
||||||
|
return 'i-ph:book-open';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filename === 'LICENSE') {
|
||||||
|
return 'i-ph:scales';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filename === '.gitignore') {
|
||||||
|
return 'i-ph:git-branch';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filename.startsWith('Dockerfile')) {
|
||||||
|
return 'i-ph:docker-logo';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default
|
||||||
|
return 'i-ph:file';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIconColorForExtension = (extension: string): string => {
|
||||||
|
// Code files
|
||||||
|
if (['js', 'jsx'].includes(extension)) {
|
||||||
|
return 'text-yellow-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['ts', 'tsx'].includes(extension)) {
|
||||||
|
return 'text-blue-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['html', 'htm', 'xhtml'].includes(extension)) {
|
||||||
|
return 'text-orange-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['css', 'scss', 'sass', 'less'].includes(extension)) {
|
||||||
|
return 'text-blue-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['json', 'jsonc'].includes(extension)) {
|
||||||
|
return 'text-yellow-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['md', 'markdown'].includes(extension)) {
|
||||||
|
return 'text-gray-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['py', 'pyc', 'pyd', 'pyo'].includes(extension)) {
|
||||||
|
return 'text-green-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['java', 'class', 'jar'].includes(extension)) {
|
||||||
|
return 'text-red-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['php'].includes(extension)) {
|
||||||
|
return 'text-purple-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['rb', 'ruby'].includes(extension)) {
|
||||||
|
return 'text-red-600';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['c', 'cpp', 'h', 'hpp', 'cc'].includes(extension)) {
|
||||||
|
return 'text-blue-600';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['go'].includes(extension)) {
|
||||||
|
return 'text-cyan-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['rs', 'rust'].includes(extension)) {
|
||||||
|
return 'text-orange-600';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['swift'].includes(extension)) {
|
||||||
|
return 'text-orange-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['kt', 'kotlin'].includes(extension)) {
|
||||||
|
return 'text-purple-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['dart'].includes(extension)) {
|
||||||
|
return 'text-cyan-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config files
|
||||||
|
if (['yml', 'yaml'].includes(extension)) {
|
||||||
|
return 'text-purple-300';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['xml'].includes(extension)) {
|
||||||
|
return 'text-orange-300';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['svg'].includes(extension)) {
|
||||||
|
return 'text-green-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['toml'].includes(extension)) {
|
||||||
|
return 'text-gray-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['ini', 'conf', 'config'].includes(extension)) {
|
||||||
|
return 'text-gray-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['env', 'env.local', 'env.development', 'env.production'].includes(extension)) {
|
||||||
|
return 'text-green-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document files
|
||||||
|
if (['pdf'].includes(extension)) {
|
||||||
|
return 'text-red-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['doc', 'docx'].includes(extension)) {
|
||||||
|
return 'text-blue-600';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['xls', 'xlsx'].includes(extension)) {
|
||||||
|
return 'text-green-600';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['ppt', 'pptx'].includes(extension)) {
|
||||||
|
return 'text-red-600';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['txt'].includes(extension)) {
|
||||||
|
return 'text-gray-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image files
|
||||||
|
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'ico', 'tiff'].includes(extension)) {
|
||||||
|
return 'text-pink-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio/Video files
|
||||||
|
if (['mp3', 'wav', 'ogg', 'flac', 'aac'].includes(extension)) {
|
||||||
|
return 'text-green-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'mkv'].includes(extension)) {
|
||||||
|
return 'text-blue-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive files
|
||||||
|
if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(extension)) {
|
||||||
|
return 'text-yellow-600';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special files
|
||||||
|
if (filename === 'package.json') {
|
||||||
|
return 'text-red-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filename === 'tsconfig.json') {
|
||||||
|
return 'text-blue-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filename === 'README.md') {
|
||||||
|
return 'text-blue-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filename === 'LICENSE') {
|
||||||
|
return 'text-gray-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filename === '.gitignore') {
|
||||||
|
return 'text-orange-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filename.startsWith('Dockerfile')) {
|
||||||
|
return 'text-blue-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default
|
||||||
|
return 'text-gray-400';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSizeClass = (size: 'sm' | 'md' | 'lg'): string => {
|
||||||
|
switch (size) {
|
||||||
|
case 'sm':
|
||||||
|
return 'w-4 h-4';
|
||||||
|
case 'md':
|
||||||
|
return 'w-5 h-5';
|
||||||
|
case 'lg':
|
||||||
|
return 'w-6 h-6';
|
||||||
|
default:
|
||||||
|
return 'w-5 h-5';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const extension = getFileExtension(filename);
|
||||||
|
const icon = getIconForExtension(extension);
|
||||||
|
const color = getIconColorForExtension(extension);
|
||||||
|
const sizeClass = getSizeClass(size);
|
||||||
|
|
||||||
|
return <span className={classNames(icon, color, sizeClass, className)} />;
|
||||||
|
}
|
||||||
92
app/components/ui/FilterChip.tsx
Normal file
92
app/components/ui/FilterChip.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { classNames } from '~/utils/classNames';
|
||||||
|
|
||||||
|
interface FilterChipProps {
|
||||||
|
/** The label text to display */
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
/** Optional value to display after the label */
|
||||||
|
value?: string | number;
|
||||||
|
|
||||||
|
/** Function to call when the remove button is clicked */
|
||||||
|
onRemove?: () => void;
|
||||||
|
|
||||||
|
/** Whether the chip is active/selected */
|
||||||
|
active?: boolean;
|
||||||
|
|
||||||
|
/** Optional icon to display before the label */
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
/** Additional class name */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FilterChip component
|
||||||
|
*
|
||||||
|
* A chip component for displaying filters with optional remove button.
|
||||||
|
*/
|
||||||
|
export function FilterChip({ label, value, onRemove, active = false, icon, className }: FilterChipProps) {
|
||||||
|
// Animation variants
|
||||||
|
const variants = {
|
||||||
|
initial: { opacity: 0, scale: 0.9 },
|
||||||
|
animate: { opacity: 1, scale: 1 },
|
||||||
|
exit: { opacity: 0, scale: 0.9 },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
variants={variants}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className={classNames(
|
||||||
|
'inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all',
|
||||||
|
active
|
||||||
|
? 'bg-purple-500/15 text-purple-600 dark:text-purple-400 border border-purple-500/30'
|
||||||
|
: 'bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark',
|
||||||
|
onRemove && 'pr-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
{icon && <span className={classNames(icon, 'text-inherit')} />}
|
||||||
|
|
||||||
|
{/* Label and value */}
|
||||||
|
<span>
|
||||||
|
{label}
|
||||||
|
{value !== undefined && ': '}
|
||||||
|
{value !== undefined && (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? 'text-purple-700 dark:text-purple-300 font-semibold'
|
||||||
|
: 'text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Remove button */}
|
||||||
|
{onRemove && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
className={classNames(
|
||||||
|
'ml-1 p-0.5 rounded-full hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 transition-colors',
|
||||||
|
active
|
||||||
|
? 'text-purple-600 dark:text-purple-400'
|
||||||
|
: 'text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark',
|
||||||
|
)}
|
||||||
|
aria-label={`Remove ${label} filter`}
|
||||||
|
>
|
||||||
|
<span className="i-ph:x w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
app/components/ui/GradientCard.tsx
Normal file
100
app/components/ui/GradientCard.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { classNames } from '~/utils/classNames';
|
||||||
|
|
||||||
|
// Predefined gradient colors
|
||||||
|
const GRADIENT_COLORS = [
|
||||||
|
'from-purple-500/10 to-blue-500/5',
|
||||||
|
'from-blue-500/10 to-cyan-500/5',
|
||||||
|
'from-cyan-500/10 to-green-500/5',
|
||||||
|
'from-green-500/10 to-yellow-500/5',
|
||||||
|
'from-yellow-500/10 to-orange-500/5',
|
||||||
|
'from-orange-500/10 to-red-500/5',
|
||||||
|
'from-red-500/10 to-pink-500/5',
|
||||||
|
'from-pink-500/10 to-purple-500/5',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface GradientCardProps {
|
||||||
|
/** Custom gradient class (overrides seed-based gradient) */
|
||||||
|
gradient?: string;
|
||||||
|
|
||||||
|
/** Seed string to determine gradient color */
|
||||||
|
seed?: string;
|
||||||
|
|
||||||
|
/** Whether to apply hover animation effect */
|
||||||
|
hoverEffect?: boolean;
|
||||||
|
|
||||||
|
/** Whether to apply border effect */
|
||||||
|
borderEffect?: boolean;
|
||||||
|
|
||||||
|
/** Card content */
|
||||||
|
children: React.ReactNode;
|
||||||
|
|
||||||
|
/** Additional class name */
|
||||||
|
className?: string;
|
||||||
|
|
||||||
|
/** Additional props */
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GradientCard component
|
||||||
|
*
|
||||||
|
* A card with a gradient background that can be determined by a seed string.
|
||||||
|
*/
|
||||||
|
export function GradientCard({
|
||||||
|
gradient,
|
||||||
|
seed,
|
||||||
|
hoverEffect = true,
|
||||||
|
borderEffect = true,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: GradientCardProps) {
|
||||||
|
// Get gradient color based on seed or use provided gradient
|
||||||
|
const gradientClass = gradient || getGradientColorFromSeed(seed);
|
||||||
|
|
||||||
|
// Animation variants for hover effect
|
||||||
|
const hoverAnimation = hoverEffect
|
||||||
|
? {
|
||||||
|
whileHover: {
|
||||||
|
scale: 1.02,
|
||||||
|
y: -2,
|
||||||
|
transition: { type: 'spring', stiffness: 400, damping: 17 },
|
||||||
|
},
|
||||||
|
whileTap: { scale: 0.98 },
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={classNames(
|
||||||
|
'p-5 rounded-xl bg-gradient-to-br',
|
||||||
|
gradientClass,
|
||||||
|
borderEffect
|
||||||
|
? 'border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark hover:border-purple-500/40'
|
||||||
|
: '',
|
||||||
|
'transition-all duration-300 shadow-sm',
|
||||||
|
hoverEffect ? 'hover:shadow-md' : '',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...hoverAnimation}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate a gradient color based on the seed string for visual variety
|
||||||
|
*/
|
||||||
|
function getGradientColorFromSeed(seedString?: string): string {
|
||||||
|
if (!seedString) {
|
||||||
|
return GRADIENT_COLORS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = seedString.length % GRADIENT_COLORS.length;
|
||||||
|
|
||||||
|
return GRADIENT_COLORS[index];
|
||||||
|
}
|
||||||
87
app/components/ui/RepositoryStats.tsx
Normal file
87
app/components/ui/RepositoryStats.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Badge } from './Badge';
|
||||||
|
import { classNames } from '~/utils/classNames';
|
||||||
|
import { formatSize } from '~/utils/formatSize';
|
||||||
|
|
||||||
|
interface RepositoryStatsProps {
|
||||||
|
stats: {
|
||||||
|
totalFiles?: number;
|
||||||
|
totalSize?: number;
|
||||||
|
languages?: Record<string, number>;
|
||||||
|
hasPackageJson?: boolean;
|
||||||
|
hasDependencies?: boolean;
|
||||||
|
};
|
||||||
|
className?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepositoryStats({ stats, className, compact = false }: RepositoryStatsProps) {
|
||||||
|
const { totalFiles, totalSize, languages, hasPackageJson, hasDependencies } = stats;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames('space-y-3', className)}>
|
||||||
|
{!compact && (
|
||||||
|
<p className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||||
|
Repository Statistics:
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={classNames('grid gap-3', compact ? 'grid-cols-2' : 'grid-cols-2 md:grid-cols-3')}>
|
||||||
|
{totalFiles !== undefined && (
|
||||||
|
<div className="flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||||
|
<span className="i-ph:files text-purple-500 w-4 h-4" />
|
||||||
|
<span className={compact ? 'text-xs' : 'text-sm'}>Total Files: {totalFiles.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalSize !== undefined && (
|
||||||
|
<div className="flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
||||||
|
<span className="i-ph:database text-purple-500 w-4 h-4" />
|
||||||
|
<span className={compact ? 'text-xs' : 'text-sm'}>Total Size: {formatSize(totalSize)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{languages && Object.keys(languages).length > 0 && (
|
||||||
|
<div className={compact ? 'pt-1' : 'pt-2'}>
|
||||||
|
<div className="flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark mb-2">
|
||||||
|
<span className="i-ph:code text-purple-500 w-4 h-4" />
|
||||||
|
<span className={compact ? 'text-xs' : 'text-sm'}>Languages:</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.entries(languages)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.slice(0, compact ? 3 : 5)
|
||||||
|
.map(([lang, size]) => (
|
||||||
|
<Badge key={lang} variant="subtle" size={compact ? 'sm' : 'md'}>
|
||||||
|
{lang} ({formatSize(size)})
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{Object.keys(languages).length > (compact ? 3 : 5) && (
|
||||||
|
<Badge variant="subtle" size={compact ? 'sm' : 'md'}>
|
||||||
|
+{Object.keys(languages).length - (compact ? 3 : 5)} more
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(hasPackageJson || hasDependencies) && (
|
||||||
|
<div className={compact ? 'pt-1' : 'pt-2'}>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{hasPackageJson && (
|
||||||
|
<Badge variant="primary" size={compact ? 'sm' : 'md'} icon="i-ph:package w-3.5 h-3.5">
|
||||||
|
package.json
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{hasDependencies && (
|
||||||
|
<Badge variant="primary" size={compact ? 'sm' : 'md'} icon="i-ph:tree-structure w-3.5 h-3.5">
|
||||||
|
Dependencies
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
app/components/ui/SearchInput.tsx
Normal file
80
app/components/ui/SearchInput.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import React, { forwardRef } from 'react';
|
||||||
|
import { classNames } from '~/utils/classNames';
|
||||||
|
import { Input } from './Input';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
interface SearchInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
/** Function to call when the clear button is clicked */
|
||||||
|
onClear?: () => void;
|
||||||
|
|
||||||
|
/** Whether to show the clear button when there is input */
|
||||||
|
showClearButton?: boolean;
|
||||||
|
|
||||||
|
/** Additional class name for the search icon */
|
||||||
|
iconClassName?: string;
|
||||||
|
|
||||||
|
/** Additional class name for the container */
|
||||||
|
containerClassName?: string;
|
||||||
|
|
||||||
|
/** Whether the search is loading */
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SearchInput component
|
||||||
|
*
|
||||||
|
* A search input field with a search icon and optional clear button.
|
||||||
|
*/
|
||||||
|
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||||
|
(
|
||||||
|
{ className, onClear, showClearButton = true, iconClassName, containerClassName, loading = false, ...props },
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const hasValue = Boolean(props.value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames('relative flex items-center w-full', containerClassName)}>
|
||||||
|
{/* Search icon or loading spinner */}
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'absolute left-3 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary',
|
||||||
|
iconClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="i-ph:spinner-gap animate-spin w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<span className="i-ph:magnifying-glass w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input field */}
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
className={classNames('pl-10', hasValue && showClearButton ? 'pr-10' : '', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Clear button */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{hasValue && showClearButton && (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
type="button"
|
||||||
|
onClick={onClear}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary p-1 rounded-full hover:bg-bolt-elements-background-depth-2"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<span className="i-ph:x w-3.5 h-3.5" />
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
SearchInput.displayName = 'SearchInput';
|
||||||
134
app/components/ui/SearchResultItem.tsx
Normal file
134
app/components/ui/SearchResultItem.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { classNames } from '~/utils/classNames';
|
||||||
|
import { Badge } from './Badge';
|
||||||
|
|
||||||
|
interface SearchResultItemProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
iconBackground?: string;
|
||||||
|
iconColor?: string;
|
||||||
|
tags?: string[];
|
||||||
|
metadata?: Array<{
|
||||||
|
icon?: string;
|
||||||
|
label: string;
|
||||||
|
value?: string | number;
|
||||||
|
}>;
|
||||||
|
actionLabel?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
onClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchResultItem({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
iconBackground = 'bg-bolt-elements-background-depth-1/80 dark:bg-bolt-elements-background-depth-4/80',
|
||||||
|
iconColor = 'text-purple-500',
|
||||||
|
tags,
|
||||||
|
metadata,
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
}: SearchResultItemProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={classNames(
|
||||||
|
'p-5 rounded-xl border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark hover:border-purple-500/40 transition-all duration-300 shadow-sm hover:shadow-md bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-3/50',
|
||||||
|
onClick ? 'cursor-pointer' : '',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
whileHover={{
|
||||||
|
scale: 1.01,
|
||||||
|
y: -1,
|
||||||
|
transition: { type: 'spring', stiffness: 400, damping: 17 },
|
||||||
|
}}
|
||||||
|
whileTap={{ scale: 0.99 }}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3 gap-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{icon && (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'w-10 h-10 rounded-xl backdrop-blur-sm flex items-center justify-center shadow-sm',
|
||||||
|
iconBackground,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={classNames(icon, 'w-5 h-5', iconColor)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark text-base">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark flex items-center gap-1">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actionLabel && onAction && (
|
||||||
|
<motion.button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAction();
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 h-9 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 min-w-[100px] justify-center text-sm shadow-sm hover:shadow-md"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{description && (
|
||||||
|
<div className="mb-4 bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm p-3 rounded-lg border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30">
|
||||||
|
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark line-clamp-2">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tags && tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="subtle" size="sm">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{metadata && metadata.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
|
||||||
|
{metadata.map((item, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-1">
|
||||||
|
{item.icon && <span className={classNames(item.icon, 'w-3.5 h-3.5')} />}
|
||||||
|
<span>
|
||||||
|
{item.label}
|
||||||
|
{item.value !== undefined && ': '}
|
||||||
|
{item.value !== undefined && (
|
||||||
|
<span className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
app/components/ui/StatusIndicator.tsx
Normal file
90
app/components/ui/StatusIndicator.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { classNames } from '~/utils/classNames';
|
||||||
|
|
||||||
|
// Status types supported by the component
|
||||||
|
type StatusType = 'online' | 'offline' | 'away' | 'busy' | 'success' | 'warning' | 'error' | 'info' | 'loading';
|
||||||
|
|
||||||
|
// Size types for the indicator
|
||||||
|
type SizeType = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
|
// Status color mapping
|
||||||
|
const STATUS_COLORS: Record<StatusType, string> = {
|
||||||
|
online: 'bg-green-500',
|
||||||
|
success: 'bg-green-500',
|
||||||
|
offline: 'bg-red-500',
|
||||||
|
error: 'bg-red-500',
|
||||||
|
away: 'bg-yellow-500',
|
||||||
|
warning: 'bg-yellow-500',
|
||||||
|
busy: 'bg-red-500',
|
||||||
|
info: 'bg-blue-500',
|
||||||
|
loading: 'bg-purple-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Size class mapping
|
||||||
|
const SIZE_CLASSES: Record<SizeType, string> = {
|
||||||
|
sm: 'w-2 h-2',
|
||||||
|
md: 'w-3 h-3',
|
||||||
|
lg: 'w-4 h-4',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Text size mapping based on indicator size
|
||||||
|
const TEXT_SIZE_CLASSES: Record<SizeType, string> = {
|
||||||
|
sm: 'text-xs',
|
||||||
|
md: 'text-sm',
|
||||||
|
lg: 'text-base',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StatusIndicatorProps {
|
||||||
|
/** The status to display */
|
||||||
|
status: StatusType;
|
||||||
|
|
||||||
|
/** Size of the indicator */
|
||||||
|
size?: SizeType;
|
||||||
|
|
||||||
|
/** Whether to show a pulsing animation */
|
||||||
|
pulse?: boolean;
|
||||||
|
|
||||||
|
/** Optional label text */
|
||||||
|
label?: string;
|
||||||
|
|
||||||
|
/** Additional class name */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StatusIndicator component
|
||||||
|
*
|
||||||
|
* A component for displaying status indicators with optional labels and pulse animations.
|
||||||
|
*/
|
||||||
|
export function StatusIndicator({ status, size = 'md', pulse = false, label, className }: StatusIndicatorProps) {
|
||||||
|
// Get the color class for the status
|
||||||
|
const colorClass = STATUS_COLORS[status] || 'bg-gray-500';
|
||||||
|
|
||||||
|
// Get the size class for the indicator
|
||||||
|
const sizeClass = SIZE_CLASSES[size];
|
||||||
|
|
||||||
|
// Get the text size class for the label
|
||||||
|
const textSizeClass = TEXT_SIZE_CLASSES[size];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames('flex items-center gap-2', className)}>
|
||||||
|
{/* Status indicator dot */}
|
||||||
|
<span className={classNames('rounded-full relative', colorClass, sizeClass)}>
|
||||||
|
{/* Pulse animation */}
|
||||||
|
{pulse && <span className={classNames('absolute inset-0 rounded-full animate-ping opacity-75', colorClass)} />}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Optional label */}
|
||||||
|
{label && (
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
'text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark',
|
||||||
|
textSizeClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ const TabsList = React.forwardRef<
|
|||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'inline-flex h-10 items-center justify-center rounded-md bg-bolt-elements-background p-1 text-bolt-elements-textSecondary',
|
'inline-flex h-10 items-center justify-center rounded-md bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-3-dark p-1 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -26,7 +26,7 @@ const TabsTrigger = React.forwardRef<
|
|||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-bolt-elements-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bolt-elements-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-bolt-elements-background data-[state=active]:text-bolt-elements-textPrimary data-[state=active]:shadow-sm',
|
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-bolt-elements-background dark:ring-offset-bolt-elements-background-dark transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bolt-elements-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-bolt-elements-background-depth-0 dark:data-[state=active]:bg-bolt-elements-background-depth-2-dark data-[state=active]:text-bolt-elements-textPrimary dark:data-[state=active]:text-bolt-elements-textPrimary-dark data-[state=active]:shadow-sm',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
112
app/components/ui/TabsWithSlider.tsx
Normal file
112
app/components/ui/TabsWithSlider.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { classNames } from '~/utils/classNames';
|
||||||
|
|
||||||
|
interface Tab {
|
||||||
|
/** Unique identifier for the tab */
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** Content to display in the tab */
|
||||||
|
label: React.ReactNode;
|
||||||
|
|
||||||
|
/** Optional icon to display before the label */
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabsWithSliderProps {
|
||||||
|
/** Array of tab objects */
|
||||||
|
tabs: Tab[];
|
||||||
|
|
||||||
|
/** ID of the currently active tab */
|
||||||
|
activeTab: string;
|
||||||
|
|
||||||
|
/** Function called when a tab is clicked */
|
||||||
|
onChange: (tabId: string) => void;
|
||||||
|
|
||||||
|
/** Additional class name for the container */
|
||||||
|
className?: string;
|
||||||
|
|
||||||
|
/** Additional class name for inactive tabs */
|
||||||
|
tabClassName?: string;
|
||||||
|
|
||||||
|
/** Additional class name for the active tab */
|
||||||
|
activeTabClassName?: string;
|
||||||
|
|
||||||
|
/** Additional class name for the slider */
|
||||||
|
sliderClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TabsWithSlider component
|
||||||
|
*
|
||||||
|
* A tabs component with an animated slider that moves to the active tab.
|
||||||
|
*/
|
||||||
|
export function TabsWithSlider({
|
||||||
|
tabs,
|
||||||
|
activeTab,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
tabClassName,
|
||||||
|
activeTabClassName,
|
||||||
|
sliderClassName,
|
||||||
|
}: TabsWithSliderProps) {
|
||||||
|
// State for slider dimensions
|
||||||
|
const [sliderDimensions, setSliderDimensions] = useState({ width: 0, left: 0 });
|
||||||
|
|
||||||
|
// Refs for tab elements
|
||||||
|
const tabsRef = useRef<(HTMLButtonElement | null)[]>([]);
|
||||||
|
|
||||||
|
// Update slider position when active tab changes
|
||||||
|
useEffect(() => {
|
||||||
|
const activeIndex = tabs.findIndex((tab) => tab.id === activeTab);
|
||||||
|
|
||||||
|
if (activeIndex !== -1 && tabsRef.current[activeIndex]) {
|
||||||
|
const activeTabElement = tabsRef.current[activeIndex];
|
||||||
|
|
||||||
|
if (activeTabElement) {
|
||||||
|
setSliderDimensions({
|
||||||
|
width: activeTabElement.offsetWidth,
|
||||||
|
left: activeTabElement.offsetLeft,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeTab, tabs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames('relative flex gap-2', className)}>
|
||||||
|
{/* Tab buttons */}
|
||||||
|
{tabs.map((tab, index) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
ref={(el) => (tabsRef.current[index] = el)}
|
||||||
|
onClick={() => onChange(tab.id)}
|
||||||
|
className={classNames(
|
||||||
|
'px-4 py-2 h-10 rounded-lg transition-all duration-200 flex items-center gap-2 min-w-[120px] justify-center relative overflow-hidden',
|
||||||
|
tab.id === activeTab
|
||||||
|
? classNames('text-white shadow-sm shadow-purple-500/20', activeTabClassName)
|
||||||
|
: classNames(
|
||||||
|
'bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark',
|
||||||
|
tabClassName,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={classNames('flex items-center gap-2', tab.id === activeTab ? 'font-medium' : '')}>
|
||||||
|
{tab.icon && <span className={tab.icon} />}
|
||||||
|
{tab.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Animated slider */}
|
||||||
|
<motion.div
|
||||||
|
className={classNames('absolute bottom-0 left-0 h-10 rounded-lg bg-purple-500 -z-10', sliderClassName)}
|
||||||
|
initial={false}
|
||||||
|
animate={{
|
||||||
|
width: sliderDimensions.width,
|
||||||
|
x: sliderDimensions.left,
|
||||||
|
}}
|
||||||
|
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||||
import { forwardRef, type ForwardedRef, type ReactElement } from 'react';
|
import { forwardRef, type ForwardedRef, type ReactElement } from 'react';
|
||||||
|
import { classNames } from '~/utils/classNames';
|
||||||
|
|
||||||
interface TooltipProps {
|
// Original WithTooltip component
|
||||||
|
interface WithTooltipProps {
|
||||||
tooltip: React.ReactNode;
|
tooltip: React.ReactNode;
|
||||||
children: ReactElement;
|
children: ReactElement;
|
||||||
sideOffset?: number;
|
sideOffset?: number;
|
||||||
@@ -25,14 +27,15 @@ const WithTooltip = forwardRef(
|
|||||||
position = 'top',
|
position = 'top',
|
||||||
maxWidth = 250,
|
maxWidth = 250,
|
||||||
delay = 0,
|
delay = 0,
|
||||||
}: TooltipProps,
|
}: WithTooltipProps,
|
||||||
_ref: ForwardedRef<HTMLElement>,
|
_ref: ForwardedRef<HTMLElement>,
|
||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
<Tooltip.Root delayDuration={delay}>
|
<TooltipPrimitive.Provider>
|
||||||
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
|
<TooltipPrimitive.Root delayDuration={delay}>
|
||||||
<Tooltip.Portal>
|
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
||||||
<Tooltip.Content
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
side={position}
|
side={position}
|
||||||
className={`
|
className={`
|
||||||
z-[2000]
|
z-[2000]
|
||||||
@@ -61,7 +64,7 @@ const WithTooltip = forwardRef(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="break-words">{tooltip}</div>
|
<div className="break-words">{tooltip}</div>
|
||||||
<Tooltip.Arrow
|
<TooltipPrimitive.Arrow
|
||||||
className={`
|
className={`
|
||||||
fill-bolt-elements-background-depth-3
|
fill-bolt-elements-background-depth-3
|
||||||
${arrowClassName}
|
${arrowClassName}
|
||||||
@@ -69,11 +72,51 @@ const WithTooltip = forwardRef(
|
|||||||
width={12}
|
width={12}
|
||||||
height={6}
|
height={6}
|
||||||
/>
|
/>
|
||||||
</Tooltip.Content>
|
</TooltipPrimitive.Content>
|
||||||
</Tooltip.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
</Tooltip.Root>
|
</TooltipPrimitive.Root>
|
||||||
|
</TooltipPrimitive.Provider>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// New Tooltip component with simpler API
|
||||||
|
interface TooltipProps {
|
||||||
|
content: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||||
|
align?: 'start' | 'center' | 'end';
|
||||||
|
delayDuration?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tooltip({
|
||||||
|
content,
|
||||||
|
children,
|
||||||
|
side = 'top',
|
||||||
|
align = 'center',
|
||||||
|
delayDuration = 300,
|
||||||
|
className,
|
||||||
|
}: TooltipProps) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider>
|
||||||
|
<TooltipPrimitive.Root delayDuration={delayDuration}>
|
||||||
|
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
side={side}
|
||||||
|
align={align}
|
||||||
|
className={classNames(
|
||||||
|
'z-50 overflow-hidden rounded-md bg-bolt-elements-background-depth-3 dark:bg-bolt-elements-background-depth-4 px-3 py-1.5 text-xs text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
sideOffset={5}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
<TooltipPrimitive.Arrow className="fill-bolt-elements-background-depth-3 dark:fill-bolt-elements-background-depth-4" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Root>
|
||||||
|
</TooltipPrimitive.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default WithTooltip;
|
export default WithTooltip;
|
||||||
|
|||||||
38
app/components/ui/index.ts
Normal file
38
app/components/ui/index.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Export all UI components for easier imports
|
||||||
|
|
||||||
|
// Core components
|
||||||
|
export * from './Badge';
|
||||||
|
export * from './Button';
|
||||||
|
export * from './Card';
|
||||||
|
export * from './Checkbox';
|
||||||
|
export * from './Collapsible';
|
||||||
|
export * from './Dialog';
|
||||||
|
export * from './IconButton';
|
||||||
|
export * from './Input';
|
||||||
|
export * from './Label';
|
||||||
|
export * from './ScrollArea';
|
||||||
|
export * from './Switch';
|
||||||
|
export * from './Tabs';
|
||||||
|
export * from './ThemeSwitch';
|
||||||
|
|
||||||
|
// Loading components
|
||||||
|
export * from './LoadingDots';
|
||||||
|
export * from './LoadingOverlay';
|
||||||
|
|
||||||
|
// New components
|
||||||
|
export * from './Breadcrumbs';
|
||||||
|
export * from './CloseButton';
|
||||||
|
export * from './CodeBlock';
|
||||||
|
export * from './EmptyState';
|
||||||
|
export * from './FileIcon';
|
||||||
|
export * from './FilterChip';
|
||||||
|
export * from './GradientCard';
|
||||||
|
export * from './RepositoryStats';
|
||||||
|
export * from './SearchInput';
|
||||||
|
export * from './SearchResultItem';
|
||||||
|
export * from './StatusIndicator';
|
||||||
|
export * from './TabsWithSlider';
|
||||||
|
|
||||||
|
// Tooltip components
|
||||||
|
export { default as WithTooltip } from './Tooltip';
|
||||||
|
export { Tooltip } from './Tooltip';
|
||||||
@@ -43,9 +43,9 @@ export function useGit() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const gitClone = useCallback(
|
const gitClone = useCallback(
|
||||||
async (url: string) => {
|
async (url: string, retryCount = 0) => {
|
||||||
if (!webcontainer || !fs || !ready) {
|
if (!webcontainer || !fs || !ready) {
|
||||||
throw 'Webcontainer not initialized';
|
throw new Error('Webcontainer not initialized. Please try again later.');
|
||||||
}
|
}
|
||||||
|
|
||||||
fileData.current = {};
|
fileData.current = {};
|
||||||
@@ -68,6 +68,12 @@ export function useGit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Add a small delay before retrying to allow for network recovery
|
||||||
|
if (retryCount > 0) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000 * retryCount));
|
||||||
|
console.log(`Retrying git clone (attempt ${retryCount + 1})...`);
|
||||||
|
}
|
||||||
|
|
||||||
await git.clone({
|
await git.clone({
|
||||||
fs,
|
fs,
|
||||||
http,
|
http,
|
||||||
@@ -90,10 +96,10 @@ export function useGit() {
|
|||||||
|
|
||||||
console.log('Repository requires authentication:', url);
|
console.log('Repository requires authentication:', url);
|
||||||
|
|
||||||
if (confirm('This repo is password protected. Ready to enter a username & password?')) {
|
if (confirm('This repository requires authentication. Would you like to enter your GitHub credentials?')) {
|
||||||
auth = {
|
auth = {
|
||||||
username: prompt('Enter username') || '',
|
username: prompt('Enter username') || '',
|
||||||
password: prompt('Enter password') || '',
|
password: prompt('Enter password or personal access token') || '',
|
||||||
};
|
};
|
||||||
return auth;
|
return auth;
|
||||||
} else {
|
} else {
|
||||||
@@ -102,8 +108,10 @@ export function useGit() {
|
|||||||
},
|
},
|
||||||
onAuthFailure: (url, _auth) => {
|
onAuthFailure: (url, _auth) => {
|
||||||
console.error(`Authentication failed for ${url}`);
|
console.error(`Authentication failed for ${url}`);
|
||||||
toast.error(`Error Authenticating with ${url.split('/')[2]}`);
|
toast.error(`Authentication failed for ${url.split('/')[2]}. Please check your credentials and try again.`);
|
||||||
throw `Error Authenticating with ${url.split('/')[2]}`;
|
throw new Error(
|
||||||
|
`Authentication failed for ${url.split('/')[2]}. Please check your credentials and try again.`,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onAuthSuccess: (url, auth) => {
|
onAuthSuccess: (url, auth) => {
|
||||||
console.log(`Authentication successful for ${url}`);
|
console.log(`Authentication successful for ${url}`);
|
||||||
@@ -121,8 +129,40 @@ export function useGit() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Git clone error:', error);
|
console.error('Git clone error:', error);
|
||||||
|
|
||||||
// toast.error(`Git clone error ${(error as any).message||""}`);
|
// Handle specific error types
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
// Check for common error patterns
|
||||||
|
if (errorMessage.includes('Authentication failed')) {
|
||||||
|
toast.error(`Authentication failed. Please check your GitHub credentials and try again.`);
|
||||||
throw error;
|
throw error;
|
||||||
|
} else if (
|
||||||
|
errorMessage.includes('ENOTFOUND') ||
|
||||||
|
errorMessage.includes('ETIMEDOUT') ||
|
||||||
|
errorMessage.includes('ECONNREFUSED')
|
||||||
|
) {
|
||||||
|
toast.error(`Network error while connecting to repository. Please check your internet connection.`);
|
||||||
|
|
||||||
|
// Retry for network errors, up to 3 times
|
||||||
|
if (retryCount < 3) {
|
||||||
|
return gitClone(url, retryCount + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Failed to connect to repository after multiple attempts. Please check your internet connection.`,
|
||||||
|
);
|
||||||
|
} else if (errorMessage.includes('404')) {
|
||||||
|
toast.error(`Repository not found. Please check the URL and make sure the repository exists.`);
|
||||||
|
throw new Error(`Repository not found. Please check the URL and make sure the repository exists.`);
|
||||||
|
} else if (errorMessage.includes('401')) {
|
||||||
|
toast.error(`Unauthorized access to repository. Please connect your GitHub account with proper permissions.`);
|
||||||
|
throw new Error(
|
||||||
|
`Unauthorized access to repository. Please connect your GitHub account with proper permissions.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(`Failed to clone repository: ${errorMessage}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[webcontainer, fs, ready],
|
[webcontainer, fs, ready],
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export interface GitHubRepoInfo {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
language: string;
|
language: string;
|
||||||
languages_url: string;
|
languages_url: string;
|
||||||
|
private?: boolean;
|
||||||
|
topics?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitHubContent {
|
export interface GitHubContent {
|
||||||
|
|||||||
@@ -149,6 +149,7 @@
|
|||||||
"shiki": "^1.24.0",
|
"shiki": "^1.24.0",
|
||||||
"tailwind-merge": "^2.2.1",
|
"tailwind-merge": "^2.2.1",
|
||||||
"unist-util-visit": "^5.0.0",
|
"unist-util-visit": "^5.0.0",
|
||||||
|
"use-debounce": "^10.0.4",
|
||||||
"vite-plugin-node-polyfills": "^0.22.0",
|
"vite-plugin-node-polyfills": "^0.22.0",
|
||||||
"zod": "^3.24.1",
|
"zod": "^3.24.1",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
|
|||||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -326,6 +326,9 @@ importers:
|
|||||||
unist-util-visit:
|
unist-util-visit:
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.0.0
|
version: 5.0.0
|
||||||
|
use-debounce:
|
||||||
|
specifier: ^10.0.4
|
||||||
|
version: 10.0.4(react@18.3.1)
|
||||||
vite-plugin-node-polyfills:
|
vite-plugin-node-polyfills:
|
||||||
specifier: ^0.22.0
|
specifier: ^0.22.0
|
||||||
version: 0.22.0(rollup@4.38.0)(vite@5.4.15(@types/node@22.13.14)(sass-embedded@1.86.0))
|
version: 0.22.0(rollup@4.38.0)(vite@5.4.15(@types/node@22.13.14)(sass-embedded@1.86.0))
|
||||||
@@ -7743,6 +7746,12 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
use-debounce@10.0.4:
|
||||||
|
resolution: {integrity: sha512-6Cf7Yr7Wk7Kdv77nnJMf6de4HuDE4dTxKij+RqE9rufDsI6zsbjyAxcH5y2ueJCQAnfgKbzXbZHYlkFwmBlWkw==}
|
||||||
|
engines: {node: '>= 16.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '*'
|
||||||
|
|
||||||
use-memo-one@1.1.3:
|
use-memo-one@1.1.3:
|
||||||
resolution: {integrity: sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==}
|
resolution: {integrity: sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -16864,6 +16873,10 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 18.3.20
|
'@types/react': 18.3.20
|
||||||
|
|
||||||
|
use-debounce@10.0.4(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
|
||||||
use-memo-one@1.1.3(react@18.3.1):
|
use-memo-one@1.1.3(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
|
|||||||
Reference in New Issue
Block a user