- Replaced broken animate-spin Tailwind class with custom weekly-spinner CSS - Standardized loading indicators across Auth, Tasks, and Calendar settings - Fixed JSX syntax errors in several components
444 lines
20 KiB
TypeScript
444 lines
20 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useCallback } from 'react';
|
|
import { CalendarConnection } from '@/lib/calendar-sync';
|
|
|
|
interface CalendarSettingsProps {
|
|
connections: CalendarConnection[];
|
|
onConnectionAdded?: (connection: CalendarConnection) => void;
|
|
onConnectionRemoved?: (connectionId: string) => void;
|
|
onSyncTriggered?: (connectionId: string) => void;
|
|
}
|
|
|
|
const CalendarSettings: React.FC<CalendarSettingsProps> = ({
|
|
connections,
|
|
onConnectionAdded,
|
|
onConnectionRemoved,
|
|
onSyncTriggered
|
|
}) => {
|
|
const [isSyncing, setIsSyncing] = useState<Record<string, boolean>>({});
|
|
const [lastSyncTime, setLastSyncTime] = useState<Record<string, Date>>({});
|
|
const [showConfirmation, setShowConfirmation] = useState<string | null>(null);
|
|
|
|
// Apple Calendar State
|
|
const [showAppleModal, setShowAppleModal] = useState(false);
|
|
const [appleEmail, setAppleEmail] = useState('');
|
|
const [applePassword, setApplePassword] = useState('');
|
|
const [isConnectingApple, setIsConnectingApple] = useState(false);
|
|
const [appleError, setAppleError] = useState('');
|
|
const [needs2FA, setNeeds2FA] = useState(false);
|
|
const [securityCode, setSecurityCode] = useState('');
|
|
|
|
// Memoized functions for performance
|
|
const formatDate = useCallback((date?: Date) => {
|
|
if (!date) return 'Never';
|
|
return date.toLocaleString();
|
|
}, []);
|
|
|
|
// Function to trigger Google OAuth
|
|
const handleGoogleConnect = useCallback(() => {
|
|
// In a real implementation, this would redirect to Google OAuth flow
|
|
// For now, we'll just log the action
|
|
console.log('Redirecting to Google OAuth flow...');
|
|
// window.location.href = '/api/calendar/google/oauth';
|
|
}, []);
|
|
|
|
// Function to trigger Apple Connect Modal
|
|
const handleAppleConnect = useCallback(() => {
|
|
setShowAppleModal(true);
|
|
setAppleError('');
|
|
setAppleEmail('');
|
|
setApplePassword('');
|
|
setNeeds2FA(false);
|
|
setSecurityCode('');
|
|
}, []);
|
|
|
|
const submitAppleConnection = async () => {
|
|
if (!appleEmail || !applePassword) {
|
|
setAppleError('Please enter both email and password.');
|
|
return;
|
|
}
|
|
|
|
setIsConnectingApple(true);
|
|
setAppleError('');
|
|
|
|
try {
|
|
const response = await fetch('/api/reminders/connect', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: appleEmail, password: applePassword }),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'Failed to connect Apple account');
|
|
}
|
|
|
|
if (data.needs2FA) {
|
|
// Show 2FA code input
|
|
setNeeds2FA(true);
|
|
} else {
|
|
// Connected without 2FA
|
|
if (onConnectionAdded && data.connection) {
|
|
onConnectionAdded(data.connection);
|
|
}
|
|
setShowAppleModal(false);
|
|
}
|
|
} catch (err: any) {
|
|
setAppleError(err.message || 'Connection failed');
|
|
} finally {
|
|
setIsConnectingApple(false);
|
|
}
|
|
};
|
|
|
|
const submitSecurityCode = async () => {
|
|
if (!securityCode || securityCode.length < 4) {
|
|
setAppleError('Please enter a valid security code.');
|
|
return;
|
|
}
|
|
|
|
setIsConnectingApple(true);
|
|
setAppleError('');
|
|
|
|
try {
|
|
const response = await fetch('/api/reminders/verify', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: appleEmail, code: securityCode, password: applePassword }),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'Verification failed');
|
|
}
|
|
|
|
setShowAppleModal(false);
|
|
// Refresh the page to pick up the new connection
|
|
window.location.reload();
|
|
} catch (err: any) {
|
|
setAppleError(err.message || 'Verification failed');
|
|
} finally {
|
|
setIsConnectingApple(false);
|
|
}
|
|
};
|
|
|
|
// Function to trigger manual sync
|
|
const handleManualSync = useCallback(async (connectionId: string) => {
|
|
setIsSyncing(prev => ({ ...prev, [connectionId]: true }));
|
|
|
|
try {
|
|
// Call the API endpoint for manual sync
|
|
const response = await fetch('/api/calendar/sync', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
connectionId,
|
|
timeMin: new Date().toISOString(),
|
|
timeMax: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // Next 7 days
|
|
}),
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
// Update last sync time
|
|
setLastSyncTime(prev => ({
|
|
...prev,
|
|
[connectionId]: new Date()
|
|
}));
|
|
|
|
// Notify parent component
|
|
if (onSyncTriggered) {
|
|
onSyncTriggered(connectionId);
|
|
}
|
|
} else {
|
|
console.error('Sync failed:', result.error);
|
|
alert(`Sync failed: ${result.error || 'Unknown error'}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Sync error:', error);
|
|
alert('Sync failed due to network error');
|
|
} finally {
|
|
setIsSyncing(prev => ({ ...prev, [connectionId]: false }));
|
|
}
|
|
}, [onSyncTriggered]);
|
|
|
|
// Function to remove connection with confirmation
|
|
const handleRemoveConnection = useCallback((connectionId: string) => {
|
|
setShowConfirmation(connectionId);
|
|
}, []);
|
|
|
|
// Confirm removal
|
|
const confirmRemoveConnection = useCallback((connectionId: string) => {
|
|
if (onConnectionRemoved) {
|
|
onConnectionRemoved(connectionId);
|
|
}
|
|
setShowConfirmation(null);
|
|
}, [onConnectionRemoved]);
|
|
|
|
// Cancel removal
|
|
const cancelRemoveConnection = useCallback(() => {
|
|
setShowConfirmation(null);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="calendar-settings bg-white rounded-lg shadow-md p-6" role="region" aria-label="Calendar connections settings">
|
|
<h2 className="text-2xl font-bold text-gray-800 mb-6" role="heading" aria-level={2}>Calendar Connections</h2>
|
|
|
|
{connections.length === 0 ? (
|
|
<div className="text-center py-8" role="status">
|
|
<div className="mb-4">
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 mx-auto text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
</svg>
|
|
</div>
|
|
<p className="text-gray-600 mb-4">No calendar connections found.</p>
|
|
<p className="text-gray-500 text-sm mb-6">Connect your Google or Apple Calendar to get started.</p>
|
|
</div>
|
|
) : (
|
|
<div className="connections-list space-y-4" role="list">
|
|
{connections.map(connection => (
|
|
<div
|
|
key={connection.id}
|
|
className="connection-item border rounded-lg p-4 hover:shadow-sm transition-shadow focus-within:ring-2 focus-within:ring-blue-500"
|
|
role="listitem"
|
|
>
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
<div className="flex items-start gap-3">
|
|
<div className="flex-shrink-0">
|
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${connection.provider === 'google' ? 'bg-blue-100 text-blue-600' : 'bg-purple-100 text-purple-600'
|
|
}`} role="img" aria-label={`${connection.provider} calendar icon`}>
|
|
{connection.provider === 'google' ? (
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
<path d="M12.24 10.285V14.4h6.806c-.275 1.765-2.056 5.174-6.806 5.174-4.095 0-7.439-3.389-7.439-7.574s3.345-7.574 7.439-7.574c2.33 0 3.891.989 4.785 1.849l3.254-3.138C18.189 1.186 15.478 0 12.24 0c-6.635 0-12 5.365-12 12s5.365 12 12 12c6.926 0 11.52-4.869 11.52-11.726 0-.788-.084-1.39-.197-1.967H12.24z" />
|
|
</svg>
|
|
) : (
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm0 22.4C6.223 22.4 1.6 17.777 1.6 12S6.223 1.6 12 1.6s10.4 4.623 10.4 10.4s-4.623 10.4-10.4 10.4zm3.322-8.48c.188.533.188 1.123 0 1.656-.188.533-.524.99-.927 1.24-.403.25-.945.328-1.42.202-.475-.126-.898-.415-1.196-.82-.298-.405-.476-.92-.476-1.47 0-.55.178-1.065.476-1.47.298-.405.721-.694 1.196-.82.475-.126 1.017-.048 1.42.202.403.25.739.707.927 1.24zM10.5 12.5c0-.45.35-.8.8-.8.45 0 .8.35.8.8s-.35.8-.8.8c-.45 0-.8-.35-.8-.8zm4.5 0c0-.45.35-.8.8-.8.45 0 .8.35.8.8s-.35.8-.8.8c-.45 0-.8-.35-.8-.8z" />
|
|
</svg>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h3 className="font-semibold text-lg" aria-label={`${connection.provider} calendar`}>
|
|
{connection.provider.charAt(0).toUpperCase() + connection.provider.slice(1)} Calendar
|
|
</h3>
|
|
<div className="text-sm text-gray-600 mt-1">
|
|
{connection.calendars.length} calendar{connection.calendars.length !== 1 ? 's' : ''} connected
|
|
</div>
|
|
<div className="text-xs text-gray-500 mt-1">
|
|
Last sync: {formatDate(lastSyncTime[connection.id])}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
onClick={() => handleManualSync(connection.id)}
|
|
disabled={!!isSyncing[connection.id]}
|
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${isSyncing[connection.id]
|
|
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
|
: 'bg-blue-600 text-white hover:bg-blue-700'
|
|
}`}
|
|
aria-label={`Sync ${connection.provider} calendar`}
|
|
>
|
|
{isSyncing[connection.id] ? (
|
|
<>
|
|
<div className="weekly-spinner -ml-1 mr-2 inline-block"></div>
|
|
Syncing...
|
|
</>
|
|
) : (
|
|
'Sync Now'
|
|
)}
|
|
</button>
|
|
|
|
{showConfirmation === connection.id ? (
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => confirmRemoveConnection(connection.id)}
|
|
className="px-4 py-2 bg-red-600 text-white rounded-md text-sm font-medium hover:bg-red-700 transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
|
aria-label="Confirm disconnect calendar"
|
|
>
|
|
Confirm
|
|
</button>
|
|
<button
|
|
onClick={cancelRemoveConnection}
|
|
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md text-sm font-medium hover:bg-gray-400 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
|
|
aria-label="Cancel disconnect"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => handleRemoveConnection(connection.id)}
|
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md text-sm font-medium hover:bg-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
|
|
aria-label={`Disconnect ${connection.provider} calendar`}
|
|
>
|
|
Disconnect
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
<div className="connect-options mt-8 pt-6 border-t border-gray-200" role="region" aria-label="Connect more calendars">
|
|
<h3 className="text-xl font-semibold text-gray-800 mb-4" role="heading" aria-level={3}>Add More Calendars</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<button
|
|
onClick={handleGoogleConnect}
|
|
className="flex items-center justify-center gap-2 p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
|
aria-label="Connect Google Calendar"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-blue-500" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
<path d="M12.24 10.285V14.4h6.806c-.275 1.765-2.056 5.174-6.806 5.174-4.095 0-7.439-3.389-7.439-7.574s3.345-7.574 7.439-7.574c2.33 0 3.891.989 4.785 1.849l3.254-3.138C18.189 1.186 15.478 0 12.24 0c-6.635 0-12 5.365-12 12s5.365 12 12 12c6.926 0 11.52-4.869 11.52-11.726 0-.788-.084-1.39-.197-1.967H12.24z" />
|
|
</svg>
|
|
<span>Connect Google Calendar</span>
|
|
</button>
|
|
<button
|
|
onClick={handleAppleConnect}
|
|
className="flex items-center justify-center gap-2 p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
|
aria-label="Connect Apple Calendar"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-purple-500" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm0 22.4C6.223 22.4 1.6 17.777 1.6 12S6.223 1.6 12 1.6s10.4 4.623 10.4 10.4s-4.623 10.4-10.4 10.4zm3.322-8.48c.188.533.188 1.123 0 1.656-.188.533-.524.99-.927 1.24-.403.25-.945.328-1.42.202-.475-.126-.898-.415-1.196-.82-.298-.405-.476-.92-.476-1.47 0-.55.178-1.065.476-1.47.298-.405.721-.694 1.196-.82.475-.126 1.017-.048 1.42.202.403.25.739.707.927 1.24zM10.5 12.5c0-.45.35-.8.8-.8.45 0 .8.35.8.8s-.35.8-.8.8c-.45 0-.8-.35-.8-.8zm4.5 0c0-.45.35-.8.8-.8.45 0 .8.35.8.8s-.35.8-.8.8c-.45 0-.8-.35-.8-.8z" />
|
|
</svg>
|
|
<span>Connect Apple Calendar</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Apple Calendar Connection Modal */}
|
|
{
|
|
showAppleModal && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-lg p-6 max-w-md w-full shadow-xl">
|
|
<h3 className="text-xl font-bold mb-4">Connect Apple Account</h3>
|
|
|
|
{!needs2FA ? (
|
|
<>
|
|
<p className="text-sm text-gray-600 mb-4">
|
|
Enter your <strong>Apple ID</strong> and <strong>password</strong> to connect your iCloud Calendar and Reminders.
|
|
A security code will be sent to your Apple devices for verification.
|
|
</p>
|
|
|
|
{appleError && (
|
|
<div className="bg-red-50 text-red-600 p-3 rounded mb-4 text-sm">
|
|
{appleError}
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Apple ID (Email)</label>
|
|
<input
|
|
type="email"
|
|
value={appleEmail}
|
|
onChange={(e) => setAppleEmail(e.target.value)}
|
|
className="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
placeholder="name@icloud.com"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
|
<input
|
|
type="password"
|
|
value={applePassword}
|
|
onChange={(e) => setApplePassword(e.target.value)}
|
|
className="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
placeholder="Enter your Apple ID password"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 mt-6">
|
|
<button
|
|
onClick={() => setShowAppleModal(false)}
|
|
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
|
disabled={isConnectingApple}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={submitAppleConnection}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-300 flex items-center"
|
|
disabled={isConnectingApple}
|
|
>
|
|
{isConnectingApple ? (
|
|
<>
|
|
<div className="weekly-spinner weekly-spinner-white -ml-1 mr-2"></div>
|
|
Connecting...
|
|
</>
|
|
) : 'Connect'}
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<p className="text-sm text-gray-600 mb-4">
|
|
A <strong>security code</strong> has been sent to your Apple devices.
|
|
Please enter the 6-digit code below to complete the connection.
|
|
</p>
|
|
|
|
{appleError && (
|
|
<div className="bg-red-50 text-red-600 p-3 rounded mb-4 text-sm">
|
|
{appleError}
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Security Code</label>
|
|
<input
|
|
type="text"
|
|
value={securityCode}
|
|
onChange={(e) => setSecurityCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
className="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none text-center text-2xl tracking-widest"
|
|
placeholder="000000"
|
|
maxLength={6}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 mt-6">
|
|
<button
|
|
onClick={() => { setNeeds2FA(false); setSecurityCode(''); setAppleError(''); }}
|
|
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
|
disabled={isConnectingApple}
|
|
>
|
|
Back
|
|
</button>
|
|
<button
|
|
onClick={submitSecurityCode}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-300 flex items-center"
|
|
disabled={isConnectingApple || securityCode.length < 6}
|
|
>
|
|
{isConnectingApple ? (
|
|
<>
|
|
<div className="weekly-spinner weekly-spinner-white -ml-1 mr-2"></div>
|
|
Verifying...
|
|
</>
|
|
) : 'Verify'}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
</div >
|
|
</div >
|
|
)
|
|
}
|
|
</div >
|
|
);
|
|
};
|
|
|
|
export default CalendarSettings; |