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
This commit is contained in:
parent
43e5296db4
commit
0bac30827c
@ -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;
|
||||
}
|
||||
}
|
||||
180
src/components/AuthForm.tsx
Normal file
180
src/components/AuthForm.tsx
Normal file
@ -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<AuthFormProps> = ({ 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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8 bg-white rounded-xl shadow-lg p-8">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-16 w-16 flex items-center justify-center rounded-full bg-blue-100 mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
<h2 className="mt-2 text-3xl font-extrabold text-gray-900">
|
||||
{type === 'signup' ? 'Create your account' : 'Sign in to your account'}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
{type === 'signup'
|
||||
? 'Join us today and organize your week'
|
||||
: 'Welcome back! Please enter your details'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 border border-red-200">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="mt-6 space-y-6" onSubmit={handleSubmit}>
|
||||
<input type="hidden" name="remember" defaultValue="true" />
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div className="mb-4">
|
||||
<label htmlFor="email-address" className="sr-only block text-sm font-medium text-gray-700 mb-1">
|
||||
Email address
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
|
||||
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="email-address"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="text-gray-500 hover:text-gray-700 focus:outline-none"
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
|
||||
<path fillRule="evenodd" d="M.458 10C1.732 5.943 6.512 3 12 3s10.268 2.943 11.542 7c-1.274 4.057-6.054 7-11.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.512 10C18.25 5.957 14.453 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z" clipRule="evenodd" />
|
||||
<path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 6.056 7 11.542 7 .346 0 .687-.022 1.004-.028z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Password must be at least 8 characters
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 transition-colors duration-200"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
) : type === 'signup' ? (
|
||||
<span>Create Account</span>
|
||||
) : (
|
||||
<span>Sign in</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="text-center text-sm text-gray-600">
|
||||
{type === 'signup' ? (
|
||||
<p>
|
||||
Already have an account?{' '}
|
||||
<button
|
||||
onClick={() => window.location.hash = '#login'}
|
||||
className="font-medium text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
Don't have an account?{' '}
|
||||
<button
|
||||
onClick={() => window.location.hash = '#signup'}
|
||||
className="font-medium text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
Create account
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthForm;
|
||||
244
src/components/CalendarSettings.tsx
Normal file
244
src/components/CalendarSettings.tsx
Normal file
@ -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<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);
|
||||
|
||||
// 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 (
|
||||
<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] ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 inline" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarSettings;
|
||||
@ -12,9 +12,10 @@ interface TaskFormData {
|
||||
|
||||
interface TaskFormProps {
|
||||
onSubmit: (task: TaskFormData) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const TaskForm: React.FC<TaskFormProps> = ({ onSubmit }) => {
|
||||
const TaskForm: React.FC<TaskFormProps> = ({ onSubmit, isLoading = false }) => {
|
||||
const [formData, setFormData] = useState<TaskFormData>({
|
||||
title: '',
|
||||
description: '',
|
||||
@ -22,6 +23,46 @@ const TaskForm: React.FC<TaskFormProps> = ({ onSubmit }) => {
|
||||
endTime: '',
|
||||
dayOfWeek: 1 // Default to Monday
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
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<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
@ -29,10 +70,21 @@ const TaskForm: React.FC<TaskFormProps> = ({ 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();
|
||||
|
||||
if (validateForm()) {
|
||||
onSubmit(formData);
|
||||
// Reset form
|
||||
setFormData({
|
||||
@ -42,64 +94,94 @@ const TaskForm: React.FC<TaskFormProps> = ({ onSubmit }) => {
|
||||
endTime: '',
|
||||
dayOfWeek: 1
|
||||
});
|
||||
setErrors({});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="task-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="title">Task Title *</label>
|
||||
<form onSubmit={handleSubmit} className="task-form bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">Add New Task</h3>
|
||||
|
||||
<div className="form-group mb-4">
|
||||
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Task Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.title ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="What needs to be done?"
|
||||
required
|
||||
/>
|
||||
{errors.title && <p className="mt-1 text-sm text-red-600">{errors.title}</p>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="description">Description</label>
|
||||
<div className="form-group mb-4">
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Add details..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div className="form-group">
|
||||
<label htmlFor="startTime">Start Time</label>
|
||||
<label htmlFor="startTime" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Start Time
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
id="startTime"
|
||||
name="startTime"
|
||||
value={formData.startTime}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.startTime ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
{errors.startTime && <p className="mt-1 text-sm text-red-600">{errors.startTime}</p>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="endTime">End Time</label>
|
||||
<label htmlFor="endTime" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
End Time
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
id="endTime"
|
||||
name="endTime"
|
||||
value={formData.endTime}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.endTime ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
{errors.endTime && <p className="mt-1 text-sm text-red-600">{errors.endTime}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="dayOfWeek">Day of Week</label>
|
||||
<div className="form-group mb-4">
|
||||
<label htmlFor="dayOfWeek" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Day of Week
|
||||
</label>
|
||||
<select
|
||||
id="dayOfWeek"
|
||||
name="dayOfWeek"
|
||||
value={formData.dayOfWeek}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value={0}>Sunday</option>
|
||||
<option value={1}>Monday</option>
|
||||
@ -111,8 +193,26 @@ const TaskForm: React.FC<TaskFormProps> = ({ onSubmit }) => {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="submit-button">
|
||||
Add Task
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={`w-full py-2 px-4 rounded-md text-white font-medium transition-colors ${
|
||||
isLoading
|
||||
? 'bg-blue-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Adding Task...
|
||||
</span>
|
||||
) : (
|
||||
'Add Task'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@ -20,6 +20,7 @@ export default function TaskItem({ task, onToggleComplete, onDelete, onEdit }: T
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedTitle, setEditedTitle] = useState(task.title);
|
||||
const [editedDescription, setEditedDescription] = useState(task.description || '');
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleSave = () => {
|
||||
onEdit({ ...task, title: editedTitle, description: editedDescription });
|
||||
@ -32,31 +33,46 @@ export default function TaskItem({ task, onToggleComplete, onDelete, onEdit }: T
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setIsDeleting(true);
|
||||
// Add a small delay to allow the UI to update
|
||||
setTimeout(() => {
|
||||
onDelete(task.id);
|
||||
setIsDeleting(false);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="border rounded-lg p-4 mb-2 bg-white shadow-sm">
|
||||
<div className="border border-gray-200 rounded-lg p-4 mb-2 bg-white shadow-sm transition-all duration-200">
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
value={editedTitle}
|
||||
onChange={(e) => setEditedTitle(e.target.value)}
|
||||
className="w-full p-2 border rounded mb-2 text-lg font-semibold"
|
||||
className="w-full p-2 border border-gray-300 rounded-md text-lg font-semibold focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Task title"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<textarea
|
||||
value={editedDescription}
|
||||
onChange={(e) => setEditedDescription(e.target.value)}
|
||||
className="w-full p-2 border rounded mb-2"
|
||||
className="w-full p-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={2}
|
||||
placeholder="Task description"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-3 py-1 bg-gray-200 rounded hover:bg-gray-300"
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
@ -66,41 +82,63 @@ export default function TaskItem({ task, onToggleComplete, onDelete, onEdit }: T
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-4 mb-2 bg-white shadow-sm">
|
||||
<div className={`border border-gray-200 rounded-lg p-4 mb-2 bg-white shadow-sm transition-all duration-200 ${
|
||||
isDeleting ? 'opacity-50' : 'hover:shadow-md'
|
||||
}`}>
|
||||
<div className="flex items-start">
|
||||
<div className="flex items-center mr-3 mt-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={task.completed}
|
||||
onChange={() => onToggleComplete(task.id)}
|
||||
className="mt-1 mr-3 h-5 w-5"
|
||||
className="h-5 w-5 text-blue-600 rounded focus:ring-blue-500"
|
||||
aria-label={task.completed ? "Mark task as incomplete" : "Mark task as complete"}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`text-lg font-semibold ${task.completed ? 'line-through text-gray-500' : ''}`}>
|
||||
<h3 className={`text-lg font-semibold ${task.completed ? 'line-through text-gray-500' : 'text-gray-800'}`}>
|
||||
{task.title}
|
||||
</h3>
|
||||
{task.description && (
|
||||
<p className={`mt-1 ${task.completed ? 'line-through text-gray-500' : 'text-gray-700'}`}>
|
||||
<p className={`mt-2 ${task.completed ? 'line-through text-gray-500' : 'text-gray-700'}`}>
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Created: {new Date(task.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
<div className="flex items-center mt-3 text-xs text-gray-500">
|
||||
<span>Created: {new Date(task.createdAt).toLocaleDateString()}</span>
|
||||
{task.updatedAt && task.updatedAt.getTime() !== task.createdAt.getTime() && (
|
||||
<span className="ml-3">Updated: {new Date(task.updatedAt).toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2 ml-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className="text-blue-500 hover:text-blue-700"
|
||||
className="p-2 text-gray-500 hover:text-blue-600 rounded-full hover:bg-gray-100 transition-colors"
|
||||
aria-label="Edit task"
|
||||
>
|
||||
Edit
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(task.id)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="p-2 text-gray-500 hover:text-red-600 rounded-full hover:bg-gray-100 transition-colors"
|
||||
aria-label="Delete task"
|
||||
>
|
||||
Delete
|
||||
{isDeleting ? (
|
||||
<svg className="animate-spin h-5 w-5 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import WeeklyTaskItem from './WeeklyTaskItem';
|
||||
import TaskItem from './TaskItem';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
@ -9,29 +9,48 @@ interface Task {
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
dayOfWeek?: number; // 0 = Sunday, 1 = Monday, etc.
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface TaskListProps {
|
||||
tasks: Task[];
|
||||
onTaskToggleComplete?: (id: string) => void;
|
||||
onTaskDelete?: (id: string) => void;
|
||||
onTaskEdit?: (task: Task) => void;
|
||||
onTaskClick?: (task: Task) => void;
|
||||
}
|
||||
|
||||
const TaskList: React.FC<TaskListProps> = ({ tasks, onTaskClick }) => {
|
||||
const TaskList: React.FC<TaskListProps> = ({
|
||||
tasks,
|
||||
onTaskToggleComplete,
|
||||
onTaskDelete,
|
||||
onTaskEdit,
|
||||
onTaskClick
|
||||
}) => {
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<p>No tasks found</p>
|
||||
<div className="empty-state text-center py-8">
|
||||
<div className="mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-12 w-12 mx-auto text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gray-600">No tasks for this day</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Add a new task using the form above</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="task-list">
|
||||
<ul className="task-list space-y-2">
|
||||
{tasks.map((task) => (
|
||||
<li key={task.id}>
|
||||
<WeeklyTaskItem
|
||||
<TaskItem
|
||||
task={task}
|
||||
onClick={() => onTaskClick?.(task)}
|
||||
onToggleComplete={onTaskToggleComplete}
|
||||
onDelete={onTaskDelete}
|
||||
onEdit={onTaskEdit}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
|
||||
336
src/components/WeeklyView.tsx
Normal file
336
src/components/WeeklyView.tsx
Normal file
@ -0,0 +1,336 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import TaskList from './TaskList';
|
||||
import { CalendarEvent } from '@/lib/calendar-events';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
completed: boolean;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
dayOfWeek?: number; // 0 = Sunday, 1 = Monday, etc.
|
||||
}
|
||||
|
||||
interface CalendarConnection {
|
||||
id: string;
|
||||
provider: 'google' | 'apple';
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: Date;
|
||||
calendars: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
isPrimary?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface WeeklyCalendarViewProps {
|
||||
tasks: Task[];
|
||||
calendarConnections: CalendarConnection[];
|
||||
onTaskDrop?: (taskId: string, newDayOfWeek: number) => void;
|
||||
}
|
||||
|
||||
const WeeklyCalendarView: React.FC<WeeklyCalendarViewProps> = ({
|
||||
tasks,
|
||||
calendarConnections,
|
||||
onTaskDrop
|
||||
}) => {
|
||||
const [currentWeekStart, setCurrentWeekStart] = useState<Date>(new Date());
|
||||
const [calendarEvents, setCalendarEvents] = useState<CalendarEvent[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Days of the week
|
||||
const daysOfWeek = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
|
||||
// Memoized functions for performance
|
||||
const getTasksForDay = useCallback((dayIndex: number): Task[] => {
|
||||
return tasks.filter(task => task.dayOfWeek === dayIndex);
|
||||
}, [tasks]);
|
||||
|
||||
const getCalendarEventsForDay = useCallback((dayIndex: number): CalendarEvent[] => {
|
||||
// Convert day index to date for comparison
|
||||
const dayDate = new Date(currentWeekStart);
|
||||
dayDate.setDate(dayDate.getDate() + dayIndex);
|
||||
|
||||
return calendarEvents.filter(event => {
|
||||
const eventStart = event.start.dateTime
|
||||
? new Date(event.start.dateTime)
|
||||
: event.start.date
|
||||
? new Date(event.start.date)
|
||||
: null;
|
||||
|
||||
if (!eventStart) return false;
|
||||
|
||||
// Check if the event occurs on the same day
|
||||
return eventStart.getDate() === dayDate.getDate() &&
|
||||
eventStart.getMonth() === dayDate.getMonth() &&
|
||||
eventStart.getFullYear() === dayDate.getFullYear();
|
||||
});
|
||||
}, [calendarEvents, currentWeekStart]);
|
||||
|
||||
// Function to handle drag start
|
||||
const handleDragStart = (e: React.DragEvent, taskId: string) => {
|
||||
e.dataTransfer.setData('taskId', taskId);
|
||||
};
|
||||
|
||||
// Function to handle drag over
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
// Function to handle drop
|
||||
const handleDrop = (e: React.DragEvent, dayIndex: number) => {
|
||||
e.preventDefault();
|
||||
const taskId = e.dataTransfer.getData('taskId');
|
||||
if (onTaskDrop) {
|
||||
onTaskDrop(taskId, dayIndex);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to goToPreviousWeek
|
||||
const goToPreviousWeek = () => {
|
||||
const newDate = new Date(currentWeekStart);
|
||||
newDate.setDate(newDate.getDate() - 7);
|
||||
setCurrentWeekStart(newDate);
|
||||
};
|
||||
|
||||
// Function to goToNextWeek
|
||||
const goToNextWeek = () => {
|
||||
const newDate = new Date(currentWeekStart);
|
||||
newDate.setDate(newDate.getDate() + 7);
|
||||
setCurrentWeekStart(newDate);
|
||||
};
|
||||
|
||||
// Get current week range
|
||||
const getWeekRange = useCallback((): { start: string; end: string } => {
|
||||
const start = new Date(currentWeekStart);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 6);
|
||||
|
||||
return {
|
||||
start: start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||
end: end.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
};
|
||||
}, [currentWeekStart]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
goToPreviousWeek();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
goToNextWeek();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [currentWeekStart]);
|
||||
|
||||
// Fetch calendar events (real implementation)
|
||||
useEffect(() => {
|
||||
// This is where we'd actually fetch events from the calendar providers
|
||||
// For now, we'll simulate with sample data to make it clear what's happening
|
||||
const fetchCalendarEvents = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// In a real implementation, we would make an API call to fetch actual events
|
||||
// Example API call:
|
||||
/*
|
||||
const response = await fetch('/api/calendar/events', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
timeMin: currentWeekStart.toISOString(),
|
||||
timeMax: new Date(currentWeekStart.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
});
|
||||
const events = await response.json();
|
||||
setCalendarEvents(events);
|
||||
*/
|
||||
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// For now, we'll keep the mock data to show the structure
|
||||
const mockEvents: CalendarEvent[] = [
|
||||
{
|
||||
id: 'cal-event-1',
|
||||
title: 'Team Meeting',
|
||||
description: 'Weekly team sync',
|
||||
start: { dateTime: '2026-01-25T10:00:00Z' },
|
||||
end: { dateTime: '2026-01-25T11:00:00Z' },
|
||||
location: 'Conference Room A',
|
||||
source: 'google',
|
||||
calendarId: 'primary',
|
||||
calendarTitle: 'Primary Calendar'
|
||||
},
|
||||
{
|
||||
id: 'cal-event-2',
|
||||
title: 'Lunch with Alex',
|
||||
description: 'Discuss project details',
|
||||
start: { dateTime: '2026-01-26T12:30:00Z' },
|
||||
end: { dateTime: '2026-01-26T13:30:00Z' },
|
||||
location: 'Café Downtown',
|
||||
source: 'apple',
|
||||
calendarId: 'work-calendar',
|
||||
calendarTitle: 'Work Calendar'
|
||||
}
|
||||
];
|
||||
|
||||
setCalendarEvents(mockEvents);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch calendar events:', err);
|
||||
setError('Failed to load calendar events');
|
||||
setCalendarEvents([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCalendarEvents();
|
||||
}, [currentWeekStart]);
|
||||
|
||||
const weekRange = getWeekRange();
|
||||
|
||||
// Combine tasks and calendar events for each day
|
||||
const getCombinedItemsForDay = useCallback((dayIndex: number) => {
|
||||
const dayTasks = getTasksForDay(dayIndex);
|
||||
const dayEvents = getCalendarEventsForDay(dayIndex);
|
||||
|
||||
// Combine and sort by time
|
||||
const combinedItems = [...dayTasks, ...dayEvents];
|
||||
|
||||
// Sort by time if available, otherwise by title
|
||||
combinedItems.sort((a, b) => {
|
||||
// For tasks, we don't have a time field, so we just sort by title
|
||||
if ('startTime' in a && 'startTime' in b) {
|
||||
return (a.startTime || '').localeCompare(b.startTime || '');
|
||||
}
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
|
||||
return combinedItems;
|
||||
}, [getTasksForDay, getCalendarEventsForDay]);
|
||||
|
||||
// Loading and error states
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="weekly-calendar-view" role="status" aria-label="Loading calendar data">
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500" role="img" aria-label="Loading indicator"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="weekly-calendar-view p-4 bg-red-50 border border-red-200 rounded-lg" role="alert">
|
||||
<div className="text-red-700 font-medium">Error: {error}</div>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-2 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
||||
aria-label="Retry loading"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="weekly-calendar-view" role="region" aria-label="Weekly calendar view">
|
||||
<div className="calendar-header mb-6">
|
||||
<div className="week-navigation flex flex-col md:flex-row items-center justify-between gap-4" role="navigation">
|
||||
<button
|
||||
onClick={goToPreviousWeek}
|
||||
className="nav-button px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-md flex items-center transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
aria-label="Previous week"
|
||||
>
|
||||
<span className="mr-2">←</span> Previous Week
|
||||
</button>
|
||||
|
||||
<span className="week-range text-xl font-semibold text-gray-800" aria-live="polite">
|
||||
{weekRange.start} - {weekRange.end}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={goToNextWeek}
|
||||
className="nav-button px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-md flex items-center transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
aria-label="Next week"
|
||||
>
|
||||
Next Week <span className="ml-2">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="calendar-grid grid grid-cols-1 md:grid-cols-7 gap-4">
|
||||
{daysOfWeek.map((day, index) => (
|
||||
<div
|
||||
key={day}
|
||||
className="day-column bg-white rounded-lg shadow-md overflow-hidden transition-all hover:shadow-lg focus-within:ring-2 focus-within:ring-blue-500"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
aria-label={`${day} column`}
|
||||
>
|
||||
<div className="day-header bg-blue-600 text-white p-3 text-center" role="heading" aria-level="3">
|
||||
<h3 className="font-semibold text-lg">{day}</h3>
|
||||
</div>
|
||||
<div className="day-content p-3 min-h-[300px]" role="region" aria-label={`${day} content`}>
|
||||
{/* Render calendar events */}
|
||||
{getCalendarEventsForDay(index).map(event => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={`calendar-event mb-3 p-3 rounded-lg border-l-4 ${
|
||||
event.source === 'google'
|
||||
? 'border-l-blue-500 bg-blue-50'
|
||||
: 'border-l-purple-500 bg-purple-50'
|
||||
}`}
|
||||
aria-label={`${event.title} calendar event`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<strong className="font-medium">{event.title}</strong>
|
||||
{event.location && <div className="text-sm text-gray-600 mt-1">📍 {event.location}</div>}
|
||||
</div>
|
||||
{event.start.dateTime && (
|
||||
<div className="text-sm font-medium text-blue-600">
|
||||
{new Date(event.start.dateTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{event.description && (
|
||||
<p className="text-sm text-gray-600 mt-2">{event.description}</p>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{event.calendarTitle}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Render tasks */}
|
||||
<TaskList
|
||||
tasks={getTasksForDay(index)}
|
||||
onTaskToggleComplete={(id) => {}}
|
||||
onTaskDelete={(id) => {}}
|
||||
onTaskEdit={(task) => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WeeklyCalendarView;
|
||||
Loading…
Reference in New Issue
Block a user