From 0bac30827ce8091b3c381f909f5fe36289f9307f Mon Sep 17 00:00:00 2001 From: mARTin Date: Mon, 26 Jan 2026 00:12:00 +0100 Subject: [PATCH] feat(04-01): polish weekly view component with enhanced styling and interactions - Improved calendar event display with better visual distinction - Enhanced drag-and-drop feedback and visual cues - Added loading states and error handling - Improved responsive design and accessibility - Updated styling for consistency across components - Refined calendar settings UI with clearer visual feedback - Added better sync status indicators - Improved connection management with confirmation dialogs - Enhanced error handling and user feedback - Standardized task form styling with better validation - Improved form accessibility and user feedback - Added loading states for task operations - Enhanced task item styling with better visual hierarchy - Improved edit/delete functionality with icons - Added better hover states and transitions - Refined task list with better empty state messaging - Improved accessibility for screen readers - Enhanced loading indicators --- src/app/globals.css | 225 +++++++++++++++---- src/components/AuthForm.tsx | 180 +++++++++++++++ src/components/CalendarSettings.tsx | 244 ++++++++++++++++++++ src/components/TaskForm.tsx | 144 ++++++++++-- src/components/TaskItem.tsx | 102 ++++++--- src/components/TaskList.tsx | 33 ++- src/components/WeeklyView.tsx | 336 ++++++++++++++++++++++++++++ 7 files changed, 1164 insertions(+), 100 deletions(-) create mode 100644 src/components/AuthForm.tsx create mode 100644 src/components/CalendarSettings.tsx create mode 100644 src/components/WeeklyView.tsx diff --git a/src/app/globals.css b/src/app/globals.css index b35f73a..047f44f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -8,29 +8,33 @@ } body { - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - background-color: #f5f7fa; - color: #333; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + background-color: #f8fafc; + color: #1e293b; line-height: 1.6; + transition: background-color 0.3s ease; } /* Typography */ h1 { - font-size: 2rem; + font-size: 2.25rem; margin-bottom: 1rem; - color: #2c3e50; + color: #0f172a; + font-weight: 700; } h2 { - font-size: 1.5rem; + font-size: 1.875rem; margin-bottom: 0.75rem; - color: #34495e; + color: #0f172a; + font-weight: 600; } h3 { - font-size: 1.25rem; + font-size: 1.5rem; margin-bottom: 0.5rem; - color: #2c3e50; + color: #1e293b; + font-weight: 600; } /* Layout */ @@ -44,28 +48,42 @@ h3 { .btn { padding: 0.5rem 1rem; border: none; - border-radius: 4px; + border-radius: 6px; cursor: pointer; font-weight: 500; - transition: background-color 0.2s ease; + transition: all 0.2s ease; + font-size: 0.875rem; } .btn-primary { - background-color: #3498db; + background-color: #3b82f6; color: white; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); } .btn-primary:hover { - background-color: #2980b9; + background-color: #2563eb; + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .btn-secondary { - background-color: #95a5a6; + background-color: #94a3b8; color: white; } .btn-secondary:hover { - background-color: #7f8c8d; + background-color: #64748b; +} + +.btn-outline { + background-color: transparent; + border: 1px solid #cbd5e1; + color: #64748b; +} + +.btn-outline:hover { + background-color: #f1f5f9; } /* Forms */ @@ -77,16 +95,26 @@ h3 { display: block; margin-bottom: 0.25rem; font-weight: 500; + color: #334155; } .form-group input, .form-group textarea, .form-group select { width: 100%; - padding: 0.5rem; - border: 1px solid #ddd; - border-radius: 4px; + padding: 0.5rem 0.75rem; + border: 1px solid #cbd5e1; + border-radius: 6px; font-size: 1rem; + transition: border-color 0.2s ease; +} + +.form-group input:focus, +.form-group textarea:focus, +.form-group select:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); } .form-row { @@ -111,15 +139,17 @@ h3 { /* Task Item */ .task-item { background: white; - border-radius: 6px; + border-radius: 8px; padding: 1rem; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); + box-shadow: 0 1px 3px rgba(0,0,0,0.05); cursor: pointer; - transition: box-shadow 0.2s ease; + transition: all 0.2s ease; + border: 1px solid #e2e8f0; } .task-item:hover { - box-shadow: 0 2px 6px rgba(0,0,0,0.15); + box-shadow: 0 2px 6px rgba(0,0,0,0.1); + transform: translateY(-1px); } .task-item.completed { @@ -128,17 +158,17 @@ h3 { .task-item h4 { margin: 0 0 0.5rem 0; - color: #2c3e50; + color: #0f172a; } .task-item p { margin: 0 0 0.5rem 0; - color: #7f8c8d; + color: #64748b; } .time-indicator { font-size: 0.875rem; - color: #3498db; + color: #3b82f6; font-weight: 500; } @@ -160,21 +190,26 @@ h3 { .nav-button { padding: 0.5rem 1rem; - background-color: #ecf0f1; + background-color: #f1f5f9; border: none; - border-radius: 4px; + border-radius: 6px; cursor: pointer; font-weight: 500; - transition: background-color 0.2s ease; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 0.25rem; } .nav-button:hover { - background-color: #d5dbdb; + background-color: #e2e8f0; + transform: translateY(-1px); } .week-range { - font-weight: 500; + font-weight: 600; font-size: 1.125rem; + color: #1e293b; } .calendar-grid { @@ -185,16 +220,23 @@ h3 { .day-column { background: white; - border-radius: 6px; + border-radius: 8px; overflow: hidden; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); + box-shadow: 0 1px 3px rgba(0,0,0,0.05); + border: 1px solid #e2e8f0; + transition: all 0.2s ease; +} + +.day-column:hover { + box-shadow: 0 2px 6px rgba(0,0,0,0.08); } .day-header { - background-color: #3498db; + background-color: #3b82f6; color: white; padding: 0.75rem; text-align: center; + font-weight: 600; } .day-content { @@ -206,31 +248,79 @@ h3 { .task-form { background: white; padding: 1.5rem; - border-radius: 6px; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.05); margin-bottom: 2rem; + border: 1px solid #e2e8f0; } .submit-button { - background-color: #27ae60; + background-color: #22c55e; color: white; padding: 0.75rem 1.5rem; border: none; - border-radius: 4px; + border-radius: 6px; cursor: pointer; font-size: 1rem; font-weight: 500; + transition: all 0.2s ease; + width: 100%; } .submit-button:hover { - background-color: #219653; + background-color: #16a34a; + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } /* Empty state */ .empty-state { text-align: center; padding: 2rem; - color: #7f8c8d; + color: #64748b; + background-color: #f8fafc; + border-radius: 8px; + border: 1px solid #e2e8f0; +} + +/* Calendar Events */ +.calendar-event { + border-left-width: 4px; + border-left-style: solid; + margin-bottom: 0.75rem; + padding: 0.75rem; + border-radius: 0 6px 6px 0; + background-color: #f8fafc; + transition: all 0.2s ease; +} + +.calendar-event:hover { + transform: translateX(2px); + box-shadow: 0 1px 3px rgba(0,0,0,0.05); +} + +/* Auth Form */ +.auth-form { + background: white; + border-radius: 8px; + padding: 2rem; + box-shadow: 0 1px 3px rgba(0,0,0,0.05); + border: 1px solid #e2e8f0; +} + +/* Loading States */ +.loading-spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 3px solid rgba(255,255,255,.3); + border-radius: 50%; + border-top-color: #3b82f6; + animation: spin 1s ease-in-out infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } } /* Responsive Design */ @@ -252,4 +342,61 @@ h3 { .week-range { font-size: 1rem; } + + h1 { + font-size: 1.75rem; + } + + h2 { + font-size: 1.5rem; + } + + .day-header { + font-size: 0.875rem; + } +} + +/* Dark Mode Support */ +@media (prefers-color-scheme: dark) { + body { + background-color: #0f172a; + color: #f8fafc; + } + + h1, h2, h3 { + color: #f8fafc; + } + + .task-item, .task-form, .day-column, .calendar-settings, .auth-form { + background-color: #1e293b; + border-color: #334155; + } + + .form-group input, + .form-group textarea, + .form-group select { + background-color: #1e293b; + border-color: #334155; + color: #f8fafc; + } + + .nav-button { + background-color: #334155; + color: #f8fafc; + } + + .nav-button:hover { + background-color: #475569; + } + + .empty-state { + background-color: #1e293b; + border-color: #334155; + color: #94a3b8; + } + + .calendar-event { + background-color: #1e293b; + border-color: #334155; + } } \ No newline at end of file diff --git a/src/components/AuthForm.tsx b/src/components/AuthForm.tsx new file mode 100644 index 0000000..95c744d --- /dev/null +++ b/src/components/AuthForm.tsx @@ -0,0 +1,180 @@ +import React, { useState } from 'react'; + +interface AuthFormProps { + type: 'signup' | 'login'; + onSubmit: (data: { email: string; password: string }) => void; + isLoading: boolean; + error?: string; +} + +const AuthForm: React.FC = ({ type, onSubmit, isLoading, error }) => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit({ email, password }); + }; + + return ( +
+
+
+
+ + + +
+

+ {type === 'signup' ? 'Create your account' : 'Sign in to your account'} +

+

+ {type === 'signup' + ? 'Join us today and organize your week' + : 'Welcome back! Please enter your details'} +

+
+ + {error && ( +
+
+
+ + + +
+
+
{error}
+
+
+
+ )} + +
+ +
+
+ +
+
+ + + + +
+ setEmail(e.target.value)} + className="appearance-none rounded-md relative block w-full pl-10 px-3 py-3 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm" + placeholder="Email address" + /> +
+
+ +
+ +
+
+ + + +
+ setPassword(e.target.value)} + className="appearance-none rounded-md relative block w-full pl-10 px-3 py-3 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm" + placeholder="Password" + /> +
+ +
+
+

+ Password must be at least 8 characters +

+
+
+ +
+ +
+
+ +
+ {type === 'signup' ? ( +

+ Already have an account?{' '} + +

+ ) : ( +

+ Don't have an account?{' '} + +

+ )} +
+
+
+ ); +}; + +export default AuthForm; \ No newline at end of file diff --git a/src/components/CalendarSettings.tsx b/src/components/CalendarSettings.tsx new file mode 100644 index 0000000..3a43938 --- /dev/null +++ b/src/components/CalendarSettings.tsx @@ -0,0 +1,244 @@ +'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 = ({ + connections, + onConnectionAdded, + onConnectionRemoved, + onSyncTriggered +}) => { + const [isSyncing, setIsSyncing] = useState>({}); + const [lastSyncTime, setLastSyncTime] = useState>({}); + const [showConfirmation, setShowConfirmation] = useState(null); + + // 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 OAuth + const handleAppleConnect = useCallback(() => { + // In a real implementation, this would redirect to Apple OAuth flow + // For now, we'll just log the action + console.log('Redirecting to Apple OAuth flow...'); + // window.location.href = '/api/calendar/apple/oauth'; + }, []); + + // 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 ( +
+

Calendar Connections

+ + {connections.length === 0 ? ( +
+
+ +
+

No calendar connections found.

+

Connect your Google or Apple Calendar to get started.

+
+ ) : ( +
+ {connections.map(connection => ( +
+
+
+
+
+ {connection.provider === 'google' ? ( + + ) : ( + + )} +
+
+
+

+ {connection.provider.charAt(0).toUpperCase() + connection.provider.slice(1)} Calendar +

+
+ {connection.calendars.length} calendar{connection.calendars.length !== 1 ? 's' : ''} connected +
+
+ Last sync: {formatDate(lastSyncTime[connection.id])} +
+
+
+ +
+ + + {showConfirmation === connection.id ? ( +
+ + +
+ ) : ( + + )} +
+
+
+ ))} +
+ )} + +
+

Add More Calendars

+
+ + +
+
+
+ ); +}; + +export default CalendarSettings; \ No newline at end of file diff --git a/src/components/TaskForm.tsx b/src/components/TaskForm.tsx index f110a45..f4f9caf 100644 --- a/src/components/TaskForm.tsx +++ b/src/components/TaskForm.tsx @@ -12,9 +12,10 @@ interface TaskFormData { interface TaskFormProps { onSubmit: (task: TaskFormData) => void; + isLoading?: boolean; } -const TaskForm: React.FC = ({ onSubmit }) => { +const TaskForm: React.FC = ({ onSubmit, isLoading = false }) => { const [formData, setFormData] = useState({ title: '', description: '', @@ -22,6 +23,46 @@ const TaskForm: React.FC = ({ onSubmit }) => { endTime: '', dayOfWeek: 1 // Default to Monday }); + const [errors, setErrors] = useState>({}); + + const validateForm = (): boolean => { + const newErrors: Record = {}; + + if (!formData.title.trim()) { + newErrors.title = 'Title is required'; + } + + // Validate time format if provided + if (formData.startTime && !isValidTime(formData.startTime)) { + newErrors.startTime = 'Invalid time format'; + } + + if (formData.endTime && !isValidTime(formData.endTime)) { + newErrors.endTime = 'Invalid time format'; + } + + // Check if start time is before end time if both are provided + if (formData.startTime && formData.endTime) { + const start = parseTime(formData.startTime); + const end = parseTime(formData.endTime); + if (start >= end) { + newErrors.endTime = 'End time must be after start time'; + } + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const isValidTime = (time: string): boolean => { + const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/; + return timeRegex.test(time); + }; + + const parseTime = (time: string): number => { + const [hours, minutes] = time.split(':').map(Number); + return hours * 60 + minutes; + }; const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target; @@ -29,77 +70,118 @@ const TaskForm: React.FC = ({ onSubmit }) => { ...formData, [name]: value }); + + // Clear error when user starts typing + if (errors[name]) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors[name]; + return newErrors; + }); + } }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - onSubmit(formData); - // Reset form - setFormData({ - title: '', - description: '', - startTime: '', - endTime: '', - dayOfWeek: 1 - }); + + if (validateForm()) { + onSubmit(formData); + // Reset form + setFormData({ + title: '', + description: '', + startTime: '', + endTime: '', + dayOfWeek: 1 + }); + setErrors({}); + } }; return ( -
-
- + +

Add New Task

+ +
+ + {errors.title &&

{errors.title}

}
-
- +
+