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:
William Valentin
2025-09-06 01:42:48 -07:00
commit e48adbcb00
159 changed files with 24405 additions and 0 deletions
+98
View File
@@ -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
+361
View File
@@ -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;
+2
View File
@@ -0,0 +1,2 @@
// Admin Components
export { default as AdminInterface } from './AdminInterface';
+316
View File
@@ -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;
+112
View File
@@ -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;
+192
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
// Authentication Components
export { default as AuthPage } from './AuthPage';
export { default as AvatarDropdown } from './AvatarDropdown';
export { default as ChangePasswordModal } from './ChangePasswordModal';
+546
View File
@@ -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;
+158
View File
@@ -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'
>
&times;
</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} &bull; {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;
+5
View File
@@ -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';
+301
View File
@@ -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'
>
&times;
</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;
+193
View File
@@ -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;
+195
View File
@@ -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;
+206
View File
@@ -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'
>
&times;
</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;
+127
View File
@@ -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'
>
&times;
</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;
+81
View File
@@ -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;
+244
View File
@@ -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'
>
&times;
</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;
+8
View File
@@ -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';
+112
View File
@@ -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;
+38
View File
@@ -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;
+74
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
// UI Components
export { default as BarChart } from './BarChart';
export { default as ReminderCard } from './ReminderCard';
export { default as ThemeSwitcher } from './ThemeSwitcher';