Initial commit: Complete NodeJS-native setup
- Migrated from Python pre-commit to NodeJS-native solution - Reorganized documentation structure - Set up Husky + lint-staged for efficient pre-commit hooks - Fixed Dockerfile healthcheck issue - Added comprehensive documentation index
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
# 🧩 Component Architecture
|
||||
|
||||
## Component Organization
|
||||
|
||||
The components are organized by feature and responsibility for better maintainability:
|
||||
|
||||
```
|
||||
components/
|
||||
├── medication/ # Medication-related components
|
||||
│ ├── AddMedicationModal.tsx
|
||||
│ ├── EditMedicationModal.tsx
|
||||
│ ├── ManageMedicationsModal.tsx
|
||||
│ ├── DoseCard.tsx
|
||||
│ └── index.ts
|
||||
├── auth/ # Authentication components
|
||||
│ ├── AuthPage.tsx
|
||||
│ ├── AvatarDropdown.tsx
|
||||
│ ├── ChangePasswordModal.tsx
|
||||
│ └── index.ts
|
||||
├── admin/ # Admin interface components
|
||||
│ ├── AdminInterface.tsx
|
||||
│ └── index.ts
|
||||
├── modals/ # Generic modal components
|
||||
│ ├── AccountModal.tsx
|
||||
│ ├── AddReminderModal.tsx
|
||||
│ ├── EditReminderModal.tsx
|
||||
│ ├── HistoryModal.tsx
|
||||
│ ├── ManageRemindersModal.tsx
|
||||
│ ├── OnboardingModal.tsx
|
||||
│ ├── StatsModal.tsx
|
||||
│ └── index.ts
|
||||
├── ui/ # Reusable UI components
|
||||
│ ├── BarChart.tsx
|
||||
│ ├── ReminderCard.tsx
|
||||
│ ├── ThemeSwitcher.tsx
|
||||
│ └── index.ts
|
||||
└── icons/ # Icon components
|
||||
└── Icons.tsx
|
||||
```
|
||||
|
||||
## Import Structure
|
||||
|
||||
### Feature-Based Imports
|
||||
|
||||
```tsx
|
||||
// Medication components
|
||||
import { AddMedicationModal, DoseCard } from './components/medication';
|
||||
|
||||
// Authentication components
|
||||
import { AuthPage, AvatarDropdown } from './components/auth';
|
||||
|
||||
// Modal components
|
||||
import { AccountModal, StatsModal } from './components/modals';
|
||||
|
||||
// UI components
|
||||
import { BarChart, ThemeSwitcher } from './components/ui';
|
||||
```
|
||||
|
||||
## Component Categories
|
||||
|
||||
### 🏥 **Medication Components**
|
||||
|
||||
- **Purpose**: Medication management and dose tracking
|
||||
- **Components**: AddMedicationModal, EditMedicationModal, ManageMedicationsModal, DoseCard
|
||||
- **Responsibility**: CRUD operations for medications and dose status management
|
||||
|
||||
### 🔐 **Authentication Components**
|
||||
|
||||
- **Purpose**: User authentication and profile management
|
||||
- **Components**: AuthPage, AvatarDropdown, ChangePasswordModal
|
||||
- **Responsibility**: Login/register, user menus, credential management
|
||||
|
||||
### 👑 **Admin Components**
|
||||
|
||||
- **Purpose**: Administrative functionality
|
||||
- **Components**: AdminInterface
|
||||
- **Responsibility**: User management, system administration
|
||||
|
||||
### 🎛️ **Modal Components**
|
||||
|
||||
- **Purpose**: Overlay interfaces for specific actions
|
||||
- **Components**: AccountModal, AddReminderModal, EditReminderModal, HistoryModal, ManageRemindersModal, OnboardingModal, StatsModal
|
||||
- **Responsibility**: Focused user interactions in modal format
|
||||
|
||||
### 🎨 **UI Components**
|
||||
|
||||
- **Purpose**: Reusable interface elements
|
||||
- **Components**: BarChart, ReminderCard, ThemeSwitcher
|
||||
- **Responsibility**: Visual presentation and data display
|
||||
|
||||
## Benefits of This Organization
|
||||
|
||||
✅ **Feature Cohesion** - Related components grouped together
|
||||
✅ **Easy Navigation** - Clear folder structure
|
||||
✅ **Reduced Import Complexity** - Index files for clean imports
|
||||
✅ **Better Maintainability** - Logical separation of concerns
|
||||
✅ **Scalability** - Easy to add new components in appropriate categories
|
||||
✅ **Testing** - Each feature can be tested independently
|
||||
@@ -0,0 +1,361 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { User, UserRole } from '../../types';
|
||||
import { AccountStatus } from '../../services/auth/auth.constants';
|
||||
import { dbService } from '../../services/couchdb.factory';
|
||||
import { useUser } from '../../contexts/UserContext';
|
||||
|
||||
interface AdminInterfaceProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
||||
const { user: currentUser } = useUser();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const allUsers = await dbService.getAllUsers();
|
||||
setUsers(allUsers);
|
||||
} catch (error) {
|
||||
setError('Failed to load users');
|
||||
console.error('Error loading users:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuspendUser = async (userId: string) => {
|
||||
try {
|
||||
await dbService.suspendUser(userId);
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
setError('Failed to suspend user');
|
||||
console.error('Error suspending user:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivateUser = async (userId: string) => {
|
||||
try {
|
||||
await dbService.activateUser(userId);
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
setError('Failed to activate user');
|
||||
console.error('Error activating user:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId: string) => {
|
||||
if (
|
||||
!confirm(
|
||||
'Are you sure you want to delete this user? This action cannot be undone.'
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await dbService.deleteUser(userId);
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
setError('Failed to delete user');
|
||||
console.error('Error deleting user:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async (userId: string) => {
|
||||
if (!newPassword || newPassword.length < 6) {
|
||||
setError('Password must be at least 6 characters long');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await dbService.changeUserPassword(userId, newPassword);
|
||||
setNewPassword('');
|
||||
setSelectedUser(null);
|
||||
setError('');
|
||||
alert('Password changed successfully');
|
||||
} catch (error) {
|
||||
setError('Failed to change password');
|
||||
console.error('Error changing password:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status?: AccountStatus) => {
|
||||
switch (status) {
|
||||
case AccountStatus.ACTIVE:
|
||||
return 'text-green-600 bg-green-100';
|
||||
case AccountStatus.SUSPENDED:
|
||||
return 'text-red-600 bg-red-100';
|
||||
case AccountStatus.PENDING:
|
||||
return 'text-yellow-600 bg-yellow-100';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-100';
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleColor = (role?: UserRole) => {
|
||||
return role === UserRole.ADMIN
|
||||
? 'text-purple-600 bg-purple-100'
|
||||
: 'text-blue-600 bg-blue-100';
|
||||
};
|
||||
|
||||
if (currentUser?.role !== UserRole.ADMIN) {
|
||||
return (
|
||||
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'>
|
||||
<div className='bg-white dark:bg-slate-800 rounded-lg p-6 max-w-md'>
|
||||
<h2 className='text-xl font-bold text-red-600 mb-4'>Access Denied</h2>
|
||||
<p className='text-slate-600 dark:text-slate-300 mb-4'>
|
||||
You don't have permission to access the admin interface.
|
||||
</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 px-4 rounded-md'
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4'>
|
||||
<div className='bg-white dark:bg-slate-800 rounded-lg w-full max-w-6xl max-h-[90vh] overflow-hidden'>
|
||||
<div className='p-6 border-b border-slate-200 dark:border-slate-600'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<h2 className='text-2xl font-bold text-slate-800 dark:text-slate-100'>
|
||||
Admin Interface
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
|
||||
>
|
||||
<svg
|
||||
className='w-6 h-6'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M6 18L18 6M6 6l12 12'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='p-6 overflow-y-auto max-h-[calc(90vh-120px)]'>
|
||||
{error && (
|
||||
<div className='bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4'>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className='text-center py-8'>
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto'></div>
|
||||
<p className='mt-2 text-slate-600 dark:text-slate-300'>
|
||||
Loading users...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<h3 className='text-lg font-semibold text-slate-800 dark:text-slate-100'>
|
||||
User Management ({users.length} users)
|
||||
</h3>
|
||||
<button
|
||||
onClick={loadUsers}
|
||||
className='bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-md text-sm'
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='min-w-full bg-white dark:bg-slate-700 rounded-lg overflow-hidden'>
|
||||
<thead className='bg-slate-50 dark:bg-slate-600'>
|
||||
<tr>
|
||||
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
|
||||
User
|
||||
</th>
|
||||
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
|
||||
Email
|
||||
</th>
|
||||
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
|
||||
Status
|
||||
</th>
|
||||
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
|
||||
Role
|
||||
</th>
|
||||
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
|
||||
Created
|
||||
</th>
|
||||
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='divide-y divide-slate-200 dark:divide-slate-600'>
|
||||
{users.map(user => (
|
||||
<tr
|
||||
key={user._id}
|
||||
className='hover:bg-slate-50 dark:hover:bg-slate-600'
|
||||
>
|
||||
<td className='px-4 py-4'>
|
||||
<div className='flex items-center'>
|
||||
{user.avatar ? (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.username}
|
||||
className='w-8 h-8 rounded-full mr-3'
|
||||
/>
|
||||
) : (
|
||||
<div className='w-8 h-8 bg-indigo-600 rounded-full flex items-center justify-center mr-3'>
|
||||
<span className='text-white text-sm font-medium'>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className='text-sm font-medium text-slate-900 dark:text-slate-100'>
|
||||
{user.username}
|
||||
</div>
|
||||
<div className='text-sm text-slate-500 dark:text-slate-400'>
|
||||
ID: {user._id.slice(-8)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className='px-4 py-4 text-sm text-slate-900 dark:text-slate-100'>
|
||||
{user.email}
|
||||
{user.emailVerified && (
|
||||
<span className='ml-2 text-green-600'>✓</span>
|
||||
)}
|
||||
</td>
|
||||
<td className='px-4 py-4'>
|
||||
<span
|
||||
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(user.status)}`}
|
||||
>
|
||||
{user.status || 'Unknown'}
|
||||
</span>
|
||||
</td>
|
||||
<td className='px-4 py-4'>
|
||||
<span
|
||||
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getRoleColor(user.role)}`}
|
||||
>
|
||||
{user.role || 'USER'}
|
||||
</span>
|
||||
</td>
|
||||
<td className='px-4 py-4 text-sm text-slate-500 dark:text-slate-400'>
|
||||
{user.createdAt
|
||||
? new Date(user.createdAt).toLocaleDateString()
|
||||
: 'Unknown'}
|
||||
</td>
|
||||
<td className='px-4 py-4'>
|
||||
<div className='flex space-x-2'>
|
||||
{user.status === AccountStatus.ACTIVE ? (
|
||||
<button
|
||||
onClick={() => handleSuspendUser(user._id)}
|
||||
className='text-red-600 hover:text-red-800 text-xs'
|
||||
disabled={user._id === currentUser?._id}
|
||||
>
|
||||
Suspend
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleActivateUser(user._id)}
|
||||
className='text-green-600 hover:text-green-800 text-xs'
|
||||
>
|
||||
Activate
|
||||
</button>
|
||||
)}
|
||||
|
||||
{user.password && (
|
||||
<button
|
||||
onClick={() => setSelectedUser(user)}
|
||||
className='text-blue-600 hover:text-blue-800 text-xs'
|
||||
>
|
||||
Change Password
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleDeleteUser(user._id)}
|
||||
className='text-red-600 hover:text-red-800 text-xs'
|
||||
disabled={
|
||||
user._id === currentUser?._id ||
|
||||
user.role === UserRole.ADMIN
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Change Modal */}
|
||||
{selectedUser && (
|
||||
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-60'>
|
||||
<div className='bg-white dark:bg-slate-800 rounded-lg p-6 max-w-md w-full mx-4'>
|
||||
<h3 className='text-lg font-semibold text-slate-800 dark:text-slate-100 mb-4'>
|
||||
Change Password for {selectedUser.username}
|
||||
</h3>
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2'>
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
className='w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-slate-700 dark:border-slate-600 dark:text-white'
|
||||
placeholder='Enter new password (min 6 characters)'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex space-x-3'>
|
||||
<button
|
||||
onClick={() => handleChangePassword(selectedUser._id)}
|
||||
className='flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 px-4 rounded-md'
|
||||
>
|
||||
Change Password
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedUser(null);
|
||||
setNewPassword('');
|
||||
}}
|
||||
className='flex-1 bg-slate-300 hover:bg-slate-400 text-slate-700 font-medium py-2 px-4 rounded-md'
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminInterface;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Admin Components
|
||||
export { default as AdminInterface } from './AdminInterface';
|
||||
@@ -0,0 +1,316 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useUser } from '../../contexts/UserContext';
|
||||
import { authService } from '../../services/auth/auth.service';
|
||||
import { PillIcon } from '../icons/Icons';
|
||||
|
||||
const AuthPage: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isSignUp, setIsSignUp] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const { login, register, loginWithOAuth } = useUser();
|
||||
|
||||
// State for email verification result
|
||||
const [verificationResult, setVerificationResult] = useState<
|
||||
null | 'success' | 'error'
|
||||
>(null);
|
||||
|
||||
// Extract token from URL and verify email
|
||||
useEffect(() => {
|
||||
const path = window.location.pathname;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token = params.get('token');
|
||||
if (path === '/verify-email' && token) {
|
||||
authService
|
||||
.verifyEmail(token)
|
||||
.then(() => setVerificationResult('success'))
|
||||
.catch(() => setVerificationResult('error'));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// FIX: Made the function async and added await to handle promises from login.
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!email.trim()) {
|
||||
setError('Email cannot be empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password.trim()) {
|
||||
setError('Password cannot be empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate email format (allow localhost for admin)
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+$/; // Simplified to allow any domain including localhost
|
||||
if (!emailRegex.test(email)) {
|
||||
setError('Please enter a valid email address.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSignUp) {
|
||||
// Registration
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters long.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await register(email, password);
|
||||
if (success) {
|
||||
setError(
|
||||
'Registration successful! Please check your email for verification (demo: verification not actually sent).'
|
||||
);
|
||||
setIsSignUp(false); // Switch back to login mode
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
} else {
|
||||
setError('Registration failed. Email may already be in use.');
|
||||
}
|
||||
} else {
|
||||
// Login
|
||||
const success = await login(email, password);
|
||||
if (!success) {
|
||||
setError('Login failed. Please check your email and password.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOAuthLogin = async (provider: 'google' | 'github') => {
|
||||
setError('');
|
||||
try {
|
||||
// Mock OAuth data - in a real app, this would come from the OAuth provider
|
||||
const mockUserData = {
|
||||
email: provider === 'google' ? 'user@gmail.com' : 'user@github.com',
|
||||
username: `${provider}_user_${Date.now()}`,
|
||||
avatar: `https://via.placeholder.com/150?text=${provider.toUpperCase()}`,
|
||||
};
|
||||
|
||||
const success = await loginWithOAuth(provider, mockUserData);
|
||||
if (!success) {
|
||||
setError(`${provider} authentication failed. Please try again.`);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(`${provider} authentication failed. Please try again.`);
|
||||
}
|
||||
};
|
||||
|
||||
if (verificationResult) {
|
||||
return (
|
||||
<div className='min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900'>
|
||||
<div className='text-center'>
|
||||
{verificationResult === 'success' ? (
|
||||
<p className='text-green-600'>
|
||||
Email verified successfully! You can now sign in.
|
||||
</p>
|
||||
) : (
|
||||
<p className='text-red-600'>
|
||||
Email verification failed. Please try again.
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setVerificationResult(null);
|
||||
window.location.href = '/';
|
||||
}}
|
||||
className='mt-4 px-4 py-2 bg-indigo-600 text-white rounded'
|
||||
>
|
||||
Go to Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 px-4'>
|
||||
<div className='w-full max-w-sm'>
|
||||
<div className='text-center mb-8'>
|
||||
<div className='inline-block bg-indigo-600 p-3 rounded-xl mb-4'>
|
||||
<PillIcon className='w-8 h-8 text-white' />
|
||||
</div>
|
||||
<h1 className='text-3xl font-bold text-slate-800 dark:text-slate-100'>
|
||||
Medication Reminder
|
||||
</h1>
|
||||
<p className='text-slate-500 dark:text-slate-400 mt-1'>
|
||||
Sign in with your email or create an account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='bg-white dark:bg-slate-800 rounded-lg shadow-lg p-8'>
|
||||
<div className='flex space-x-1 mb-6'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setIsSignUp(false);
|
||||
setError('');
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
}}
|
||||
className={`flex-1 py-2 px-4 text-sm font-medium rounded-md transition-colors ${
|
||||
!isSignUp
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setIsSignUp(true);
|
||||
setError('');
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
}}
|
||||
className={`flex-1 py-2 px-4 text-sm font-medium rounded-md transition-colors ${
|
||||
isSignUp
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
Sign Up
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className='mb-6'>
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<label
|
||||
htmlFor='email'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
type='email'
|
||||
id='email'
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white'
|
||||
placeholder='your@email.com'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor='password'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
id='password'
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white'
|
||||
placeholder={
|
||||
isSignUp
|
||||
? 'Create a password (min 6 characters)'
|
||||
: 'Enter your password'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isSignUp && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor='confirmPassword'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
id='confirmPassword'
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white'
|
||||
placeholder='Confirm your password'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
className={`text-sm mt-3 ${error.includes('successful') ? 'text-green-600' : 'text-red-500'}`}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type='submit'
|
||||
className='w-full mt-6 bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500'
|
||||
>
|
||||
{isSignUp ? 'Create Account' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className='relative mb-6'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<div className='w-full border-t border-slate-300 dark:border-slate-600' />
|
||||
</div>
|
||||
<div className='relative flex justify-center text-sm'>
|
||||
<span className='px-2 bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400'>
|
||||
Or create an account with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<button
|
||||
onClick={() => handleOAuthLogin('google')}
|
||||
className='w-full flex items-center justify-center px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-md shadow-sm text-sm font-medium text-slate-700 dark:text-slate-200 bg-white dark:bg-slate-700 hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors duration-200'
|
||||
>
|
||||
<svg className='w-4 h-4 mr-2' viewBox='0 0 24 24'>
|
||||
<path
|
||||
fill='#4285F4'
|
||||
d='M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z'
|
||||
/>
|
||||
<path
|
||||
fill='#34A853'
|
||||
d='M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z'
|
||||
/>
|
||||
<path
|
||||
fill='#FBBC05'
|
||||
d='M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z'
|
||||
/>
|
||||
<path
|
||||
fill='#EA4335'
|
||||
d='M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z'
|
||||
/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleOAuthLogin('github')}
|
||||
className='w-full flex items-center justify-center px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-md shadow-sm text-sm font-medium text-slate-700 dark:text-slate-200 bg-white dark:bg-slate-700 hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors duration-200'
|
||||
>
|
||||
<svg className='w-4 h-4 mr-2 fill-current' viewBox='0 0 24 24'>
|
||||
<path d='M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z' />
|
||||
</svg>
|
||||
Continue with GitHub
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthPage;
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { User, UserRole } from '../../types';
|
||||
|
||||
interface AvatarDropdownProps {
|
||||
user: User;
|
||||
onLogout: () => void;
|
||||
onAdmin?: () => void;
|
||||
onChangePassword?: () => void;
|
||||
}
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
return name ? name.charAt(0).toUpperCase() : '?';
|
||||
};
|
||||
|
||||
const AvatarDropdown: React.FC<AvatarDropdownProps> = ({
|
||||
user,
|
||||
onLogout,
|
||||
onAdmin,
|
||||
onChangePassword,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className='relative' ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className='w-10 h-10 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-lg font-bold text-slate-600 dark:text-slate-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-slate-900'
|
||||
aria-label='User menu'
|
||||
>
|
||||
{user.avatar ? (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt='User avatar'
|
||||
className='w-full h-full rounded-full object-cover'
|
||||
/>
|
||||
) : (
|
||||
<span>{getInitials(user.username)}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className='absolute right-0 mt-2 w-48 bg-white dark:bg-slate-800 rounded-md shadow-lg ring-1 ring-black ring-opacity-5 py-1 z-30 border dark:border-slate-700'>
|
||||
<div className='px-4 py-2 border-b border-slate-200 dark:border-slate-700'>
|
||||
<p className='text-sm text-slate-500 dark:text-slate-400'>
|
||||
Signed in as
|
||||
</p>
|
||||
<p className='text-sm font-medium text-slate-800 dark:text-slate-200 truncate'>
|
||||
{user.username}
|
||||
</p>
|
||||
{user.role === UserRole.ADMIN && (
|
||||
<p className='text-xs text-purple-600 dark:text-purple-400 font-medium'>
|
||||
Administrator
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Change Option - Only for password-based accounts */}
|
||||
{user.password && onChangePassword && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onChangePassword();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className='w-full text-left px-4 py-2 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700'
|
||||
>
|
||||
Change Password
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Admin Interface - Only for admins */}
|
||||
{user.role === UserRole.ADMIN && onAdmin && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onAdmin();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className='w-full text-left px-4 py-2 text-sm text-purple-700 dark:text-purple-300 hover:bg-slate-100 dark:hover:bg-slate-700 font-medium'
|
||||
>
|
||||
Admin Interface
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
onLogout();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className='w-full text-left px-4 py-2 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700'
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvatarDropdown;
|
||||
@@ -0,0 +1,192 @@
|
||||
import React, { useState } from 'react';
|
||||
import { authService } from '../../services/auth/auth.service';
|
||||
import { useUser } from '../../contexts/UserContext';
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { user } = useUser();
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
// Validation
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
setError('All fields are required');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError('New password must be at least 6 characters long');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('New passwords do not match');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPassword === newPassword) {
|
||||
setError('New password must be different from current password');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await authService.changePassword(user!._id, currentPassword, newPassword);
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
setError(error.message || 'Failed to change password');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't show for OAuth users
|
||||
if (!user?.password) {
|
||||
return (
|
||||
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'>
|
||||
<div className='bg-white dark:bg-slate-800 rounded-lg p-6 max-w-md'>
|
||||
<h2 className='text-xl font-bold text-slate-800 dark:text-slate-100 mb-4'>
|
||||
Password Change Not Available
|
||||
</h2>
|
||||
<p className='text-slate-600 dark:text-slate-300 mb-4'>
|
||||
This account was created using OAuth (Google/GitHub). Password
|
||||
changes are not available for OAuth accounts.
|
||||
</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 px-4 rounded-md'
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'>
|
||||
<div className='bg-white dark:bg-slate-800 rounded-lg p-6 max-w-md w-full mx-4'>
|
||||
<div className='flex justify-between items-center mb-6'>
|
||||
<h2 className='text-xl font-bold text-slate-800 dark:text-slate-100'>
|
||||
Change Password
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
|
||||
>
|
||||
<svg
|
||||
className='w-6 h-6'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M6 18L18 6M6 6l12 12'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||
<div>
|
||||
<label
|
||||
htmlFor='currentPassword'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1'
|
||||
>
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
id='currentPassword'
|
||||
value={currentPassword}
|
||||
onChange={e => setCurrentPassword(e.target.value)}
|
||||
className='w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-slate-700 dark:border-slate-600 dark:text-white'
|
||||
placeholder='Enter your current password'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor='newPassword'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1'
|
||||
>
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
id='newPassword'
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
className='w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-slate-700 dark:border-slate-600 dark:text-white'
|
||||
placeholder='Enter new password (min 6 characters)'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor='confirmPassword'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1'
|
||||
>
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
id='confirmPassword'
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
className='w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-slate-700 dark:border-slate-600 dark:text-white'
|
||||
placeholder='Confirm your new password'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className='bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded'>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex space-x-3 pt-4'>
|
||||
<button
|
||||
type='submit'
|
||||
disabled={loading}
|
||||
className='flex-1 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200'
|
||||
>
|
||||
{loading ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onClose}
|
||||
className='flex-1 bg-slate-300 hover:bg-slate-400 text-slate-700 font-medium py-2 px-4 rounded-md transition-colors duration-200'
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangePasswordModal;
|
||||
@@ -0,0 +1,4 @@
|
||||
// Authentication Components
|
||||
export { default as AuthPage } from './AuthPage';
|
||||
export { default as AvatarDropdown } from './AvatarDropdown';
|
||||
export { default as ChangePasswordModal } from './ChangePasswordModal';
|
||||
@@ -0,0 +1,546 @@
|
||||
import React from 'react';
|
||||
|
||||
export const PillIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<path d='m10.5 20.5 10-10a4.95 4.95 0 1 0-7-7l-10 10a4.95 4.95 0 1 0 7 7Z' />
|
||||
<path d='m8.5 8.5 7 7' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ClockIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<circle cx='12' cy='12' r='10' />
|
||||
<polyline points='12 6 12 12 16 14' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CheckCircleIcon: React.FC<
|
||||
React.SVGProps<SVGSVGElement>
|
||||
> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<path d='M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z' />
|
||||
<path d='m9 12 2 2 4-4' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const XCircleIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<circle cx='12' cy='12' r='10' />
|
||||
<line x1='15' y1='9' x2='9' y2='15' />
|
||||
<line x1='9' y1='9' x2='15' y2='15' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PlusIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<line x1='12' y1='5' x2='12' y2='19' />
|
||||
<line x1='5' y1='12' x2='19' y2='12' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TrashIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<polyline points='3 6 5 6 21 6' />
|
||||
<path d='M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2' />
|
||||
<line x1='10' y1='11' x2='10' y2='17' />
|
||||
<line x1='14' y1='11' x2='14' y2='17' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const EditIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<path d='M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7'></path>
|
||||
<path d='M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z'></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const MenuIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<line x1='4' x2='20' y1='12' y2='12' />
|
||||
<line x1='4' x2='20' y1='6' y2='6' />
|
||||
<line x1='4' x2='20' y1='18' y2='18' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const HistoryIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<path d='M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8' />
|
||||
<path d='M3 3v5h5' />
|
||||
<path d='M12 7v5l4 2' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const InfoIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<circle cx='12' cy='12' r='10'></circle>
|
||||
<line x1='12' y1='16' x2='12' y2='12'></line>
|
||||
<line x1='12' y1='8' x2='12.01' y2='8'></line>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SunIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<circle cx='12' cy='12' r='4'></circle>
|
||||
<path d='M12 2v2'></path>
|
||||
<path d='M12 20v2'></path>
|
||||
<path d='m4.93 4.93 1.41 1.41'></path>
|
||||
<path d='m17.66 17.66 1.41 1.41'></path>
|
||||
<path d='M2 12h2'></path>
|
||||
<path d='M20 12h2'></path>
|
||||
<path d='m6.34 17.66-1.41 1.41'></path>
|
||||
<path d='m19.07 4.93-1.41 1.41'></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SunsetIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<path d='M12 10V2' />
|
||||
<path d='m4.93 10.93 1.41 1.41' />
|
||||
<path d='M2 18h2' />
|
||||
<path d='M20 18h2' />
|
||||
<path d='m19.07 10.93-1.41 1.41' />
|
||||
<path d='M22 22H2' />
|
||||
<path d='m16 6-4 4-4-4' />
|
||||
<path d='M16 18a4 4 0 0 0-8 0' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const MoonIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<path d='M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z'></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const DesktopIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<rect width='20' height='14' x='2' y='3' rx='2' />
|
||||
<line x1='8' x2='16' y1='21' y2='21' />
|
||||
<line x1='12' x2='12' y1='17' y2='21' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SearchIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<circle cx='11' cy='11' r='8'></circle>
|
||||
<line x1='21' y1='21' x2='16.65' y2='16.65'></line>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CapsuleIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<path d='M8.5 16.5a5 5 0 1 0 0-9' />
|
||||
<path d='M15.5 7.5a5 5 0 1 0 0 9' />
|
||||
<line x1='8.5' y1='7.5' x2='15.5' y2='7.5' />
|
||||
<line x1='8.5' y1='16.5' x2='15.5' y2='16.5' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SyringeIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<path d='m18 2 4 4' />
|
||||
<path d='m18 6 2.5-2.5' />
|
||||
<path d='m13.5 7.5 7.5-7.5' />
|
||||
<path d='M3 21l6-6' />
|
||||
<path d='m3 11 8 8' />
|
||||
<path d='m7 7 4 4' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const BottleIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<path d='M9 11h6' />
|
||||
<path d='M12 8v6' />
|
||||
<path d='M20 10.5A6.5 6.5 0 0 0 7.5 4H7a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-.5A6.5 6.5 0 0 0 20 10.5Z' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TabletIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<circle cx='12' cy='12' r='10' />
|
||||
<path d='m14.2 7.8-8.4 8.4' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SettingsIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<path d='M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 0 2l-.15.08a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1 0-2l.15-.08a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z' />
|
||||
<circle cx='12' cy='12' r='3' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const UserIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<path d='M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2' />
|
||||
<circle cx='12' cy='7' r='4' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CameraIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<path d='M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z' />
|
||||
<circle cx='12' cy='13' r='3' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const BellIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<path d='M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9' />
|
||||
<path d='M10.3 21a1.94 1.94 0 0 0 3.4 0' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ZzzIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<path d='M4 12h4l4 8 4-16h4' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const WaterDropIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<path d='M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CoffeeIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<path d='M10 2v2' />
|
||||
<path d='M14 2v2' />
|
||||
<path d='M16 8a4 4 0 0 1-4 4H8a4 4 0 0 1 0-8h8' />
|
||||
<path d='M6 22V8h14v10a4 4 0 0 1-4 4H6' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const BarChartIcon: React.FC<React.SVGProps<SVGSVGElement>> = props => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<line x1='12' y1='20' x2='12' y2='10' />
|
||||
<line x1='18' y1='20' x2='18' y2='4' />
|
||||
<line x1='6' y1='20' x2='6' y2='16' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const medicationIcons: {
|
||||
[key: string]: React.FC<React.SVGProps<SVGSVGElement>>;
|
||||
} = {
|
||||
pill: PillIcon,
|
||||
tablet: TabletIcon,
|
||||
capsule: CapsuleIcon,
|
||||
syringe: SyringeIcon,
|
||||
bottle: BottleIcon,
|
||||
};
|
||||
|
||||
export const getMedicationIcon = (
|
||||
iconName?: string
|
||||
): React.FC<React.SVGProps<SVGSVGElement>> => {
|
||||
return (iconName && medicationIcons[iconName]) || PillIcon;
|
||||
};
|
||||
|
||||
export const reminderIcons: {
|
||||
[key: string]: React.FC<React.SVGProps<SVGSVGElement>>;
|
||||
} = {
|
||||
bell: BellIcon,
|
||||
water: WaterDropIcon,
|
||||
break: CoffeeIcon,
|
||||
};
|
||||
|
||||
export const getReminderIcon = (
|
||||
iconName?: string
|
||||
): React.FC<React.SVGProps<SVGSVGElement>> => {
|
||||
return (iconName && reminderIcons[iconName]) || BellIcon;
|
||||
};
|
||||
@@ -0,0 +1,269 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Medication, Frequency } from '../../types';
|
||||
import { medicationIcons } from '../icons/Icons';
|
||||
|
||||
interface AddMedicationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAdd: (medication: Omit<Medication, '_id' | '_rev'>) => Promise<void>;
|
||||
}
|
||||
|
||||
const AddMedicationModal: React.FC<AddMedicationModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onAdd,
|
||||
}) => {
|
||||
const [name, setName] = useState('');
|
||||
const [dosage, setDosage] = useState('');
|
||||
const [frequency, setFrequency] = useState<Frequency>(Frequency.Daily);
|
||||
const [hoursBetween, setHoursBetween] = useState(8);
|
||||
const [startTime, setStartTime] = useState('09:00');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [icon, setIcon] = useState('pill');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setName('');
|
||||
setDosage('');
|
||||
setFrequency(Frequency.Daily);
|
||||
setHoursBetween(8);
|
||||
setStartTime('09:00');
|
||||
setNotes('');
|
||||
setIcon('pill');
|
||||
setIsSaving(false);
|
||||
setTimeout(() => nameInputRef.current?.focus(), 100);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !isSaving) onClose();
|
||||
};
|
||||
if (isOpen) {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose, isSaving]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name || !dosage || !startTime || isSaving) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onAdd({
|
||||
name,
|
||||
dosage,
|
||||
frequency,
|
||||
hoursBetween:
|
||||
frequency === Frequency.EveryXHours ? hoursBetween : undefined,
|
||||
startTime,
|
||||
notes,
|
||||
icon,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to add medication', error);
|
||||
alert('There was an error saving your medication. Please try again.');
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 z-50 flex justify-center items-center p-4'
|
||||
role='dialog'
|
||||
aria-modal='true'
|
||||
aria-labelledby='add-med-title'
|
||||
>
|
||||
<div
|
||||
className='bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md'
|
||||
ref={modalRef}
|
||||
>
|
||||
<div className='p-6 border-b border-slate-200 dark:border-slate-700'>
|
||||
<h3
|
||||
id='add-med-title'
|
||||
className='text-xl font-semibold text-slate-800 dark:text-slate-100'
|
||||
>
|
||||
Add New Medication
|
||||
</h3>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className='p-6 space-y-4 max-h-[70vh] overflow-y-auto'>
|
||||
<div>
|
||||
<label
|
||||
htmlFor='name'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
Medication Name
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='name'
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
required
|
||||
ref={nameInputRef}
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor='dosage'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
Dosage (e.g., "1 tablet", "500mg")
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='dosage'
|
||||
value={dosage}
|
||||
onChange={e => setDosage(e.target.value)}
|
||||
required
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor='frequency'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
Frequency
|
||||
</label>
|
||||
<select
|
||||
id='frequency'
|
||||
value={frequency}
|
||||
onChange={e => setFrequency(e.target.value as Frequency)}
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm dark:bg-slate-700 dark:border-slate-600 dark:text-white'
|
||||
>
|
||||
{Object.values(Frequency).map(f => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{frequency === Frequency.EveryXHours && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor='hoursBetween'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
Hours Between Doses
|
||||
</label>
|
||||
<input
|
||||
type='number'
|
||||
id='hoursBetween'
|
||||
value={hoursBetween}
|
||||
onChange={e => setHoursBetween(parseInt(e.target.value, 10))}
|
||||
min='1'
|
||||
max='23'
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label
|
||||
htmlFor='startTime'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
First Dose Time
|
||||
</label>
|
||||
<input
|
||||
type='time'
|
||||
id='startTime'
|
||||
value={startTime}
|
||||
onChange={e => setStartTime(e.target.value)}
|
||||
required
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-slate-700 dark:text-slate-300'>
|
||||
Icon
|
||||
</label>
|
||||
<div className='mt-2 flex flex-wrap gap-2'>
|
||||
{Object.entries(medicationIcons).map(([key, IconComponent]) => (
|
||||
<button
|
||||
key={key}
|
||||
type='button'
|
||||
onClick={() => setIcon(key)}
|
||||
className={`p-2 rounded-full transition-colors ${icon === key ? 'bg-indigo-600 text-white ring-2 ring-offset-2 ring-indigo-500 ring-offset-white dark:ring-offset-slate-800' : 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600'}`}
|
||||
aria-label={`Select ${key} icon`}
|
||||
>
|
||||
<IconComponent className='w-6 h-6' />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor='notes'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
Notes (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id='notes'
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white'
|
||||
placeholder='e.g., take with food'
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div className='px-6 py-4 bg-slate-50 dark:bg-slate-700/50 flex justify-end space-x-3 rounded-b-lg border-t border-slate-200 dark:border-slate-700'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
className='px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-md shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:bg-slate-700 dark:text-slate-200 dark:border-slate-600 dark:hover:bg-slate-600 dark:focus:ring-offset-slate-800 disabled:opacity-50'
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type='submit'
|
||||
disabled={isSaving}
|
||||
className='px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-slate-800 disabled:opacity-50 disabled:cursor-not-allowed flex items-center'
|
||||
>
|
||||
{isSaving && <Spinner />}
|
||||
{isSaving ? 'Adding...' : 'Add Medication'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Spinner = () => (
|
||||
<svg
|
||||
className='animate-spin -ml-1 mr-3 h-5 w-5 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>
|
||||
);
|
||||
|
||||
export default AddMedicationModal;
|
||||
@@ -0,0 +1,158 @@
|
||||
import React from 'react';
|
||||
import { Medication, Dose, DoseStatus } from '../../types';
|
||||
import {
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
InfoIcon,
|
||||
getMedicationIcon,
|
||||
ZzzIcon,
|
||||
} from '../icons/Icons';
|
||||
|
||||
interface DoseCardProps {
|
||||
dose: Dose & { takenAt?: string };
|
||||
medication: Medication;
|
||||
status: DoseStatus;
|
||||
onToggleDose: (doseId: string) => void;
|
||||
onSnooze: (doseId: string) => void;
|
||||
snoozedUntil?: Date;
|
||||
}
|
||||
|
||||
const statusStyles = {
|
||||
[DoseStatus.UPCOMING]: {
|
||||
bg: 'bg-white dark:bg-slate-800',
|
||||
icon: <ClockIcon className='w-6 h-6 text-slate-400 dark:text-slate-500' />,
|
||||
text: 'text-slate-500 dark:text-slate-400',
|
||||
button:
|
||||
'border-indigo-600 text-indigo-600 hover:bg-indigo-600 hover:text-white dark:text-indigo-400 dark:border-indigo-400 dark:hover:bg-indigo-400 dark:hover:text-white',
|
||||
buttonText: 'Take',
|
||||
ring: 'hover:ring-indigo-300 dark:hover:ring-indigo-500',
|
||||
},
|
||||
[DoseStatus.TAKEN]: {
|
||||
bg: 'bg-green-50 dark:bg-green-900/20',
|
||||
icon: (
|
||||
<CheckCircleIcon className='w-6 h-6 text-green-500 dark:text-green-400' />
|
||||
),
|
||||
text: 'text-green-700 dark:text-green-400',
|
||||
button:
|
||||
'border-green-500 text-green-500 hover:bg-green-500 hover:text-white dark:text-green-400 dark:border-green-400 dark:hover:bg-green-400 dark:hover:text-slate-900',
|
||||
buttonText: 'Untake',
|
||||
ring: '',
|
||||
},
|
||||
[DoseStatus.MISSED]: {
|
||||
bg: 'bg-red-50 dark:bg-red-900/20',
|
||||
icon: <XCircleIcon className='w-6 h-6 text-red-500 dark:text-red-400' />,
|
||||
text: 'text-red-700 dark:text-red-400',
|
||||
button:
|
||||
'border-red-500 text-red-500 hover:bg-red-500 hover:text-white dark:text-red-400 dark:border-red-400 dark:hover:bg-red-400 dark:hover:text-slate-900',
|
||||
buttonText: 'Take Now',
|
||||
ring: '',
|
||||
},
|
||||
[DoseStatus.SNOOZED]: {
|
||||
bg: 'bg-amber-50 dark:bg-amber-900/20',
|
||||
icon: <ZzzIcon className='w-6 h-6 text-amber-500 dark:text-amber-400' />,
|
||||
text: 'text-amber-700 dark:text-amber-400',
|
||||
button:
|
||||
'border-indigo-600 text-indigo-600 hover:bg-indigo-600 hover:text-white dark:text-indigo-400 dark:border-indigo-400 dark:hover:bg-indigo-400 dark:hover:text-white',
|
||||
buttonText: 'Take',
|
||||
ring: '',
|
||||
},
|
||||
};
|
||||
|
||||
const DoseCard: React.FC<DoseCardProps> = ({
|
||||
dose,
|
||||
medication,
|
||||
status,
|
||||
onToggleDose,
|
||||
onSnooze,
|
||||
snoozedUntil,
|
||||
}) => {
|
||||
const styles = statusStyles[status];
|
||||
const timeString = dose.scheduledTime.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const takenTimeString = dose.takenAt
|
||||
? new Date(dose.takenAt).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: '';
|
||||
const snoozedTimeString = snoozedUntil
|
||||
? snoozedUntil.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: '';
|
||||
const MedicationIcon = getMedicationIcon(medication.icon);
|
||||
|
||||
return (
|
||||
<li
|
||||
className={`shadow-md rounded-lg p-4 flex flex-col justify-between transition-all duration-300 ${styles.bg} ${styles.ring} ring-4 ring-transparent border border-slate-200 dark:border-slate-700`}
|
||||
>
|
||||
<div>
|
||||
<div className='flex justify-between items-start'>
|
||||
<div className='flex items-center space-x-3'>
|
||||
<MedicationIcon className='w-7 h-7 text-indigo-500 dark:text-indigo-400 flex-shrink-0' />
|
||||
<div>
|
||||
<h4 className='font-bold text-lg text-slate-800 dark:text-slate-100'>
|
||||
{medication.name}
|
||||
</h4>
|
||||
<p className='text-slate-600 dark:text-slate-300'>
|
||||
{medication.dosage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{styles.icon}
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center space-x-2 mt-4 font-semibold text-lg ${styles.text}`}
|
||||
>
|
||||
<ClockIcon className='w-5 h-5' />
|
||||
<span>{timeString}</span>
|
||||
</div>
|
||||
|
||||
{status === DoseStatus.SNOOZED && (
|
||||
<p className='text-sm text-amber-600 dark:text-amber-500 mt-1'>
|
||||
Snoozed until {snoozedTimeString}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === DoseStatus.TAKEN && (
|
||||
<p className='text-sm text-green-600 dark:text-green-500 mt-1'>
|
||||
Taken at {takenTimeString}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{medication.notes && (
|
||||
<div className='mt-3 p-2 bg-indigo-50 dark:bg-indigo-900/30 rounded-lg flex items-start space-x-2'>
|
||||
<InfoIcon className='w-4 h-4 text-indigo-500 dark:text-indigo-400 mt-0.5 flex-shrink-0' />
|
||||
<p className='text-sm text-indigo-800 dark:text-indigo-200'>
|
||||
{medication.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='mt-4 flex items-center space-x-2'>
|
||||
{status === DoseStatus.UPCOMING && (
|
||||
<button
|
||||
onClick={() => onSnooze(dose.id)}
|
||||
className='w-1/3 py-2 px-2 rounded-lg font-semibold border-2 transition-colors duration-200 border-slate-300 text-slate-500 hover:bg-slate-100 dark:border-slate-600 dark:text-slate-400 dark:hover:bg-slate-700'
|
||||
aria-label={`Snooze ${medication.name} for 5 minutes`}
|
||||
>
|
||||
<ZzzIcon className='w-5 h-5 mx-auto' />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onToggleDose(dose.id)}
|
||||
className={`w-full py-2 px-4 rounded-lg font-semibold border-2 transition-colors duration-200 ${styles.button}`}
|
||||
aria-label={`${styles.buttonText} ${medication.name} at ${timeString}`}
|
||||
>
|
||||
{styles.buttonText}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default DoseCard;
|
||||
@@ -0,0 +1,274 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Medication, Frequency } from '../../types';
|
||||
import { medicationIcons } from '../icons/Icons';
|
||||
|
||||
interface EditMedicationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
medication: Medication | null;
|
||||
onUpdate: (medication: Medication) => Promise<void>;
|
||||
}
|
||||
|
||||
const EditMedicationModal: React.FC<EditMedicationModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
medication,
|
||||
onUpdate,
|
||||
}) => {
|
||||
const [name, setName] = useState('');
|
||||
const [dosage, setDosage] = useState('');
|
||||
const [frequency, setFrequency] = useState<Frequency>(Frequency.Daily);
|
||||
const [hoursBetween, setHoursBetween] = useState(8);
|
||||
const [startTime, setStartTime] = useState('09:00');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [icon, setIcon] = useState('pill');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (medication) {
|
||||
setName(medication.name);
|
||||
setDosage(medication.dosage);
|
||||
setFrequency(medication.frequency);
|
||||
setHoursBetween(medication.hoursBetween || 8);
|
||||
setStartTime(medication.startTime);
|
||||
setNotes(medication.notes || '');
|
||||
setIcon(medication.icon || 'pill');
|
||||
setIsSaving(false);
|
||||
}
|
||||
if (isOpen) {
|
||||
setTimeout(() => nameInputRef.current?.focus(), 100);
|
||||
}
|
||||
}, [medication, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !isSaving) onClose();
|
||||
};
|
||||
if (isOpen) {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose, isSaving]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!medication || !name || !dosage || !startTime || isSaving) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onUpdate({
|
||||
...medication,
|
||||
name,
|
||||
dosage,
|
||||
frequency,
|
||||
hoursBetween:
|
||||
frequency === Frequency.EveryXHours ? hoursBetween : undefined,
|
||||
startTime,
|
||||
notes,
|
||||
icon,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update medication', error);
|
||||
alert('There was an error updating your medication. Please try again.');
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 z-50 flex justify-center items-center p-4'
|
||||
role='dialog'
|
||||
aria-modal='true'
|
||||
aria-labelledby='edit-med-title'
|
||||
>
|
||||
<div
|
||||
className='bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md'
|
||||
ref={modalRef}
|
||||
>
|
||||
<div className='p-6 border-b border-slate-200 dark:border-slate-700'>
|
||||
<h3
|
||||
id='edit-med-title'
|
||||
className='text-xl font-semibold text-slate-800 dark:text-slate-100'
|
||||
>
|
||||
Edit Medication
|
||||
</h3>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className='p-6 space-y-4 max-h-[70vh] overflow-y-auto'>
|
||||
<div>
|
||||
<label
|
||||
htmlFor='edit-name'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
Medication Name
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='edit-name'
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
required
|
||||
ref={nameInputRef}
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor='edit-dosage'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
Dosage (e.g., "1 tablet", "500mg")
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='edit-dosage'
|
||||
value={dosage}
|
||||
onChange={e => setDosage(e.target.value)}
|
||||
required
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor='edit-frequency'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
Frequency
|
||||
</label>
|
||||
<select
|
||||
id='edit-frequency'
|
||||
value={frequency}
|
||||
onChange={e => setFrequency(e.target.value as Frequency)}
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm dark:bg-slate-700 dark:border-slate-600 dark:text-white'
|
||||
>
|
||||
{Object.values(Frequency).map(f => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{frequency === Frequency.EveryXHours && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor='edit-hoursBetween'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
Hours Between Doses
|
||||
</label>
|
||||
<input
|
||||
type='number'
|
||||
id='edit-hoursBetween'
|
||||
value={hoursBetween}
|
||||
onChange={e => setHoursBetween(parseInt(e.target.value, 10))}
|
||||
min='1'
|
||||
max='23'
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label
|
||||
htmlFor='edit-startTime'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
First Dose Time
|
||||
</label>
|
||||
<input
|
||||
type='time'
|
||||
id='edit-startTime'
|
||||
value={startTime}
|
||||
onChange={e => setStartTime(e.target.value)}
|
||||
required
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-slate-700 dark:text-slate-300'>
|
||||
Icon
|
||||
</label>
|
||||
<div className='mt-2 flex flex-wrap gap-2'>
|
||||
{Object.entries(medicationIcons).map(([key, IconComponent]) => (
|
||||
<button
|
||||
key={key}
|
||||
type='button'
|
||||
onClick={() => setIcon(key)}
|
||||
className={`p-2 rounded-full transition-colors ${icon === key ? 'bg-indigo-600 text-white ring-2 ring-offset-2 ring-indigo-500 ring-offset-white dark:ring-offset-slate-800' : 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600'}`}
|
||||
aria-label={`Select ${key} icon`}
|
||||
>
|
||||
<IconComponent className='w-6 h-6' />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor='edit-notes'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
Notes (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id='edit-notes'
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white'
|
||||
placeholder='e.g., take with food'
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div className='px-6 py-4 bg-slate-50 dark:bg-slate-700/50 flex justify-end space-x-3 rounded-b-lg border-t border-slate-200 dark:border-slate-700'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
className='px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-md shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:bg-slate-700 dark:text-slate-200 dark:border-slate-600 dark:hover:bg-slate-600 dark:focus:ring-offset-slate-800 disabled:opacity-50'
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type='submit'
|
||||
disabled={isSaving}
|
||||
className='px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-slate-800 disabled:opacity-50 disabled:cursor-not-allowed flex items-center'
|
||||
>
|
||||
{isSaving && <Spinner />}
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Spinner = () => (
|
||||
<svg
|
||||
className='animate-spin -ml-1 mr-3 h-5 w-5 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>
|
||||
);
|
||||
|
||||
export default EditMedicationModal;
|
||||
@@ -0,0 +1,177 @@
|
||||
import React, { useMemo, useEffect, useRef } from 'react';
|
||||
import { Medication } from '../../types';
|
||||
import { TrashIcon, EditIcon, getMedicationIcon } from '../icons/Icons';
|
||||
|
||||
interface ManageMedicationsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
medications: Medication[];
|
||||
// FIX: Changed onDelete to expect the full medication object to match the parent's handler.
|
||||
onDelete: (medication: Medication) => void;
|
||||
onEdit: (medication: Medication) => void;
|
||||
}
|
||||
|
||||
const ManageMedicationsModal: React.FC<ManageMedicationsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
medications,
|
||||
onDelete,
|
||||
onEdit,
|
||||
}) => {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTimeout(() => closeButtonRef.current?.focus(), 100);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
if (isOpen) {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !modalRef.current) return;
|
||||
const focusableElements = modalRef.current.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
const firstElement = focusableElements[0] as HTMLElement;
|
||||
const lastElement = focusableElements[
|
||||
focusableElements.length - 1
|
||||
] as HTMLElement;
|
||||
|
||||
const handleTabKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
lastElement.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
firstElement.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleTabKey);
|
||||
return () => document.removeEventListener('keydown', handleTabKey);
|
||||
}, [isOpen]);
|
||||
|
||||
const sortedMedications = useMemo(
|
||||
() => [...medications].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[medications]
|
||||
);
|
||||
|
||||
const handleDeleteConfirmation = (medication: Medication) => {
|
||||
if (window.confirm(`Are you sure you want to delete ${medication.name}?`)) {
|
||||
// FIX: Pass the whole medication object to the onDelete handler.
|
||||
onDelete(medication);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 z-50 flex justify-center items-center p-4'
|
||||
role='dialog'
|
||||
aria-modal='true'
|
||||
aria-labelledby='manage-med-title'
|
||||
>
|
||||
<div
|
||||
className='bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-lg'
|
||||
ref={modalRef}
|
||||
>
|
||||
<div className='p-6 border-b border-slate-200 dark:border-slate-700 flex justify-between items-center'>
|
||||
<h3
|
||||
id='manage-med-title'
|
||||
className='text-xl font-semibold text-slate-800 dark:text-slate-100'
|
||||
>
|
||||
Manage Medications
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
ref={closeButtonRef}
|
||||
className='text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 text-3xl leading-none'
|
||||
aria-label='Close'
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className='p-6 max-h-[60vh] overflow-y-auto'>
|
||||
{sortedMedications.length > 0 ? (
|
||||
<ul className='space-y-3'>
|
||||
{sortedMedications.map(med => {
|
||||
const MedicationIcon = getMedicationIcon(med.icon);
|
||||
return (
|
||||
// FIX: The Medication type has `_id`, not `id`. Used for the key.
|
||||
<li
|
||||
key={med._id}
|
||||
className='p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg flex justify-between items-center'
|
||||
>
|
||||
<div className='flex items-center space-x-3'>
|
||||
<MedicationIcon className='w-6 h-6 text-indigo-500 dark:text-indigo-400 flex-shrink-0' />
|
||||
<div>
|
||||
<p className='font-semibold text-slate-800 dark:text-slate-100'>
|
||||
{med.name}
|
||||
</p>
|
||||
<p className='text-sm text-slate-500 dark:text-slate-400'>
|
||||
{med.dosage} • {med.frequency}
|
||||
</p>
|
||||
{med.notes && (
|
||||
<p className='text-xs text-slate-400 dark:text-slate-500 mt-1 italic'>
|
||||
Note: "{med.notes}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center space-x-1'>
|
||||
<button
|
||||
onClick={() => onEdit(med)}
|
||||
className='p-2 text-indigo-600 hover:bg-indigo-100 dark:text-indigo-400 dark:hover:bg-slate-700 rounded-full'
|
||||
aria-label={`Edit ${med.name}`}
|
||||
>
|
||||
<EditIcon className='w-5 h-5' />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteConfirmation(med)}
|
||||
className='p-2 text-red-500 hover:bg-red-100 dark:text-red-400 dark:hover:bg-slate-700 rounded-full'
|
||||
aria-label={`Delete ${med.name}`}
|
||||
>
|
||||
<TrashIcon className='w-5 h-5' />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className='text-center text-slate-500 dark:text-slate-400 py-8'>
|
||||
No medications have been added yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='px-6 py-4 bg-slate-50 dark:bg-slate-700/50 flex justify-end rounded-b-lg border-t border-slate-200 dark:border-slate-700'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onClose}
|
||||
className='px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-md shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:bg-slate-700 dark:text-slate-200 dark:border-slate-600 dark:hover:bg-slate-600 dark:focus:ring-offset-slate-800'
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManageMedicationsModal;
|
||||
@@ -0,0 +1,5 @@
|
||||
// Medication Components
|
||||
export { default as AddMedicationModal } from './AddMedicationModal';
|
||||
export { default as EditMedicationModal } from './EditMedicationModal';
|
||||
export { default as ManageMedicationsModal } from './ManageMedicationsModal';
|
||||
export { default as DoseCard } from './DoseCard';
|
||||
@@ -0,0 +1,301 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { User, UserSettings } from '../../types';
|
||||
import { CameraIcon, TrashIcon, UserIcon } from '../icons/Icons';
|
||||
|
||||
interface AccountModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: User;
|
||||
settings: UserSettings;
|
||||
onUpdateUser: (user: User) => Promise<void>;
|
||||
onUpdateSettings: (settings: UserSettings) => Promise<void>;
|
||||
onDeleteAllData: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AccountModal: React.FC<AccountModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
user,
|
||||
settings,
|
||||
onUpdateUser,
|
||||
onUpdateSettings,
|
||||
onDeleteAllData,
|
||||
}) => {
|
||||
const [username, setUsername] = useState(user.username);
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setUsername(user.username);
|
||||
setSuccessMessage('');
|
||||
setTimeout(() => closeButtonRef.current?.focus(), 100);
|
||||
}
|
||||
}, [isOpen, user.username]);
|
||||
|
||||
const handleUsernameSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (username.trim() && username !== user.username) {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onUpdateUser({ ...user, username: username.trim() });
|
||||
setSuccessMessage('Username updated successfully!');
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
} catch (error) {
|
||||
alert('Failed to update username.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleNotifications = (
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
onUpdateSettings({ ...settings, notificationsEnabled: e.target.checked });
|
||||
};
|
||||
|
||||
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onUpdateUser({ ...user, avatar: reader.result as string });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAvatar = async () => {
|
||||
const { avatar, ...userWithoutAvatar } = user;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onUpdateUser(userWithoutAvatar);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDeleteAllData();
|
||||
} catch (error) {
|
||||
alert('Failed to delete data.');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 z-50 flex justify-center items-center p-4'
|
||||
role='dialog'
|
||||
aria-modal='true'
|
||||
aria-labelledby='account-title'
|
||||
>
|
||||
<div
|
||||
className='bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-lg'
|
||||
ref={modalRef}
|
||||
>
|
||||
<div className='p-6 border-b border-slate-200 dark:border-slate-700 flex justify-between items-center'>
|
||||
<h3
|
||||
id='account-title'
|
||||
className='text-xl font-semibold text-slate-800 dark:text-slate-100'
|
||||
>
|
||||
Account Settings
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
ref={closeButtonRef}
|
||||
className='text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 text-3xl leading-none'
|
||||
aria-label='Close'
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className='p-6 space-y-6 max-h-[60vh] overflow-y-auto'>
|
||||
<section>
|
||||
<h4 className='text-lg font-medium text-slate-700 dark:text-slate-200 mb-3'>
|
||||
Profile
|
||||
</h4>
|
||||
<div className='flex items-center space-x-4'>
|
||||
<div className='relative'>
|
||||
{user.avatar ? (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt='User avatar'
|
||||
className='w-20 h-20 rounded-full object-cover'
|
||||
/>
|
||||
) : (
|
||||
<span className='w-20 h-20 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center'>
|
||||
<UserIcon className='w-10 h-10 text-slate-500' />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className='absolute bottom-0 right-0 bg-white dark:bg-slate-600 rounded-full p-1.5 shadow-md border border-slate-200 dark:border-slate-500 hover:bg-slate-100 dark:hover:bg-slate-500'
|
||||
aria-label='Change profile picture'
|
||||
>
|
||||
<CameraIcon className='w-4 h-4 text-slate-700 dark:text-slate-200' />
|
||||
</button>
|
||||
<input
|
||||
type='file'
|
||||
ref={fileInputRef}
|
||||
onChange={handleAvatarChange}
|
||||
accept='image/*'
|
||||
className='hidden'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col'>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className='px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-md shadow-sm hover:bg-slate-50 dark:bg-slate-700 dark:text-slate-200 dark:border-slate-600 dark:hover:bg-slate-600'
|
||||
>
|
||||
Change Picture
|
||||
</button>
|
||||
{user.avatar && (
|
||||
<button
|
||||
onClick={handleRemoveAvatar}
|
||||
className='mt-2 flex items-center text-sm text-red-600 dark:text-red-500 hover:underline'
|
||||
>
|
||||
<TrashIcon className='w-3 h-3 mr-1' /> Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleUsernameSubmit} className='space-y-3 mt-4'>
|
||||
<div>
|
||||
<label
|
||||
htmlFor='username'
|
||||
className='block text-sm font-medium text-slate-600 dark:text-slate-300'
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<div className='mt-1 flex rounded-md shadow-sm'>
|
||||
<input
|
||||
type='text'
|
||||
id='username'
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
className='flex-1 block w-full min-w-0 rounded-none rounded-l-md px-3 py-2 border border-slate-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white'
|
||||
/>
|
||||
<button
|
||||
type='submit'
|
||||
className='inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-r-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 dark:focus:ring-offset-slate-800'
|
||||
disabled={
|
||||
username === user.username || !username.trim() || isSaving
|
||||
}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{successMessage && (
|
||||
<p className='text-sm text-green-600 dark:text-green-500 mt-2'>
|
||||
{successMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 className='text-lg font-medium text-slate-700 dark:text-slate-200 mb-3'>
|
||||
Preferences
|
||||
</h4>
|
||||
<div className='flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg'>
|
||||
<span className='font-medium text-slate-800 dark:text-slate-100'>
|
||||
Enable Notifications
|
||||
</span>
|
||||
<label
|
||||
htmlFor='notifications-toggle'
|
||||
className='relative inline-flex items-center cursor-pointer'
|
||||
>
|
||||
<input
|
||||
type='checkbox'
|
||||
id='notifications-toggle'
|
||||
className='sr-only peer'
|
||||
checked={settings.notificationsEnabled}
|
||||
onChange={handleToggleNotifications}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-slate-200 dark:bg-slate-600 rounded-full peer peer-focus:ring-4 peer-focus:ring-indigo-300 dark:peer-focus:ring-indigo-800 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-slate-600 peer-checked:bg-indigo-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 className='text-lg font-medium text-red-600 dark:text-red-500 mb-3'>
|
||||
Danger Zone
|
||||
</h4>
|
||||
<div className='p-4 border border-red-300 dark:border-red-500/50 rounded-lg'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<p className='font-semibold text-slate-800 dark:text-slate-100'>
|
||||
Delete All Data
|
||||
</p>
|
||||
<p className='text-sm text-slate-500 dark:text-slate-400'>
|
||||
Permanently delete all your medications and history.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className='px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 dark:focus:ring-offset-slate-800 disabled:opacity-50 disabled:cursor-not-allowed flex items-center'
|
||||
>
|
||||
{isDeleting && <Spinner />}
|
||||
{isDeleting ? 'Deleting...' : 'Delete Data'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div className='px-6 py-4 bg-slate-50 dark:bg-slate-700/50 flex justify-end rounded-b-lg border-t border-slate-200 dark:border-slate-700'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onClose}
|
||||
className='px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-md shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:bg-slate-700 dark:text-slate-200 dark:border-slate-600 dark:hover:bg-slate-600 dark:focus:ring-offset-slate-800'
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Spinner = () => (
|
||||
<svg
|
||||
className='animate-spin -ml-1 mr-3 h-5 w-5 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>
|
||||
);
|
||||
|
||||
export default AccountModal;
|
||||
@@ -0,0 +1,193 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { CustomReminder } from '../../types';
|
||||
import { reminderIcons } from '../icons/Icons';
|
||||
|
||||
interface AddReminderModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAdd: (reminder: Omit<CustomReminder, '_id' | '_rev'>) => Promise<void>;
|
||||
}
|
||||
|
||||
const AddReminderModal: React.FC<AddReminderModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onAdd,
|
||||
}) => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [icon, setIcon] = useState('bell');
|
||||
const [frequencyMinutes, setFrequencyMinutes] = useState(60);
|
||||
const [startTime, setStartTime] = useState('09:00');
|
||||
const [endTime, setEndTime] = useState('17:00');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTitle('');
|
||||
setIcon('bell');
|
||||
setFrequencyMinutes(60);
|
||||
setStartTime('09:00');
|
||||
setEndTime('17:00');
|
||||
setIsSaving(false);
|
||||
setTimeout(() => titleInputRef.current?.focus(), 100);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title || isSaving) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onAdd({
|
||||
title,
|
||||
icon,
|
||||
frequencyMinutes,
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to add reminder', error);
|
||||
alert('There was an error saving your reminder. Please try again.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 z-50 flex justify-center items-center p-4'
|
||||
role='dialog'
|
||||
aria-modal='true'
|
||||
aria-labelledby='add-rem-title'
|
||||
>
|
||||
<div className='bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md'>
|
||||
<div className='p-6 border-b border-slate-200 dark:border-slate-700'>
|
||||
<h3
|
||||
id='add-rem-title'
|
||||
className='text-xl font-semibold text-slate-800 dark:text-slate-100'
|
||||
>
|
||||
Add New Reminder
|
||||
</h3>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className='p-6 space-y-4 max-h-[70vh] overflow-y-auto'>
|
||||
<div>
|
||||
<label
|
||||
htmlFor='rem-title'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='rem-title'
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
required
|
||||
ref={titleInputRef}
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white'
|
||||
placeholder='e.g., Drink water'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-slate-700 dark:text-slate-300'>
|
||||
Icon
|
||||
</label>
|
||||
<div className='mt-2 flex flex-wrap gap-2'>
|
||||
{Object.entries(reminderIcons).map(([key, IconComponent]) => (
|
||||
<button
|
||||
key={key}
|
||||
type='button'
|
||||
onClick={() => setIcon(key)}
|
||||
className={`p-2 rounded-full transition-colors ${icon === key ? 'bg-indigo-600 text-white ring-2 ring-offset-2 ring-indigo-500 ring-offset-white dark:ring-offset-slate-800' : 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600'}`}
|
||||
aria-label={`Select ${key} icon`}
|
||||
>
|
||||
<IconComponent className='w-6 h-6' />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor='rem-frequency'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
Remind me every (minutes)
|
||||
</label>
|
||||
<input
|
||||
type='number'
|
||||
id='rem-frequency'
|
||||
value={frequencyMinutes}
|
||||
onChange={e =>
|
||||
setFrequencyMinutes(parseInt(e.target.value, 10))
|
||||
}
|
||||
min='1'
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div>
|
||||
<label
|
||||
htmlFor='rem-startTime'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
From
|
||||
</label>
|
||||
<input
|
||||
type='time'
|
||||
id='rem-startTime'
|
||||
value={startTime}
|
||||
onChange={e => setStartTime(e.target.value)}
|
||||
required
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor='rem-endTime'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
Until
|
||||
</label>
|
||||
<input
|
||||
type='time'
|
||||
id='rem-endTime'
|
||||
value={endTime}
|
||||
onChange={e => setEndTime(e.target.value)}
|
||||
required
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='px-6 py-4 bg-slate-50 dark:bg-slate-700/50 flex justify-end space-x-3 rounded-b-lg border-t border-slate-200 dark:border-slate-700'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
className='px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-md shadow-sm hover:bg-slate-50 disabled:opacity-50 dark:bg-slate-700 dark:text-slate-200 dark:border-slate-600 dark:hover:bg-slate-600'
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type='submit'
|
||||
disabled={isSaving}
|
||||
className='px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center dark:focus:ring-offset-slate-800'
|
||||
>
|
||||
{isSaving ? 'Adding...' : 'Add Reminder'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddReminderModal;
|
||||
@@ -0,0 +1,195 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { CustomReminder } from '../../types';
|
||||
import { reminderIcons } from '../icons/Icons';
|
||||
|
||||
interface EditReminderModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
reminder: CustomReminder | null;
|
||||
onUpdate: (reminder: CustomReminder) => Promise<void>;
|
||||
}
|
||||
|
||||
const EditReminderModal: React.FC<EditReminderModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
reminder,
|
||||
onUpdate,
|
||||
}) => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [icon, setIcon] = useState('bell');
|
||||
const [frequencyMinutes, setFrequencyMinutes] = useState(60);
|
||||
const [startTime, setStartTime] = useState('09:00');
|
||||
const [endTime, setEndTime] = useState('17:00');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && reminder) {
|
||||
setTitle(reminder.title);
|
||||
setIcon(reminder.icon);
|
||||
setFrequencyMinutes(reminder.frequencyMinutes);
|
||||
setStartTime(reminder.startTime);
|
||||
setEndTime(reminder.endTime);
|
||||
setIsSaving(false);
|
||||
setTimeout(() => titleInputRef.current?.focus(), 100);
|
||||
}
|
||||
}, [isOpen, reminder]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title || !reminder || isSaving) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onUpdate({
|
||||
...reminder,
|
||||
title,
|
||||
icon,
|
||||
frequencyMinutes,
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update reminder', error);
|
||||
alert('There was an error updating your reminder. Please try again.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 z-50 flex justify-center items-center p-4'
|
||||
role='dialog'
|
||||
aria-modal='true'
|
||||
aria-labelledby='edit-rem-title'
|
||||
>
|
||||
<div className='bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md'>
|
||||
<div className='p-6 border-b border-slate-200 dark:border-slate-700'>
|
||||
<h3
|
||||
id='edit-rem-title'
|
||||
className='text-xl font-semibold text-slate-800 dark:text-slate-100'
|
||||
>
|
||||
Edit Reminder
|
||||
</h3>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className='p-6 space-y-4 max-h-[70vh] overflow-y-auto'>
|
||||
<div>
|
||||
<label
|
||||
htmlFor='rem-edit-title'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='rem-edit-title'
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
required
|
||||
ref={titleInputRef}
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-slate-700 dark:text-slate-300'>
|
||||
Icon
|
||||
</label>
|
||||
<div className='mt-2 flex flex-wrap gap-2'>
|
||||
{Object.entries(reminderIcons).map(([key, IconComponent]) => (
|
||||
<button
|
||||
key={key}
|
||||
type='button'
|
||||
onClick={() => setIcon(key)}
|
||||
className={`p-2 rounded-full transition-colors ${icon === key ? 'bg-indigo-600 text-white ring-2 ring-offset-2 ring-indigo-500 ring-offset-white dark:ring-offset-slate-800' : 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600'}`}
|
||||
aria-label={`Select ${key} icon`}
|
||||
>
|
||||
<IconComponent className='w-6 h-6' />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor='rem-edit-frequency'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
Remind me every (minutes)
|
||||
</label>
|
||||
<input
|
||||
type='number'
|
||||
id='rem-edit-frequency'
|
||||
value={frequencyMinutes}
|
||||
onChange={e =>
|
||||
setFrequencyMinutes(parseInt(e.target.value, 10))
|
||||
}
|
||||
min='1'
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div>
|
||||
<label
|
||||
htmlFor='rem-edit-startTime'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
From
|
||||
</label>
|
||||
<input
|
||||
type='time'
|
||||
id='rem-edit-startTime'
|
||||
value={startTime}
|
||||
onChange={e => setStartTime(e.target.value)}
|
||||
required
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor='rem-edit-endTime'
|
||||
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
|
||||
>
|
||||
Until
|
||||
</label>
|
||||
<input
|
||||
type='time'
|
||||
id='rem-edit-endTime'
|
||||
value={endTime}
|
||||
onChange={e => setEndTime(e.target.value)}
|
||||
required
|
||||
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='px-6 py-4 bg-slate-50 dark:bg-slate-700/50 flex justify-end space-x-3 rounded-b-lg border-t border-slate-200 dark:border-slate-700'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
className='px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-md shadow-sm hover:bg-slate-50 disabled:opacity-50 dark:bg-slate-700 dark:text-slate-200 dark:border-slate-600 dark:hover:bg-slate-600'
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type='submit'
|
||||
disabled={isSaving}
|
||||
className='px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center dark:focus:ring-offset-slate-800'
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditReminderModal;
|
||||
@@ -0,0 +1,206 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { HistoricalDose } from '../../types';
|
||||
import {
|
||||
PillIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
ClockIcon,
|
||||
} from '../icons/Icons';
|
||||
|
||||
interface HistoryModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
history: { date: string; doses: HistoricalDose[] }[];
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: HistoricalDose['status']) => {
|
||||
switch (status) {
|
||||
case 'TAKEN':
|
||||
return (
|
||||
<CheckCircleIcon className='w-5 h-5 text-green-500 dark:text-green-400' />
|
||||
);
|
||||
case 'MISSED':
|
||||
return <XCircleIcon className='w-5 h-5 text-red-500 dark:text-red-400' />;
|
||||
default:
|
||||
return (
|
||||
<ClockIcon className='w-5 h-5 text-slate-400 dark:text-slate-500' />
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const HistoryModal: React.FC<HistoryModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
history,
|
||||
}) => {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTimeout(() => closeButtonRef.current?.focus(), 100);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
if (isOpen) {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !modalRef.current) return;
|
||||
const focusableElements = modalRef.current.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
const firstElement = focusableElements[0] as HTMLElement;
|
||||
const lastElement = focusableElements[
|
||||
focusableElements.length - 1
|
||||
] as HTMLElement;
|
||||
|
||||
const handleTabKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
lastElement.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
firstElement.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleTabKey);
|
||||
return () => document.removeEventListener('keydown', handleTabKey);
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const userTimezoneOffset = date.getTimezoneOffset() * 60000;
|
||||
return new Date(date.getTime() + userTimezoneOffset).toLocaleDateString(
|
||||
undefined,
|
||||
{
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className='fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 z-50 flex justify-center items-center p-4'
|
||||
role='dialog'
|
||||
aria-modal='true'
|
||||
aria-labelledby='history-title'
|
||||
>
|
||||
<div
|
||||
className='bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-2xl'
|
||||
ref={modalRef}
|
||||
>
|
||||
<div className='p-6 border-b border-slate-200 dark:border-slate-700 flex justify-between items-center'>
|
||||
<h3
|
||||
id='history-title'
|
||||
className='text-xl font-semibold text-slate-800 dark:text-slate-100'
|
||||
>
|
||||
Medication History
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
ref={closeButtonRef}
|
||||
className='text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 text-3xl leading-none'
|
||||
aria-label='Close'
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className='p-6 max-h-[60vh] overflow-y-auto space-y-6'>
|
||||
{history.length > 0 ? (
|
||||
history.map(({ date, doses }) => (
|
||||
<section key={date}>
|
||||
<h4 className='font-bold text-slate-700 dark:text-slate-300 mb-3'>
|
||||
{formatDate(date)}
|
||||
</h4>
|
||||
<ul className='space-y-2'>
|
||||
{doses.map(dose => (
|
||||
<li
|
||||
key={dose.id}
|
||||
className='p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg flex justify-between items-center'
|
||||
>
|
||||
<div className='flex items-center space-x-3'>
|
||||
<div className='flex-shrink-0'>
|
||||
{getStatusIcon(dose.status)}
|
||||
</div>
|
||||
<div>
|
||||
<p className='font-semibold text-slate-800 dark:text-slate-100'>
|
||||
{dose.medication.name}
|
||||
</p>
|
||||
<p className='text-sm text-slate-500 dark:text-slate-400'>
|
||||
{dose.medication.dosage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-right'>
|
||||
<p className='font-medium text-slate-700 dark:text-slate-300'>
|
||||
{dose.scheduledTime.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
{dose.status === 'TAKEN' && dose.takenAt && (
|
||||
<p className='text-xs text-green-600 dark:text-green-500'>
|
||||
Taken at{' '}
|
||||
{new Date(dose.takenAt).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{dose.status === 'MISSED' && (
|
||||
<p className='text-xs text-red-600 dark:text-red-500'>
|
||||
Missed
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
) : (
|
||||
<div className='text-center py-10'>
|
||||
<PillIcon className='w-12 h-12 mx-auto text-slate-300 dark:text-slate-600' />
|
||||
<p className='mt-4 text-slate-500 dark:text-slate-400'>
|
||||
No medication history found.
|
||||
</p>
|
||||
<p className='text-sm text-slate-400 dark:text-slate-500'>
|
||||
History will appear here once you start tracking doses.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='px-6 py-4 bg-slate-50 dark:bg-slate-700/50 flex justify-end rounded-b-lg border-t border-slate-200 dark:border-slate-700'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onClose}
|
||||
className='px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-md shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:bg-slate-700 dark:text-slate-200 dark:border-slate-600 dark:hover:bg-slate-600 dark:focus:ring-offset-slate-800'
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoryModal;
|
||||
@@ -0,0 +1,127 @@
|
||||
import React from 'react';
|
||||
import { CustomReminder } from '../../types';
|
||||
import { TrashIcon, EditIcon, PlusIcon, getReminderIcon } from '../icons/Icons';
|
||||
|
||||
interface ManageRemindersModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
reminders: CustomReminder[];
|
||||
onAdd: () => void;
|
||||
onDelete: (reminder: CustomReminder) => void;
|
||||
onEdit: (reminder: CustomReminder) => void;
|
||||
}
|
||||
|
||||
const ManageRemindersModal: React.FC<ManageRemindersModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
reminders,
|
||||
onAdd,
|
||||
onDelete,
|
||||
onEdit,
|
||||
}) => {
|
||||
const handleDeleteConfirmation = (reminder: CustomReminder) => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Are you sure you want to delete the reminder "${reminder.title}"?`
|
||||
)
|
||||
) {
|
||||
onDelete(reminder);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 z-50 flex justify-center items-center p-4'
|
||||
role='dialog'
|
||||
aria-modal='true'
|
||||
aria-labelledby='manage-rem-title'
|
||||
>
|
||||
<div className='bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-lg'>
|
||||
<div className='p-6 border-b border-slate-200 dark:border-slate-700 flex justify-between items-center'>
|
||||
<h3
|
||||
id='manage-rem-title'
|
||||
className='text-xl font-semibold text-slate-800 dark:text-slate-100'
|
||||
>
|
||||
Manage Custom Reminders
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 text-3xl leading-none'
|
||||
aria-label='Close'
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className='p-6 max-h-[60vh] overflow-y-auto'>
|
||||
{reminders.length > 0 ? (
|
||||
<ul className='space-y-3'>
|
||||
{reminders.map(rem => {
|
||||
const ReminderIcon = getReminderIcon(rem.icon);
|
||||
return (
|
||||
<li
|
||||
key={rem._id}
|
||||
className='p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg flex justify-between items-center'
|
||||
>
|
||||
<div className='flex items-center space-x-3'>
|
||||
<ReminderIcon className='w-6 h-6 text-sky-500 dark:text-sky-400 flex-shrink-0' />
|
||||
<div>
|
||||
<p className='font-semibold text-slate-800 dark:text-slate-100'>
|
||||
{rem.title}
|
||||
</p>
|
||||
<p className='text-sm text-slate-500 dark:text-slate-400'>
|
||||
Every {rem.frequencyMinutes} mins from {rem.startTime}{' '}
|
||||
to {rem.endTime}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center space-x-1'>
|
||||
<button
|
||||
onClick={() => onEdit(rem)}
|
||||
className='p-2 text-indigo-600 hover:bg-indigo-100 dark:text-indigo-400 dark:hover:bg-slate-700 rounded-full'
|
||||
aria-label={`Edit ${rem.title}`}
|
||||
>
|
||||
<EditIcon className='w-5 h-5' />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteConfirmation(rem)}
|
||||
className='p-2 text-red-500 hover:bg-red-100 dark:text-red-400 dark:hover:bg-slate-700 rounded-full'
|
||||
aria-label={`Delete ${rem.title}`}
|
||||
>
|
||||
<TrashIcon className='w-5 h-5' />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className='text-center text-slate-500 dark:text-slate-400 py-8'>
|
||||
No custom reminders have been added yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='px-6 py-4 bg-slate-50 dark:bg-slate-700/50 flex justify-between items-center rounded-b-lg border-t border-slate-200 dark:border-slate-700'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onAdd}
|
||||
className='inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-slate-900'
|
||||
>
|
||||
<PlusIcon className='-ml-1 mr-2 h-5 w-5' />
|
||||
Add New Reminder
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onClose}
|
||||
className='px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-md shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:bg-slate-700 dark:text-slate-200 dark:border-slate-600 dark:hover:bg-slate-600 dark:focus:ring-offset-slate-800'
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManageRemindersModal;
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useState } from 'react';
|
||||
import { PillIcon, PlusIcon, CheckCircleIcon } from './icons/Icons';
|
||||
|
||||
interface OnboardingModalProps {
|
||||
isOpen: boolean;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
const onboardingSteps = [
|
||||
{
|
||||
icon: PillIcon,
|
||||
title: 'Welcome to Medication Reminder!',
|
||||
description:
|
||||
'This quick tour will show you how to get the most out of the app.',
|
||||
},
|
||||
{
|
||||
icon: PlusIcon,
|
||||
title: 'Add Your Medications',
|
||||
description:
|
||||
"Start by clicking the 'Add Medication' button. You can set the name, dosage, frequency, and a custom icon.",
|
||||
},
|
||||
{
|
||||
icon: CheckCircleIcon,
|
||||
title: 'Track Your Doses',
|
||||
description:
|
||||
"Your daily schedule will appear on the main screen. Simply tap 'Take' to record a dose and stay on track with your health.",
|
||||
},
|
||||
];
|
||||
|
||||
const OnboardingModal: React.FC<OnboardingModalProps> = ({
|
||||
isOpen,
|
||||
onComplete,
|
||||
}) => {
|
||||
const [step, setStep] = useState(0);
|
||||
const currentStep = onboardingSteps[step];
|
||||
const isLastStep = step === onboardingSteps.length - 1;
|
||||
|
||||
const handleNext = () => {
|
||||
if (isLastStep) {
|
||||
onComplete();
|
||||
} else {
|
||||
setStep(s => s + 1);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 bg-black bg-opacity-60 dark:bg-opacity-80 z-50 flex justify-center items-center p-4'>
|
||||
<div className='bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-sm text-center p-8 m-4'>
|
||||
<div className='mx-auto bg-indigo-100 dark:bg-indigo-900/50 rounded-full h-16 w-16 flex items-center justify-center mb-6'>
|
||||
<currentStep.icon className='w-8 h-8 text-indigo-600 dark:text-indigo-400' />
|
||||
</div>
|
||||
<h2 className='text-2xl font-bold text-slate-800 dark:text-slate-100 mb-2'>
|
||||
{currentStep.title}
|
||||
</h2>
|
||||
<p className='text-slate-600 dark:text-slate-300 mb-8'>
|
||||
{currentStep.description}
|
||||
</p>
|
||||
|
||||
<div className='flex justify-center items-center mb-8 space-x-2'>
|
||||
{onboardingSteps.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`w-2.5 h-2.5 rounded-full transition-colors ${step === index ? 'bg-indigo-600' : 'bg-slate-300 dark:bg-slate-600'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className='w-full px-4 py-3 text-lg font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-slate-800'
|
||||
>
|
||||
{isLastStep ? 'Get Started' : 'Next'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingModal;
|
||||
@@ -0,0 +1,244 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { DailyStat, MedicationStat } from '../../types';
|
||||
import BarChart from '../ui/BarChart';
|
||||
import { BarChartIcon, getMedicationIcon } from '../icons/Icons';
|
||||
|
||||
interface StatsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
dailyStats: DailyStat[];
|
||||
medicationStats: MedicationStat[];
|
||||
}
|
||||
|
||||
const formatLastTaken = (isoString?: string) => {
|
||||
if (!isoString)
|
||||
return <span className='text-slate-400 dark:text-slate-500'>N/A</span>;
|
||||
|
||||
const date = new Date(isoString);
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate() - 1
|
||||
);
|
||||
|
||||
const timeString = date.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const datePart = new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate()
|
||||
);
|
||||
|
||||
if (datePart.getTime() === today.getTime()) {
|
||||
return `Today at ${timeString}`;
|
||||
}
|
||||
if (datePart.getTime() === yesterday.getTime()) {
|
||||
return `Yesterday at ${timeString}`;
|
||||
}
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const StatsModal: React.FC<StatsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
dailyStats,
|
||||
medicationStats,
|
||||
}) => {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTimeout(() => closeButtonRef.current?.focus(), 100);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
if (isOpen) {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const hasData = medicationStats.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 z-50 flex justify-center items-center p-4'
|
||||
role='dialog'
|
||||
aria-modal='true'
|
||||
aria-labelledby='stats-title'
|
||||
>
|
||||
<div
|
||||
className='bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-3xl'
|
||||
ref={modalRef}
|
||||
>
|
||||
<div className='p-6 border-b border-slate-200 dark:border-slate-700 flex justify-between items-center'>
|
||||
<h3
|
||||
id='stats-title'
|
||||
className='text-xl font-semibold text-slate-800 dark:text-slate-100'
|
||||
>
|
||||
Medication Statistics
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
ref={closeButtonRef}
|
||||
className='text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 text-3xl leading-none'
|
||||
aria-label='Close'
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className='p-6 max-h-[70vh] overflow-y-auto space-y-8'>
|
||||
{hasData ? (
|
||||
<>
|
||||
<section>
|
||||
<h4 className='text-lg font-semibold text-slate-700 dark:text-slate-200 mb-4'>
|
||||
Weekly Adherence
|
||||
</h4>
|
||||
<div className='p-4 bg-slate-50 dark:bg-slate-700/50 rounded-lg'>
|
||||
<BarChart data={dailyStats} />
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h4 className='text-lg font-semibold text-slate-700 dark:text-slate-200 mb-4'>
|
||||
Medication Breakdown
|
||||
</h4>
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='min-w-full divide-y divide-slate-200 dark:divide-slate-700'>
|
||||
<thead className='bg-slate-50 dark:bg-slate-700/50'>
|
||||
<tr>
|
||||
<th
|
||||
scope='col'
|
||||
className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider'
|
||||
>
|
||||
Medication
|
||||
</th>
|
||||
<th
|
||||
scope='col'
|
||||
className='px-4 py-3 text-center text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider'
|
||||
>
|
||||
Taken
|
||||
</th>
|
||||
<th
|
||||
scope='col'
|
||||
className='px-4 py-3 text-center text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider'
|
||||
>
|
||||
Missed
|
||||
</th>
|
||||
<th
|
||||
scope='col'
|
||||
className='px-4 py-3 text-center text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider'
|
||||
>
|
||||
Upcoming
|
||||
</th>
|
||||
<th
|
||||
scope='col'
|
||||
className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider'
|
||||
>
|
||||
Last Taken
|
||||
</th>
|
||||
<th
|
||||
scope='col'
|
||||
className='px-4 py-3 text-right text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider'
|
||||
>
|
||||
Adherence
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='bg-white dark:bg-slate-800 divide-y divide-slate-200 dark:divide-slate-700'>
|
||||
{medicationStats.map(
|
||||
({
|
||||
medication,
|
||||
taken,
|
||||
missed,
|
||||
upcoming,
|
||||
adherence,
|
||||
lastTakenAt,
|
||||
}) => {
|
||||
const MedicationIcon = getMedicationIcon(
|
||||
medication.icon
|
||||
);
|
||||
const adherenceColor =
|
||||
adherence >= 90
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: adherence >= 70
|
||||
? 'text-amber-600 dark:text-amber-400'
|
||||
: 'text-red-600 dark:text-red-400';
|
||||
return (
|
||||
<tr key={medication._id}>
|
||||
<td className='px-4 py-4 whitespace-nowrap'>
|
||||
<div className='flex items-center space-x-3'>
|
||||
<MedicationIcon className='w-6 h-6 text-indigo-500 dark:text-indigo-400 flex-shrink-0' />
|
||||
<div>
|
||||
<div className='text-sm font-semibold text-slate-900 dark:text-slate-100'>
|
||||
{medication.name}
|
||||
</div>
|
||||
<div className='text-xs text-slate-500 dark:text-slate-400'>
|
||||
{medication.dosage}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className='px-4 py-4 whitespace-nowrap text-center text-sm text-slate-500 dark:text-slate-400'>
|
||||
{taken}
|
||||
</td>
|
||||
<td className='px-4 py-4 whitespace-nowrap text-center text-sm text-slate-500 dark:text-slate-400'>
|
||||
{missed}
|
||||
</td>
|
||||
<td className='px-4 py-4 whitespace-nowrap text-center text-sm text-slate-500 dark:text-slate-400'>
|
||||
{upcoming}
|
||||
</td>
|
||||
<td className='px-4 py-4 whitespace-nowrap text-sm text-slate-500 dark:text-slate-400'>
|
||||
{formatLastTaken(lastTakenAt)}
|
||||
</td>
|
||||
<td
|
||||
className={`px-4 py-4 whitespace-nowrap text-right text-sm font-bold ${adherenceColor}`}
|
||||
>
|
||||
{adherence}%
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<div className='text-center py-10'>
|
||||
<BarChartIcon className='w-12 h-12 mx-auto text-slate-300 dark:text-slate-600' />
|
||||
<p className='mt-4 text-slate-500 dark:text-slate-400'>
|
||||
Not enough data to display stats.
|
||||
</p>
|
||||
<p className='text-sm text-slate-400 dark:text-slate-500'>
|
||||
Statistics will appear here once you start tracking your doses.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='px-6 py-4 bg-slate-50 dark:bg-slate-700/50 flex justify-end rounded-b-lg border-t border-slate-200 dark:border-slate-700'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onClose}
|
||||
className='px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-md shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:bg-slate-700 dark:text-slate-200 dark:border-slate-600 dark:hover:bg-slate-600 dark:focus:ring-offset-slate-800'
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsModal;
|
||||
@@ -0,0 +1,8 @@
|
||||
// Modal Components
|
||||
export { default as AccountModal } from './AccountModal';
|
||||
export { default as AddReminderModal } from './AddReminderModal';
|
||||
export { default as EditReminderModal } from './EditReminderModal';
|
||||
export { default as HistoryModal } from './HistoryModal';
|
||||
export { default as ManageRemindersModal } from './ManageRemindersModal';
|
||||
export { default as OnboardingModal } from './OnboardingModal';
|
||||
export { default as StatsModal } from './StatsModal';
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
import { DailyStat } from '../../types';
|
||||
|
||||
interface BarChartProps {
|
||||
data: DailyStat[];
|
||||
}
|
||||
|
||||
const BarChart: React.FC<BarChartProps> = ({ data }) => {
|
||||
const chartHeight = 150;
|
||||
const barWidth = 30;
|
||||
const barMargin = 15;
|
||||
const chartWidth = data.length * (barWidth + barMargin);
|
||||
|
||||
const getDayLabel = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const userTimezoneOffset = date.getTimezoneOffset() * 60000;
|
||||
const adjustedDate = new Date(date.getTime() + userTimezoneOffset);
|
||||
return adjustedDate.toLocaleDateString('en-US', { weekday: 'short' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='w-full overflow-x-auto pb-4'>
|
||||
<svg
|
||||
viewBox={`0 0 ${chartWidth} ${chartHeight + 40}`}
|
||||
width='100%'
|
||||
height='190'
|
||||
aria-labelledby='chart-title'
|
||||
role='img'
|
||||
>
|
||||
<title id='chart-title'>Weekly Medication Adherence Chart</title>
|
||||
|
||||
{/* Y-Axis Labels */}
|
||||
<g className='text-xs fill-current text-slate-500 dark:text-slate-400'>
|
||||
<text x='-5' y='15' textAnchor='end'>
|
||||
100%
|
||||
</text>
|
||||
<text x='-5' y={chartHeight / 2 + 5} textAnchor='end'>
|
||||
50%
|
||||
</text>
|
||||
<text x='-5' y={chartHeight + 5} textAnchor='end'>
|
||||
0%
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* Y-Axis Grid Lines */}
|
||||
<line
|
||||
x1='0'
|
||||
y1='10'
|
||||
x2={chartWidth}
|
||||
y2='10'
|
||||
className='stroke-current text-slate-200 dark:text-slate-600'
|
||||
strokeDasharray='2,2'
|
||||
/>
|
||||
<line
|
||||
x1='0'
|
||||
y1={chartHeight / 2 + 2.5}
|
||||
x2={chartWidth}
|
||||
y2={chartHeight / 2 + 2.5}
|
||||
className='stroke-current text-slate-200 dark:text-slate-600'
|
||||
strokeDasharray='2,2'
|
||||
/>
|
||||
<line
|
||||
x1='0'
|
||||
y1={chartHeight}
|
||||
x2={chartWidth}
|
||||
y2={chartHeight}
|
||||
className='stroke-current text-slate-300 dark:text-slate-500'
|
||||
/>
|
||||
|
||||
{data.map((item, index) => {
|
||||
const x = index * (barWidth + barMargin);
|
||||
const barHeight = (item.adherence / 100) * (chartHeight - 10);
|
||||
const y = chartHeight - barHeight;
|
||||
|
||||
const barColorClass =
|
||||
item.adherence >= 90
|
||||
? 'fill-current text-green-500 dark:text-green-400'
|
||||
: item.adherence >= 70
|
||||
? 'fill-current text-amber-500 dark:text-amber-400'
|
||||
: 'fill-current text-red-500 dark:text-red-400';
|
||||
|
||||
return (
|
||||
<g key={item.date}>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={barWidth}
|
||||
height={barHeight}
|
||||
rx='4'
|
||||
className={barColorClass}
|
||||
>
|
||||
<title>
|
||||
{getDayLabel(item.date)}: {item.adherence}% adherence
|
||||
</title>
|
||||
</rect>
|
||||
<text
|
||||
x={x + barWidth / 2}
|
||||
y={chartHeight + 20}
|
||||
textAnchor='middle'
|
||||
className='text-xs fill-current text-slate-600 dark:text-slate-300 font-medium'
|
||||
>
|
||||
{getDayLabel(item.date)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BarChart;
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { ReminderInstance } from '../../types';
|
||||
import { ClockIcon, getReminderIcon } from '../icons/Icons';
|
||||
|
||||
interface ReminderCardProps {
|
||||
reminder: ReminderInstance;
|
||||
}
|
||||
|
||||
const ReminderCard: React.FC<ReminderCardProps> = ({ reminder }) => {
|
||||
const timeString = reminder.scheduledTime.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const ReminderIcon = getReminderIcon(reminder.icon);
|
||||
|
||||
return (
|
||||
<li className='shadow-md rounded-lg p-4 flex flex-col justify-between transition-all duration-300 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700'>
|
||||
<div>
|
||||
<div className='flex justify-between items-start'>
|
||||
<div className='flex items-center space-x-3'>
|
||||
<ReminderIcon className='w-7 h-7 text-sky-500 dark:text-sky-400 flex-shrink-0' />
|
||||
<div>
|
||||
<h4 className='font-bold text-lg text-slate-800 dark:text-slate-100'>
|
||||
{reminder.title}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center space-x-2 mt-4 font-semibold text-lg text-slate-500 dark:text-slate-400'>
|
||||
<ClockIcon className='w-5 h-5' />
|
||||
<span>{timeString}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReminderCard;
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { SunIcon, MoonIcon, DesktopIcon } from '../icons/Icons';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
const themeOptions: {
|
||||
value: Theme;
|
||||
label: string;
|
||||
icon: React.FC<React.ComponentProps<'svg'>>;
|
||||
}[] = [
|
||||
{ value: 'light', label: 'Light', icon: SunIcon },
|
||||
{ value: 'dark', label: 'Dark', icon: MoonIcon },
|
||||
{ value: 'system', label: 'System', icon: DesktopIcon },
|
||||
];
|
||||
|
||||
const ThemeSwitcher: React.FC = () => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentTheme =
|
||||
themeOptions.find(t => t.value === theme) || themeOptions[2];
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className='relative' ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className='flex items-center justify-center w-10 h-10 rounded-lg bg-slate-100 hover:bg-slate-200 dark:bg-slate-700 dark:hover:bg-slate-600 transition-colors'
|
||||
aria-label={`Current theme: ${currentTheme.label}. Change theme.`}
|
||||
>
|
||||
<SunIcon className='w-5 h-5 text-slate-700 dark:hidden' />
|
||||
<MoonIcon className='w-5 h-5 text-slate-200 hidden dark:block' />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className='absolute right-0 mt-2 w-36 bg-white dark:bg-slate-800 rounded-md shadow-lg ring-1 ring-black ring-opacity-5 py-1 z-30 border dark:border-slate-700'>
|
||||
{themeOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
setTheme(option.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`w-full text-left flex items-center space-x-2 px-3 py-2 text-sm ${
|
||||
theme === option.value
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<option.icon className='w-4 h-4' />
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeSwitcher;
|
||||
@@ -0,0 +1,4 @@
|
||||
// UI Components
|
||||
export { default as BarChart } from './BarChart';
|
||||
export { default as ReminderCard } from './ReminderCard';
|
||||
export { default as ThemeSwitcher } from './ThemeSwitcher';
|
||||
Reference in New Issue
Block a user