Revert "fix: resolve chat conversation hanging and stream interruption issues (#1971)"

This reverts commit e68593f22d.
This commit is contained in:
Stijnus
2025-09-07 00:14:13 +02:00
committed by Stijnus
parent e68593f22d
commit 37217a5c7b
61 changed files with 1432 additions and 8811 deletions

View File

@@ -1,173 +1,28 @@
import { json, type MetaFunction } from '@remix-run/cloudflare';
import { useLoaderData } from '@remix-run/react';
import { ClientOnly } from 'remix-utils/client-only';
import { BaseChat } from '~/components/chat/BaseChat';
import { Chat } from '~/components/chat/Chat.client';
import { Header } from '~/components/header/Header';
import BackgroundRays from '~/components/ui/BackgroundRays';
import { useEffect, useState } from 'react';
import { providersStore } from '~/lib/stores/settings';
import { authStore } from '~/lib/stores/auth';
import { useNavigate } from '@remix-run/react';
import { motion, AnimatePresence } from 'framer-motion';
export const meta: MetaFunction = () => {
return [
{ title: 'Bolt.gives' },
{
name: 'description',
content: 'Build web applications with AI assistance - Enhanced fork with advanced features',
},
];
return [{ title: 'Bolt' }, { name: 'description', content: 'Talk with Bolt, an AI assistant from StackBlitz' }];
};
export const loader = ({ context }: { context: any }) => {
// Check which local providers are configured
const configuredProviders: string[] = [];
// Check Ollama
if (context.cloudflare?.env?.OLLAMA_API_BASE_URL || process.env?.OLLAMA_API_BASE_URL) {
configuredProviders.push('Ollama');
}
// Check LMStudio
if (context.cloudflare?.env?.LMSTUDIO_API_BASE_URL || process.env?.LMSTUDIO_API_BASE_URL) {
configuredProviders.push('LMStudio');
}
// Check OpenAILike
if (context.cloudflare?.env?.OPENAI_LIKE_API_BASE_URL || process.env?.OPENAI_LIKE_API_BASE_URL) {
configuredProviders.push('OpenAILike');
}
return json({ configuredProviders });
};
export const loader = () => json({});
/**
* Landing page component for Bolt.gives
* Enhanced fork with multi-user authentication, advanced features, and provider auto-detection
* Landing page component for Bolt
* Note: Settings functionality should ONLY be accessed through the sidebar menu.
* Do not add settings button/panel to this landing page as it was intentionally removed
* to keep the UI clean and consistent with the design system.
*/
export default function Index() {
const data = useLoaderData<{ configuredProviders: string[] }>();
const [showMultiUserBanner, setShowMultiUserBanner] = useState(false);
const navigate = useNavigate();
useEffect(() => {
// Enable configured providers if they haven't been manually configured yet
if (data?.configuredProviders && data.configuredProviders.length > 0) {
const savedSettings = localStorage.getItem('provider_settings');
if (!savedSettings) {
// No saved settings, so enable the configured providers
const currentProviders = providersStore.get();
data.configuredProviders.forEach((providerName) => {
if (currentProviders[providerName]) {
providersStore.setKey(providerName, {
...currentProviders[providerName],
settings: {
...currentProviders[providerName].settings,
enabled: true,
},
});
}
});
// Save to localStorage so this only happens once
localStorage.setItem('provider_settings', JSON.stringify(providersStore.get()));
}
}
}, [data?.configuredProviders]);
useEffect(() => {
// Check if user is authenticated
const authState = authStore.get();
// Show banner only if not authenticated and hasn't been dismissed
const bannerDismissed = localStorage.getItem('multiUserBannerDismissed');
if (!authState.isAuthenticated && !bannerDismissed) {
setTimeout(() => setShowMultiUserBanner(true), 2000);
}
}, []);
const handleActivateMultiUser = () => {
navigate('/auth');
};
const handleDismissBanner = () => {
setShowMultiUserBanner(false);
localStorage.setItem('multiUserBannerDismissed', 'true');
};
return (
<div className="flex flex-col h-full w-full bg-bolt-elements-background-depth-1">
<BackgroundRays />
<Header />
<ClientOnly fallback={<BaseChat />}>{() => <Chat />}</ClientOnly>
{/* Optional Multi-User Activation Banner */}
<AnimatePresence>
{showMultiUserBanner && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.3 }}
className="fixed bottom-6 right-6 max-w-sm z-50"
>
<div className="bg-bolt-elements-background-depth-2 backdrop-blur-xl rounded-xl border border-bolt-elements-borderColor shadow-2xl p-4">
<button
onClick={handleDismissBanner}
className="absolute top-2 right-2 text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary transition-colors"
aria-label="Close"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<div className="w-10 h-10 rounded-lg bg-gradient-to-r from-blue-500 to-purple-600 flex items-center justify-center shadow-lg">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
</div>
</div>
<div className="flex-1">
<h3 className="text-sm font-semibold text-bolt-elements-textPrimary mb-1">
Unlock Multi-User Features
</h3>
<p className="text-xs text-bolt-elements-textSecondary mb-3">
Save your projects, personalized settings, and collaborate with workspace isolation.
</p>
<div className="flex gap-2">
<button
onClick={handleActivateMultiUser}
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-gradient-to-r from-blue-500 to-purple-600 text-white hover:from-blue-600 hover:to-purple-700 transition-all shadow-lg hover:shadow-xl"
>
Activate Now
</button>
<button
onClick={handleDismissBanner}
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-bolt-elements-button-secondary-background text-bolt-elements-button-secondary-text hover:bg-bolt-elements-button-secondary-backgroundHover transition-all"
>
Continue as Guest
</button>
</div>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -1,351 +0,0 @@
import { useState, useEffect } from 'react';
import { useNavigate } from '@remix-run/react';
import { motion, AnimatePresence } from 'framer-motion';
import { useStore } from '@nanostores/react';
import { authStore } from '~/lib/stores/auth';
import { ProtectedRoute } from '~/components/auth/ProtectedRoute';
import { classNames } from '~/utils/classNames';
interface User {
id: string;
username: string;
firstName: string;
avatar?: string;
createdAt: string;
lastLogin?: string;
}
export default function UserManagement() {
const navigate = useNavigate();
const authState = useStore(authStore);
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
try {
const response = await fetch('/api/users', {
headers: {
Authorization: `Bearer ${authState.token}`,
},
});
if (response.ok) {
const data = (await response.json()) as { users: User[] };
setUsers(data.users);
}
} catch (error) {
console.error('Failed to fetch users:', error);
} finally {
setLoading(false);
}
};
const handleDeleteUser = async () => {
if (!selectedUser) {
return;
}
setDeleting(true);
try {
const response = await fetch(`/api/users/${selectedUser.id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${authState.token}`,
},
});
if (response.ok) {
setUsers(users.filter((u) => u.id !== selectedUser.id));
setShowDeleteModal(false);
setSelectedUser(null);
}
} catch (error) {
console.error('Failed to delete user:', error);
} finally {
setDeleting(false);
}
};
const filteredUsers = users.filter(
(user) =>
user.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.firstName.toLowerCase().includes(searchQuery.toLowerCase()),
);
return (
<ProtectedRoute>
<div className="min-h-screen bg-bolt-elements-background-depth-1">
{/* Header */}
<header className="border-b border-bolt-elements-borderColor bg-bolt-elements-background-depth-2">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/')}
className="p-2 rounded-lg hover:bg-bolt-elements-background-depth-3 transition-colors"
>
<span className="i-ph:arrow-left text-xl text-bolt-elements-textPrimary" />
</button>
<div>
<h1 className="text-xl font-bold text-bolt-elements-textPrimary">User Management</h1>
<p className="text-sm text-bolt-elements-textSecondary">Manage system users</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="relative">
<input
type="text"
placeholder="Search users..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={classNames(
'w-64 px-4 py-2 pl-10 rounded-lg',
'bg-bolt-elements-background-depth-1',
'border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-accent-500',
)}
/>
<span className="absolute left-3 top-2.5 i-ph:magnifying-glass text-bolt-elements-textTertiary" />
</div>
<button
onClick={() => navigate('/auth')}
className={classNames(
'px-4 py-2 rounded-lg',
'bg-accent-500 text-white',
'hover:bg-accent-600',
'transition-colors',
'flex items-center gap-2',
)}
>
<span className="i-ph:plus text-lg" />
<span>Add User</span>
</button>
</div>
</div>
</div>
</header>
{/* User Stats */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 border border-bolt-elements-borderColor">
<p className="text-sm text-bolt-elements-textSecondary mb-1">Total Users</p>
<p className="text-2xl font-bold text-bolt-elements-textPrimary">{users.length}</p>
</div>
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 border border-bolt-elements-borderColor">
<p className="text-sm text-bolt-elements-textSecondary mb-1">Active Today</p>
<p className="text-2xl font-bold text-green-500">
{
users.filter((u) => {
if (!u.lastLogin) {
return false;
}
const lastLogin = new Date(u.lastLogin);
const today = new Date();
return lastLogin.toDateString() === today.toDateString();
}).length
}
</p>
</div>
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 border border-bolt-elements-borderColor">
<p className="text-sm text-bolt-elements-textSecondary mb-1">New This Week</p>
<p className="text-2xl font-bold text-blue-500">
{
users.filter((u) => {
const created = new Date(u.createdAt);
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
return created > weekAgo;
}).length
}
</p>
</div>
<div className="bg-bolt-elements-background-depth-2 rounded-lg p-4 border border-bolt-elements-borderColor">
<p className="text-sm text-bolt-elements-textSecondary mb-1">Storage Used</p>
<p className="text-2xl font-bold text-bolt-elements-textPrimary">0 MB</p>
</div>
</div>
</div>
{/* User List */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8">
{loading ? (
<div className="flex justify-center py-12">
<span className="i-svg-spinners:3-dots-scale text-2xl text-bolt-elements-textPrimary" />
</div>
) : filteredUsers.length === 0 ? (
<div className="text-center py-12">
<span className="i-ph:users text-4xl text-bolt-elements-textTertiary mb-4" />
<p className="text-bolt-elements-textSecondary">
{searchQuery ? 'No users found matching your search' : 'No users yet'}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<AnimatePresence>
{filteredUsers.map((user, index) => (
<motion.div
key={user.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
className={classNames(
'bg-bolt-elements-background-depth-2 rounded-lg p-6',
'border border-bolt-elements-borderColor',
'hover:shadow-lg transition-all',
user.id === authState.user?.id ? 'ring-2 ring-accent-500' : '',
)}
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-bolt-elements-background-depth-3 flex items-center justify-center overflow-hidden border border-bolt-elements-borderColor">
{user.avatar ? (
<img src={user.avatar} alt={user.firstName} className="w-full h-full object-cover" />
) : (
<span className="text-lg font-medium text-bolt-elements-textPrimary">
{user.firstName[0].toUpperCase()}
</span>
)}
</div>
<div>
<p className="font-medium text-bolt-elements-textPrimary">
{user.firstName}
{user.id === authState.user?.id && (
<span className="ml-2 text-xs px-2 py-0.5 rounded-full bg-accent-500/20 text-accent-500">
You
</span>
)}
</p>
<p className="text-sm text-bolt-elements-textSecondary">@{user.username}</p>
</div>
</div>
<div className="flex items-center gap-1">
<button
className="p-1 rounded hover:bg-bolt-elements-background-depth-3 transition-colors"
title="Edit user"
>
<span className="i-ph:pencil text-bolt-elements-textSecondary" />
</button>
{user.id !== authState.user?.id && (
<button
onClick={() => {
setSelectedUser(user);
setShowDeleteModal(true);
}}
className="p-1 rounded hover:bg-red-500/10 transition-colors"
title="Delete user"
>
<span className="i-ph:trash text-red-500" />
</button>
)}
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
<span className="i-ph:calendar-blank" />
<span>Joined {new Date(user.createdAt).toLocaleDateString()}</span>
</div>
{user.lastLogin && (
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
<span className="i-ph:clock" />
<span>Last active {new Date(user.lastLogin).toLocaleDateString()}</span>
</div>
)}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
)}
</div>
{/* Delete Confirmation Modal */}
<AnimatePresence>
{showDeleteModal && selectedUser && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={() => !deleting && setShowDeleteModal(false)}
>
<motion.div
initial={{ scale: 0.95 }}
animate={{ scale: 1 }}
exit={{ scale: 0.95 }}
className="bg-bolt-elements-background-depth-2 rounded-lg p-6 max-w-md w-full border border-bolt-elements-borderColor"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-bold text-bolt-elements-textPrimary mb-2">Delete User</h2>
<p className="text-bolt-elements-textSecondary mb-6">
Are you sure you want to delete{' '}
<span className="font-medium text-bolt-elements-textPrimary">@{selectedUser.username}</span>? This
action cannot be undone and will permanently remove all user data.
</p>
<div className="flex gap-3">
<button
onClick={() => setShowDeleteModal(false)}
disabled={deleting}
className={classNames(
'flex-1 px-4 py-2 rounded-lg',
'border border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary',
'hover:bg-bolt-elements-background-depth-3',
'disabled:opacity-50',
'transition-colors',
)}
>
Cancel
</button>
<button
onClick={handleDeleteUser}
disabled={deleting}
className={classNames(
'flex-1 px-4 py-2 rounded-lg',
'bg-red-500 text-white',
'hover:bg-red-600',
'disabled:opacity-50',
'transition-colors',
'flex items-center justify-center gap-2',
)}
>
{deleting ? (
<>
<span className="i-svg-spinners:3-dots-scale" />
Deleting...
</>
) : (
<>
<span className="i-ph:trash" />
Delete User
</>
)}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
</ProtectedRoute>
);
}

View File

@@ -1,92 +0,0 @@
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
import { getUserByUsername, updateLastLogin, logSecurityEvent } from '~/lib/utils/fileUserStorage';
import { verifyPassword, generateToken } from '~/lib/utils/crypto';
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== 'POST') {
return json({ error: 'Method not allowed' }, { status: 405 });
}
try {
const body = (await request.json()) as { username?: string; password?: string };
const { username, password } = body;
if (!username || !password) {
return json({ error: 'Username and password are required' }, { status: 400 });
}
// Get user from storage
const user = await getUserByUsername(username);
if (!user) {
// Log failed login attempt
await logSecurityEvent({
timestamp: new Date().toISOString(),
username,
action: 'failed_login',
details: `Failed login attempt for non-existent user: ${username}`,
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
});
return json({ error: 'Invalid username or password' }, { status: 401 });
}
// Verify password
const isValid = await verifyPassword(password, user.passwordHash);
if (!isValid) {
// Log failed login attempt
await logSecurityEvent({
timestamp: new Date().toISOString(),
userId: user.id,
username: user.username,
action: 'failed_login',
details: `Failed login attempt with incorrect password`,
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
});
return json({ error: 'Invalid username or password' }, { status: 401 });
}
// Update last login time
await updateLastLogin(user.id);
// Generate JWT token
const token = generateToken({
userId: user.id,
username: user.username,
firstName: user.firstName,
});
// Log successful login
await logSecurityEvent({
timestamp: new Date().toISOString(),
userId: user.id,
username: user.username,
action: 'login',
details: 'Successful login',
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
});
// Return user data without password
const { passwordHash, ...userWithoutPassword } = user;
return json({
success: true,
user: userWithoutPassword,
token,
});
} catch (error) {
console.error('Login error:', error);
await logSecurityEvent({
timestamp: new Date().toISOString(),
action: 'error',
details: `Login error: ${error}`,
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
});
return json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -1,37 +0,0 @@
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
import { verifyToken } from '~/lib/utils/crypto';
import { logSecurityEvent } from '~/lib/utils/fileUserStorage';
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== 'POST') {
return json({ error: 'Method not allowed' }, { status: 405 });
}
try {
// Get token from Authorization header
const authHeader = request.headers.get('Authorization');
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
const payload = verifyToken(token);
if (payload) {
// Log logout event
await logSecurityEvent({
timestamp: new Date().toISOString(),
userId: payload.userId,
username: payload.username,
action: 'logout',
details: 'User logged out',
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
});
}
}
return json({ success: true });
} catch (error) {
console.error('Logout error:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -1,93 +0,0 @@
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
import { createUser, getUserByUsername, logSecurityEvent } from '~/lib/utils/fileUserStorage';
import { validatePassword, generateToken } from '~/lib/utils/crypto';
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== 'POST') {
return json({ error: 'Method not allowed' }, { status: 405 });
}
try {
const body = (await request.json()) as {
username?: string;
password?: string;
firstName?: string;
avatar?: string;
};
const { username, password, firstName, avatar } = body;
// Validate required fields
if (!username || !password || !firstName) {
return json({ error: 'Username, password, and first name are required' }, { status: 400 });
}
// Validate username format
if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
return json(
{
error: 'Username must be 3-20 characters and contain only letters, numbers, and underscores',
},
{ status: 400 },
);
}
// Validate password strength
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return json({ error: passwordValidation.errors.join('. ') }, { status: 400 });
}
// Check if username already exists
const existingUser = await getUserByUsername(username);
if (existingUser) {
return json({ error: 'Username already exists' }, { status: 400 });
}
// Create new user
const user = await createUser(username, password, firstName, avatar);
if (!user) {
return json({ error: 'Failed to create user' }, { status: 500 });
}
// Generate JWT token
const token = generateToken({
userId: user.id,
username: user.username,
firstName: user.firstName,
});
// Log successful signup
await logSecurityEvent({
timestamp: new Date().toISOString(),
userId: user.id,
username: user.username,
action: 'signup',
details: 'New user registration',
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
});
// Return user data without password
const { passwordHash, ...userWithoutPassword } = user;
return json({
success: true,
user: userWithoutPassword,
token,
});
} catch (error) {
console.error('Signup error:', error);
await logSecurityEvent({
timestamp: new Date().toISOString(),
action: 'error',
details: `Signup error: ${error}`,
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
});
return json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -1,44 +0,0 @@
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
import { verifyToken } from '~/lib/utils/crypto';
import { getUserById } from '~/lib/utils/fileUserStorage';
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== 'POST') {
return json({ error: 'Method not allowed' }, { status: 405 });
}
try {
// Get token from Authorization header
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return json({ error: 'No token provided' }, { status: 401 });
}
const token = authHeader.substring(7);
const payload = verifyToken(token);
if (!payload) {
return json({ error: 'Invalid token' }, { status: 401 });
}
// Get user from storage
const user = await getUserById(payload.userId);
if (!user) {
return json({ error: 'User not found' }, { status: 404 });
}
// Return user data without password
const { passwordHash, ...userWithoutPassword } = user;
return json({
success: true,
user: userWithoutPassword,
});
} catch (error) {
console.error('Token verification error:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -13,7 +13,6 @@ import { createSummary } from '~/lib/.server/llm/create-summary';
import { extractPropertiesFromMessage } from '~/lib/.server/llm/utils';
import type { DesignScheme } from '~/types/design-scheme';
import { MCPService } from '~/lib/services/mcpService';
import { StreamRecoveryManager } from '~/lib/.server/llm/stream-recovery';
export async function action(args: ActionFunctionArgs) {
return chatAction(args);
@@ -75,22 +74,6 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
const encoder: TextEncoder = new TextEncoder();
let progressCounter: number = 1;
// Initialize stream recovery manager
const recovery = new StreamRecoveryManager({
maxRetries: 3,
retryDelay: 2000,
timeout: 45000, // 45 seconds timeout
onTimeout: () => {
logger.warn('Stream timeout detected - attempting recovery');
},
onRetry: (attempt) => {
logger.info(`Stream recovery attempt ${attempt}`);
},
onError: (error) => {
logger.error('Stream error in recovery:', error);
},
});
try {
const mcpService = MCPService.getInstance();
const totalMessageContent = messages.reduce((acc, message) => acc + message.content, '');
@@ -330,77 +313,28 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
});
(async () => {
try {
recovery.startMonitoring();
for await (const part of result.fullStream) {
if (part.type === 'error') {
const error: any = part.error;
logger.error('Streaming error:', error);
let lastActivityTime = Date.now();
const activityCheckInterval = 5000; // Check every 5 seconds
// Set up activity monitoring
const activityChecker = setInterval(() => {
const timeSinceLastActivity = Date.now() - lastActivityTime;
if (timeSinceLastActivity > 30000) {
logger.warn(`No stream activity for ${timeSinceLastActivity}ms`);
// Attempt to recover if stream appears stuck
recovery.attemptRecovery();
// Enhanced error handling for common streaming issues
if (error.message?.includes('Invalid JSON response')) {
logger.error('Invalid JSON response detected - likely malformed API response');
} else if (error.message?.includes('token')) {
logger.error('Token-related error detected - possible token limit exceeded');
}
}, activityCheckInterval);
for await (const part of result.fullStream) {
// Record activity
lastActivityTime = Date.now();
recovery.recordActivity();
if (part.type === 'error') {
const error: any = part.error;
logger.error('Streaming error:', error);
// Enhanced error handling for common streaming issues
if (error.message?.includes('Invalid JSON response')) {
logger.error('Invalid JSON response detected - likely malformed API response');
} else if (error.message?.includes('token')) {
logger.error('Token-related error detected - possible token limit exceeded');
}
// Attempt recovery for certain errors
const canRecover = await recovery.handleError(error);
if (!canRecover) {
clearInterval(activityChecker);
recovery.stop();
return;
}
}
return;
}
// Clean up
clearInterval(activityChecker);
recovery.stop();
} catch (streamError) {
logger.error('Fatal stream error:', streamError);
recovery.stop();
throw streamError;
}
})();
result.mergeIntoDataStream(dataStream);
},
onError: (error: any) => {
// Stop recovery manager on error
recovery.stop();
// Provide more specific error messages for common issues
const errorMessage = error.message || 'Unknown error';
// Log detailed error for debugging
logger.error('Chat API error:', {
message: errorMessage,
stack: error.stack,
code: error.code,
});
if (errorMessage.includes('model') && errorMessage.includes('not found')) {
return 'Custom error: Invalid model selected. Please check that the model name is correct and available.';
}
@@ -426,11 +360,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
}
if (errorMessage.includes('network') || errorMessage.includes('timeout')) {
return 'Custom error: Network error or timeout. The connection was interrupted. Please try again or switch to a different AI model.';
}
if (errorMessage.includes('stream') || errorMessage.includes('hang')) {
return 'Custom error: The conversation stream was interrupted. Please refresh the page and try again.';
return 'Custom error: Network error. Please check your internet connection and try again.';
}
return `Custom error: ${errorMessage}`;
@@ -473,32 +403,17 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
}),
);
// Set up cleanup for recovery manager
const cleanupStream = dataStream.pipeThrough(
new TransformStream({
flush() {
recovery.stop();
},
}),
);
return new Response(cleanupStream, {
return new Response(dataStream, {
status: 200,
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
Connection: 'keep-alive',
'Cache-Control': 'no-cache',
'Text-Encoding': 'chunked',
'X-Accel-Buffering': 'no', // Disable nginx buffering
},
});
} catch (error: any) {
logger.error('Fatal error in chat API:', error);
// Ensure recovery manager is stopped on error
if (typeof recovery !== 'undefined') {
recovery.stop();
}
logger.error(error);
const errorResponse = {
error: true,

View File

@@ -8,7 +8,6 @@ interface ModelsResponse {
modelList: ModelInfo[];
providers: ProviderInfo[];
defaultProvider: ProviderInfo;
configuredProviders?: string[];
}
let cachedProviders: ProviderInfo[] | null = null;
@@ -83,28 +82,9 @@ export async function loader({
});
}
// Check which local providers are configured in environment
const configuredProviders: string[] = [];
// Check Ollama
if (context.cloudflare?.env?.OLLAMA_API_BASE_URL || process.env?.OLLAMA_API_BASE_URL) {
configuredProviders.push('Ollama');
}
// Check LMStudio
if (context.cloudflare?.env?.LMSTUDIO_API_BASE_URL || process.env?.LMSTUDIO_API_BASE_URL) {
configuredProviders.push('LMStudio');
}
// Check OpenAILike
if (context.cloudflare?.env?.OPENAI_LIKE_API_BASE_URL || process.env?.OPENAI_LIKE_API_BASE_URL) {
configuredProviders.push('OpenAILike');
}
return json<ModelsResponse>({
modelList,
providers,
defaultProvider,
configuredProviders,
});
}

View File

@@ -1,239 +0,0 @@
/**
* Netlify Quick Deploy API Endpoint
* Contributed by Keoma Wright
*
* This endpoint handles quick deployments to Netlify without requiring authentication,
* using Netlify's drop API for instant deployment.
*/
import { type ActionFunctionArgs, json } from '@remix-run/cloudflare';
import crypto from 'crypto';
interface QuickDeployRequestBody {
files: Record<string, string>;
chatId: string;
framework?: string;
}
// Use environment variable or fallback to public token for quick deploys
const NETLIFY_QUICK_DEPLOY_TOKEN = process.env.NETLIFY_QUICK_DEPLOY_TOKEN || '';
export async function action({ request }: ActionFunctionArgs) {
try {
const { files, chatId, framework } = (await request.json()) as QuickDeployRequestBody;
if (!files || Object.keys(files).length === 0) {
return json({ error: 'No files to deploy' }, { status: 400 });
}
// Generate a unique site name
const siteName = `bolt-quick-${chatId.substring(0, 8)}-${Date.now()}`;
// Prepare files for Netlify Drop API
const deployFiles: Record<string, string> = {};
// Add index.html if it doesn't exist (for static sites)
if (!files['/index.html'] && !files['index.html']) {
// Check if there's any HTML file
const htmlFile = Object.keys(files).find((f) => f.endsWith('.html'));
if (!htmlFile) {
// Create a basic index.html
deployFiles['/index.html'] = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${framework || 'Bolt'} App</title>
</head>
<body>
<div id="root"></div>
<script>
// Check if there's a main.js or app.js
const scripts = ${JSON.stringify(Object.keys(files).filter((f) => f.endsWith('.js')))};
if (scripts.length > 0) {
const script = document.createElement('script');
script.src = scripts[0];
document.body.appendChild(script);
}
</script>
</body>
</html>`;
}
}
// Process and normalize file paths
for (const [filePath, content] of Object.entries(files)) {
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
deployFiles[normalizedPath] = content;
}
// Use Netlify's API to create a new site and deploy
let siteId: string | undefined;
let deployUrl: string | undefined;
if (NETLIFY_QUICK_DEPLOY_TOKEN) {
// If we have a token, use the authenticated API
try {
// Create a new site
const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', {
method: 'POST',
headers: {
Authorization: `Bearer ${NETLIFY_QUICK_DEPLOY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: siteName,
custom_domain: null,
}),
});
if (createSiteResponse.ok) {
const site = (await createSiteResponse.json()) as any;
siteId = site.id;
// Create file digests for deployment
const fileDigests: Record<string, string> = {};
for (const [path, content] of Object.entries(deployFiles)) {
const hash = crypto.createHash('sha1').update(content).digest('hex');
fileDigests[path] = hash;
}
// Create deployment
const deployResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/deploys`, {
method: 'POST',
headers: {
Authorization: `Bearer ${NETLIFY_QUICK_DEPLOY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
files: fileDigests,
async: false,
draft: false,
}),
});
if (deployResponse.ok) {
const deploy = (await deployResponse.json()) as any;
// Upload files
for (const [path, content] of Object.entries(deployFiles)) {
await fetch(`https://api.netlify.com/api/v1/deploys/${deploy.id}/files${path}`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${NETLIFY_QUICK_DEPLOY_TOKEN}`,
'Content-Type': 'application/octet-stream',
},
body: content,
});
}
deployUrl = deploy.ssl_url || deploy.url || `https://${siteName}.netlify.app`;
}
}
} catch (error) {
console.error('Error with authenticated deployment:', error);
}
}
// Fallback to Netlify Drop (no authentication required)
if (!deployUrl) {
// Create a form data with files
const formData = new FormData();
// Add each file to the form data
for (const [path, content] of Object.entries(deployFiles)) {
const blob = new Blob([content], { type: 'text/plain' });
const fileName = path.startsWith('/') ? path.substring(1) : path;
formData.append('file', blob, fileName);
}
// Deploy using Netlify Drop API (no auth required)
const dropResponse = await fetch('https://api.netlify.com/api/v1/sites', {
method: 'POST',
body: formData,
});
if (dropResponse.ok) {
const dropData = (await dropResponse.json()) as any;
siteId = dropData.id;
deployUrl = dropData.ssl_url || dropData.url || `https://${dropData.subdomain}.netlify.app`;
} else {
// Try alternative deployment method
const zipContent = await createZipArchive(deployFiles);
const zipResponse = await fetch('https://api.netlify.com/api/v1/sites', {
method: 'POST',
headers: {
'Content-Type': 'application/zip',
},
body: zipContent,
});
if (zipResponse.ok) {
const zipData = (await zipResponse.json()) as any;
siteId = zipData.id;
deployUrl = zipData.ssl_url || zipData.url;
} else {
throw new Error('Failed to deploy to Netlify');
}
}
}
if (!deployUrl) {
return json({ error: 'Deployment failed - could not get deployment URL' }, { status: 500 });
}
return json({
success: true,
url: deployUrl,
siteId,
siteName,
});
} catch (error) {
console.error('Quick deploy error:', error);
return json(
{
error: error instanceof Error ? error.message : 'Deployment failed',
details: error instanceof Error ? error.stack : undefined,
},
{ status: 500 },
);
}
}
// Helper function to create a simple ZIP archive (minimal implementation)
async function createZipArchive(files: Record<string, string>): Promise<ArrayBuffer> {
// This is a simplified ZIP creation - in production, use a proper ZIP library
const encoder = new TextEncoder();
const parts: Uint8Array[] = [];
// For simplicity, we'll create a tar-like format
for (const [path, content] of Object.entries(files)) {
const pathBytes = encoder.encode(path);
const contentBytes = encoder.encode(content);
// Simple header: path length (4 bytes) + content length (4 bytes)
const header = new Uint8Array(8);
new DataView(header.buffer).setUint32(0, pathBytes.length, true);
new DataView(header.buffer).setUint32(4, contentBytes.length, true);
parts.push(header);
parts.push(pathBytes);
parts.push(contentBytes);
}
// Combine all parts
const totalLength = parts.reduce((sum, part) => sum + part.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const part of parts) {
result.set(part, offset);
offset += part.length;
}
return result.buffer;
}

View File

@@ -1,49 +0,0 @@
import type { ActionFunctionArgs } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
import { verifyToken } from '~/lib/utils/crypto';
import { deleteUser } from '~/lib/utils/fileUserStorage';
export async function action({ request, params }: ActionFunctionArgs) {
try {
const { id } = params;
if (!id) {
return json({ error: 'User ID is required' }, { status: 400 });
}
// Verify authentication
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const token = authHeader.substring(7);
const payload = verifyToken(token);
if (!payload) {
return json({ error: 'Invalid token' }, { status: 401 });
}
// Prevent users from deleting themselves
if (payload.userId === id) {
return json({ error: 'Cannot delete your own account' }, { status: 400 });
}
if (request.method === 'DELETE') {
// Delete the user
const success = await deleteUser(id);
if (success) {
return json({ success: true });
} else {
return json({ error: 'User not found' }, { status: 404 });
}
}
return json({ error: 'Method not allowed' }, { status: 405 });
} catch (error) {
console.error('User operation error:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -1,30 +0,0 @@
import type { LoaderFunctionArgs } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
import { verifyToken } from '~/lib/utils/crypto';
import { getAllUsers } from '~/lib/utils/fileUserStorage';
export async function loader({ request }: LoaderFunctionArgs) {
try {
// Verify authentication
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const token = authHeader.substring(7);
const payload = verifyToken(token);
if (!payload) {
return json({ error: 'Invalid token' }, { status: 401 });
}
// Get all users (without passwords)
const users = await getAllUsers();
return json({ users });
} catch (error) {
console.error('Failed to fetch users:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -1,422 +0,0 @@
import { useState } from 'react';
import { useNavigate } from '@remix-run/react';
import { motion, AnimatePresence } from 'framer-motion';
import { login, signup } from '~/lib/stores/auth';
import { validatePassword } from '~/lib/utils/crypto';
import { classNames } from '~/utils/classNames';
export default function AuthPage() {
const navigate = useNavigate();
const [mode, setMode] = useState<'login' | 'signup'>('login');
const [formData, setFormData] = useState({
username: '',
password: '',
firstName: '',
confirmPassword: '',
rememberMe: false,
});
const [avatar, setAvatar] = useState<string | undefined>();
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
// Clear error for this field
setErrors((prev) => ({ ...prev, [name]: '' }));
};
const handleAvatarUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setAvatar(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
setLoading(true);
try {
if (mode === 'signup') {
// Validate form
const validationErrors: Record<string, string> = {};
if (!formData.username) {
validationErrors.username = 'Username is required';
}
if (!formData.firstName) {
validationErrors.firstName = 'First name is required';
}
const passwordValidation = validatePassword(formData.password);
if (!passwordValidation.valid) {
validationErrors.password = passwordValidation.errors[0];
}
if (formData.password !== formData.confirmPassword) {
validationErrors.confirmPassword = 'Passwords do not match';
}
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
setLoading(false);
return;
}
const result = await signup(formData.username, formData.password, formData.firstName, avatar);
if (result.success) {
navigate('/');
} else {
setErrors({ general: result.error || 'Signup failed' });
}
} else {
const result = await login(formData.username, formData.password, formData.rememberMe);
if (result.success) {
navigate('/');
} else {
setErrors({ general: result.error || 'Invalid username or password' });
}
}
} catch {
setErrors({ general: 'An error occurred. Please try again.' });
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center relative overflow-hidden">
{/* Animated gradient background */}
<div className="absolute inset-0 bg-gradient-to-br from-blue-600 via-purple-600 to-pink-600">
<div className="absolute inset-0 bg-black/30" />
<motion.div
className="absolute inset-0 opacity-30"
animate={{
background: [
'radial-gradient(circle at 20% 80%, #3b82f6 0%, transparent 50%)',
'radial-gradient(circle at 80% 20%, #a855f7 0%, transparent 50%)',
'radial-gradient(circle at 40% 40%, #ec4899 0%, transparent 50%)',
'radial-gradient(circle at 20% 80%, #3b82f6 0%, transparent 50%)',
],
}}
transition={{ duration: 10, repeat: Infinity }}
/>
</div>
{/* Logo and Title */}
<div className="absolute top-8 left-8 z-20">
<motion.div initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-white/10 backdrop-blur flex items-center justify-center">
<span className="text-2xl"></span>
</div>
<div>
<h1 className="text-2xl font-bold text-white">bolt.diy</h1>
<p className="text-sm text-white/70">Multi-User Edition</p>
</div>
</motion.div>
</div>
{/* Auth Card */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="relative z-10 w-full max-w-md mx-4"
>
<div className="bg-white/10 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/20 overflow-hidden">
{/* Tab Header */}
<div className="flex relative bg-white/5">
<button
onClick={() => setMode('login')}
className={classNames(
'flex-1 py-4 text-center font-semibold transition-all',
mode === 'login'
? 'text-white bg-gradient-to-r from-blue-500/20 to-purple-600/20'
: 'text-white/70 hover:text-white hover:bg-white/5',
)}
>
Sign In
</button>
<button
onClick={() => setMode('signup')}
className={classNames(
'flex-1 py-4 text-center font-semibold transition-all',
mode === 'signup'
? 'text-white bg-gradient-to-r from-blue-500/20 to-purple-600/20'
: 'text-white/70 hover:text-white hover:bg-white/5',
)}
>
Sign Up
</button>
{/* Sliding indicator */}
<motion.div
className="absolute bottom-0 h-1 bg-gradient-to-r from-blue-500 to-purple-600"
initial={false}
animate={{
x: mode === 'login' ? '0%' : '100%',
width: '50%',
}}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
</div>
{/* Form Content */}
<div className="p-8">
<AnimatePresence mode="wait">
<motion.form
key={mode}
initial={{ opacity: 0, x: mode === 'login' ? -20 : 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: mode === 'login' ? 20 : -20 }}
transition={{ duration: 0.3 }}
onSubmit={handleSubmit}
className="space-y-6"
>
{/* Avatar Upload (Signup only) */}
{mode === 'signup' && (
<div className="flex justify-center">
<div className="relative">
<div className="w-24 h-24 rounded-full bg-white/20 backdrop-blur flex items-center justify-center overflow-hidden border-2 border-white/30">
{avatar ? (
<img src={avatar} alt="Avatar" className="w-full h-full object-cover" />
) : (
<span className="text-3xl text-white/50">👤</span>
)}
</div>
<label className="absolute bottom-0 right-0 w-8 h-8 bg-white/20 backdrop-blur rounded-full flex items-center justify-center cursor-pointer hover:bg-white/30 transition-colors border border-white/30">
<span className="text-sm">📷</span>
<input type="file" accept="image/*" onChange={handleAvatarUpload} className="hidden" />
</label>
</div>
</div>
)}
{/* First Name (Signup only) */}
{mode === 'signup' && (
<div>
<label className="block text-sm font-medium text-white/80 mb-2">First Name</label>
<input
type="text"
name="firstName"
value={formData.firstName}
onChange={handleInputChange}
className={classNames(
'w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur',
'border border-white/20 text-white placeholder-white/40',
'focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent',
'transition-all',
errors.firstName && 'border-red-400',
)}
placeholder="Enter your first name"
required
/>
{errors.firstName && <p className="mt-1 text-sm text-red-300">{errors.firstName}</p>}
</div>
)}
{/* Username */}
<div>
<label className="block text-sm font-medium text-white/80 mb-2">Username</label>
<input
type="text"
name="username"
value={formData.username}
onChange={handleInputChange}
className={classNames(
'w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur',
'border border-white/20 text-white placeholder-white/40',
'focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent',
'transition-all',
errors.username && 'border-red-400',
)}
placeholder="Enter your username"
required
/>
{errors.username && <p className="mt-1 text-sm text-red-300">{errors.username}</p>}
</div>
{/* Password */}
<div>
<label className="block text-sm font-medium text-white/80 mb-2">Password</label>
<input
type="password"
name="password"
value={formData.password}
onChange={handleInputChange}
className={classNames(
'w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur',
'border border-white/20 text-white placeholder-white/40',
'focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent',
'transition-all',
errors.password && 'border-red-400',
)}
placeholder="Enter your password"
required
/>
{errors.password && <p className="mt-1 text-sm text-red-300">{errors.password}</p>}
{mode === 'signup' && formData.password && (
<div className="mt-2 space-y-1">
<div className="flex items-center gap-2">
<div
className={classNames(
'w-2 h-2 rounded-full',
formData.password.length >= 8 ? 'bg-green-400' : 'bg-white/30',
)}
/>
<span className="text-xs text-white/60">At least 8 characters</span>
</div>
<div className="flex items-center gap-2">
<div
className={classNames(
'w-2 h-2 rounded-full',
/[A-Z]/.test(formData.password) ? 'bg-green-400' : 'bg-white/30',
)}
/>
<span className="text-xs text-white/60">One uppercase letter</span>
</div>
<div className="flex items-center gap-2">
<div
className={classNames(
'w-2 h-2 rounded-full',
/[a-z]/.test(formData.password) ? 'bg-green-400' : 'bg-white/30',
)}
/>
<span className="text-xs text-white/60">One lowercase letter</span>
</div>
<div className="flex items-center gap-2">
<div
className={classNames(
'w-2 h-2 rounded-full',
/[0-9]/.test(formData.password) ? 'bg-green-400' : 'bg-white/30',
)}
/>
<span className="text-xs text-white/60">One number</span>
</div>
</div>
)}
</div>
{/* Confirm Password (Signup only) */}
{mode === 'signup' && (
<div>
<label className="block text-sm font-medium text-white/80 mb-2">Confirm Password</label>
<input
type="password"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
className={classNames(
'w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur',
'border border-white/20 text-white placeholder-white/40',
'focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent',
'transition-all',
errors.confirmPassword && 'border-red-400',
)}
placeholder="Confirm your password"
required
/>
{errors.confirmPassword && <p className="mt-1 text-sm text-red-300">{errors.confirmPassword}</p>}
</div>
)}
{/* Remember Me (Login only) */}
{mode === 'login' && (
<div className="flex items-center">
<input
type="checkbox"
name="rememberMe"
id="rememberMe"
checked={formData.rememberMe}
onChange={handleInputChange}
className="w-4 h-4 rounded bg-white/10 border-white/20 text-blue-500 focus:ring-white/50"
/>
<label htmlFor="rememberMe" className="ml-2 text-sm text-white/70">
Remember me for 7 days
</label>
</div>
)}
{/* Error Message */}
{errors.general && (
<div className="p-3 rounded-lg bg-red-500/20 border border-red-500/30">
<p className="text-sm text-red-200">{errors.general}</p>
</div>
)}
{/* Submit Button */}
<button
type="submit"
disabled={loading}
className={classNames(
'w-full py-3 rounded-lg font-semibold transition-all',
'bg-gradient-to-r from-blue-500 to-purple-600 text-white',
'hover:from-blue-600 hover:to-purple-700',
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
'disabled:opacity-50 disabled:cursor-not-allowed',
'flex items-center justify-center gap-2',
'shadow-lg hover:shadow-xl',
)}
>
{loading ? (
<>
<span className="i-svg-spinners:3-dots-scale w-5 h-5" />
{mode === 'login' ? 'Signing in...' : 'Creating account...'}
</>
) : mode === 'login' ? (
'Sign In'
) : (
'Create Account'
)}
</button>
</motion.form>
</AnimatePresence>
{/* Developer Credit */}
<div className="mt-8 pt-6 border-t border-white/10">
<p className="text-center text-xs text-white/40">
Developed by <span className="text-white/60 font-medium">Keoma Wright</span>
</p>
</div>
{/* Continue as Guest */}
<div className="pb-6">
<button
onClick={() => navigate('/')}
className="w-full py-2 text-center text-sm text-white/60 hover:text-white transition-colors"
>
<span className="flex items-center justify-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
Continue as Guest
</span>
</button>
</div>
</div>
</div>
</motion.div>
</div>
);
}