My-Weekly-ToDo-List/src/components/CalendarSettings.tsx
mARTin 5c01869444 feat: comprehensive accessibility pass — ARIA, focus traps, keyboard move, live regions
- Modals (SearchModal, ImportListModal, CalendarEventModal): role=dialog,
  aria-modal, aria-label, focus trap via keydown Tab handler, aria-hidden on backdrop
- OnboardingWizard: role=dialog on card, aria-hidden on overlay, aria-current=step
  on progress dots wrapped in <nav><ol>, aria-label on nav buttons
- GridTaskBlock: aria-label on all ~12 action buttons (complete, edit, add subtask,
  notes, rolling, recurrence, project, link, delete, move), aria-expanded/aria-pressed
  where applicable, aria-hidden on all decorative SVGs, aria-label on inline edit
  textarea and subtask inputs; note indicator div → button
- Keyboard task move: new "Move Task" dialog in GridTaskBlock with date+time
  inputs, routed via onMoveTask prop → moveTaskToSlot in WeeklyView
- WeeklyView: visually-hidden aria-live="polite" region with announce() helper;
  announces task add, complete/incomplete, delete, sync complete/failed
- Forms: aria-invalid + aria-describedby + role=alert on error paragraphs in
  TaskForm; auth error div gets id + role=alert; email input gets aria-describedby;
  show/hide password button gets aria-label + aria-pressed
- Spinners: role=status + aria-label on standalone spinners (TaskItem delete,
  WeeklyView sync); aria-hidden on inline button spinners (AuthForm, TaskForm,
  CalendarSettings, SettingsSidebar, EmailAuthForm)
- layout.tsx: Open Graph and Twitter Card meta tags added

v1.85.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:18:08 +02:00

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" aria-hidden="true"></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" aria-hidden="true"></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" aria-hidden="true"></div>
Verifying...
</>
) : 'Verify'}
</button>
</div>
</>
)
}
</div >
</div >
)
}
</div >
);
};
export default CalendarSettings;