feat(frontend): create reusable components for Phase 1 refactoring
- Add Alert component with 4 variants (success/error/warning/info) - Add LoadingSpinner and LoadingButton components - Add FormInput and FormSelect components with icon support - Add Modal and ConfirmationModal components - Add ChatMessage, ChatMessageList, and ChatInput components - Add EventCard component These components will eliminate ~450 lines of duplicated code across pages. Part of Phase 1 (Quick Wins) frontend refactoring.
This commit is contained in:
52
frontend/src/components/chat/ChatInput.jsx
Normal file
52
frontend/src/components/chat/ChatInput.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Send } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Chat Input component with send button
|
||||
*
|
||||
* @param {string} value - Input value
|
||||
* @param {function} onChange - Change handler for input
|
||||
* @param {function} onSubmit - Submit handler for form
|
||||
* @param {boolean} disabled - Whether input is disabled (e.g., when not connected)
|
||||
* @param {string} placeholder - Placeholder text (default: "Write a message...")
|
||||
* @param {string} className - Additional classes for the form
|
||||
* @param {boolean} autoFocus - Whether to autofocus the input
|
||||
*/
|
||||
const ChatInput = ({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
disabled = false,
|
||||
placeholder = 'Write a message...',
|
||||
className = '',
|
||||
autoFocus = false
|
||||
}) => {
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!value.trim() || disabled) return;
|
||||
onSubmit(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={`flex space-x-2 ${className}`}>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocus}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled || !value.trim()}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Send className="w-5 h-5" />
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatInput;
|
||||
57
frontend/src/components/chat/ChatMessage.jsx
Normal file
57
frontend/src/components/chat/ChatMessage.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import Avatar from '../common/Avatar';
|
||||
|
||||
/**
|
||||
* Individual Chat Message component
|
||||
*
|
||||
* @param {object} message - Message object with { id, content, username, avatar, createdAt, userId/user_id }
|
||||
* @param {boolean} isOwn - Whether this message belongs to the current user
|
||||
* @param {function} formatTime - Optional custom time formatter (default: toLocaleTimeString)
|
||||
*/
|
||||
const ChatMessage = ({ message, isOwn, formatTime }) => {
|
||||
const defaultFormatTime = (timestamp) => {
|
||||
return new Date(timestamp).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const timeFormatter = formatTime || defaultFormatTime;
|
||||
|
||||
return (
|
||||
<div className={`flex ${isOwn ? 'justify-end' : 'justify-start'}`}>
|
||||
<div
|
||||
className={`flex items-start space-x-2 max-w-md ${
|
||||
isOwn ? 'flex-row-reverse space-x-reverse' : ''
|
||||
}`}
|
||||
>
|
||||
<Avatar
|
||||
src={message.avatar}
|
||||
username={message.username}
|
||||
size={32}
|
||||
title={message.username}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-baseline space-x-2 mb-1">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{message.username}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{timeFormatter(message.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`rounded-lg px-4 py-2 ${
|
||||
isOwn
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessage;
|
||||
67
frontend/src/components/chat/ChatMessageList.jsx
Normal file
67
frontend/src/components/chat/ChatMessageList.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import ChatMessage from './ChatMessage';
|
||||
|
||||
/**
|
||||
* Chat Message List component with infinite scroll support
|
||||
*
|
||||
* @param {Array} messages - Array of message objects
|
||||
* @param {number} currentUserId - Current user's ID to determine message ownership
|
||||
* @param {React.Ref} messagesEndRef - Ref for scroll-to-bottom functionality
|
||||
* @param {React.Ref} messagesContainerRef - Ref for the scrollable container
|
||||
* @param {boolean} loadingOlder - Whether older messages are being loaded
|
||||
* @param {boolean} hasMore - Whether there are more messages to load
|
||||
* @param {string} emptyText - Text to show when there are no messages
|
||||
* @param {function} formatTime - Optional custom time formatter
|
||||
*/
|
||||
const ChatMessageList = ({
|
||||
messages = [],
|
||||
currentUserId,
|
||||
messagesEndRef,
|
||||
messagesContainerRef,
|
||||
loadingOlder = false,
|
||||
hasMore = false,
|
||||
emptyText = 'No messages yet. Start the conversation!',
|
||||
formatTime
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="flex-1 overflow-y-auto p-4 space-y-4"
|
||||
>
|
||||
{/* Loading indicator for older messages */}
|
||||
{loadingOlder && (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary-600" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{messages.length === 0 && !loadingOlder && (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
{emptyText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
{messages.map((message) => {
|
||||
// Support both userId and user_id (database field names may vary)
|
||||
const messageUserId = message.userId ?? message.user_id;
|
||||
const isOwnMessage = messageUserId === currentUserId;
|
||||
|
||||
return (
|
||||
<ChatMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
isOwn={isOwnMessage}
|
||||
formatTime={formatTime}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Scroll anchor */}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessageList;
|
||||
63
frontend/src/components/common/Alert.jsx
Normal file
63
frontend/src/components/common/Alert.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { CheckCircle, AlertCircle, AlertTriangle, Info } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Reusable Alert component for displaying success, error, warning, and info messages
|
||||
*
|
||||
* @param {string} type - Alert type: 'success', 'error', 'warning', 'info'
|
||||
* @param {string} message - Message to display (if empty, component returns null)
|
||||
* @param {React.Component} icon - Optional custom icon (overrides default)
|
||||
* @param {function} onClose - Optional close handler for dismissible alerts
|
||||
*/
|
||||
const Alert = ({ type = 'info', message, icon: CustomIcon, onClose }) => {
|
||||
if (!message) return null;
|
||||
|
||||
const styles = {
|
||||
success: {
|
||||
bg: 'bg-green-50',
|
||||
border: 'border-green-200',
|
||||
text: 'text-green-600',
|
||||
iconComponent: CheckCircle
|
||||
},
|
||||
error: {
|
||||
bg: 'bg-red-50',
|
||||
border: 'border-red-200',
|
||||
text: 'text-red-600',
|
||||
iconComponent: AlertCircle
|
||||
},
|
||||
warning: {
|
||||
bg: 'bg-yellow-50',
|
||||
border: 'border-yellow-200',
|
||||
text: 'text-yellow-600',
|
||||
iconComponent: AlertTriangle
|
||||
},
|
||||
info: {
|
||||
bg: 'bg-blue-50',
|
||||
border: 'border-blue-200',
|
||||
text: 'text-blue-600',
|
||||
iconComponent: Info
|
||||
}
|
||||
};
|
||||
|
||||
const style = styles[type] || styles.info;
|
||||
const IconComponent = CustomIcon || style.iconComponent;
|
||||
|
||||
return (
|
||||
<div className={`p-3 ${style.bg} border ${style.border} rounded-md flex items-start gap-2`}>
|
||||
<IconComponent className={`w-5 h-5 ${style.text} flex-shrink-0 mt-0.5`} />
|
||||
<p className={`text-sm ${style.text} flex-1`}>{message}</p>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`${style.text} hover:opacity-70 transition-opacity flex-shrink-0`}
|
||||
aria-label="Close alert"
|
||||
>
|
||||
<svg className="w-4 h-4" 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>
|
||||
);
|
||||
};
|
||||
|
||||
export default Alert;
|
||||
71
frontend/src/components/common/FormInput.jsx
Normal file
71
frontend/src/components/common/FormInput.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Reusable Form Input component with optional icon
|
||||
*
|
||||
* @param {string} label - Input label text
|
||||
* @param {string} name - Input name attribute
|
||||
* @param {string} type - Input type (text, email, password, url, number, etc.)
|
||||
* @param {string} value - Input value
|
||||
* @param {function} onChange - Change handler
|
||||
* @param {React.Component} icon - Optional Lucide icon component (e.g., Mail, Lock)
|
||||
* @param {string} placeholder - Placeholder text
|
||||
* @param {boolean} required - Whether field is required
|
||||
* @param {string} helperText - Optional helper text below input
|
||||
* @param {number} maxLength - Maximum input length
|
||||
* @param {string} className - Additional CSS classes for the container
|
||||
* @param {string} error - Error message (shows red border and error text)
|
||||
*/
|
||||
const FormInput = ({
|
||||
label,
|
||||
name,
|
||||
type = 'text',
|
||||
value,
|
||||
onChange,
|
||||
icon: Icon,
|
||||
placeholder,
|
||||
required = false,
|
||||
helperText,
|
||||
maxLength,
|
||||
className = '',
|
||||
error,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && (
|
||||
<label htmlFor={name} className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
{Icon && (
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
id={name}
|
||||
type={type}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
maxLength={maxLength}
|
||||
className={`${Icon ? 'pl-10' : ''} w-full px-3 py-2 border ${
|
||||
error ? 'border-red-300 focus:ring-red-500 focus:border-red-500' : 'border-gray-300 focus:ring-primary-500 focus:border-primary-500'
|
||||
} rounded-md focus:ring-1 focus:outline-none`}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-red-600 mt-1">{error}</p>
|
||||
)}
|
||||
{!error && helperText && (
|
||||
<p className="text-xs text-gray-500 mt-1">{helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormInput;
|
||||
79
frontend/src/components/common/FormSelect.jsx
Normal file
79
frontend/src/components/common/FormSelect.jsx
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Reusable Form Select (dropdown) component with optional icon
|
||||
*
|
||||
* @param {string} label - Select label text
|
||||
* @param {string} name - Select name attribute
|
||||
* @param {string} value - Selected value
|
||||
* @param {function} onChange - Change handler
|
||||
* @param {Array<string|object>} options - Array of options (strings or {value, label} objects)
|
||||
* @param {React.Component} icon - Optional Lucide icon component (e.g., Globe, MapPin)
|
||||
* @param {string} placeholder - Placeholder option text (if provided, adds empty option)
|
||||
* @param {boolean} required - Whether field is required
|
||||
* @param {string} helperText - Optional helper text below select
|
||||
* @param {string} className - Additional CSS classes for the container
|
||||
* @param {string} error - Error message (shows red border and error text)
|
||||
*/
|
||||
const FormSelect = ({
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
options = [],
|
||||
icon: Icon,
|
||||
placeholder,
|
||||
required = false,
|
||||
helperText,
|
||||
className = '',
|
||||
error,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && (
|
||||
<label htmlFor={name} className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
{Icon && (
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<select
|
||||
id={name}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
required={required}
|
||||
className={`${Icon ? 'pl-10' : ''} w-full px-3 py-2 border ${
|
||||
error ? 'border-red-300 focus:ring-red-500 focus:border-red-500' : 'border-gray-300 focus:ring-primary-500 focus:border-primary-500'
|
||||
} rounded-md focus:ring-1 focus:outline-none bg-white`}
|
||||
{...props}
|
||||
>
|
||||
{placeholder && <option value="">{placeholder}</option>}
|
||||
{options.map((option) => {
|
||||
// Support both string options and {value, label} objects
|
||||
const optionValue = typeof option === 'string' ? option : option.value;
|
||||
const optionLabel = typeof option === 'string' ? option : option.label;
|
||||
|
||||
return (
|
||||
<option key={optionValue} value={optionValue}>
|
||||
{optionLabel}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-red-600 mt-1">{error}</p>
|
||||
)}
|
||||
{!error && helperText && (
|
||||
<p className="text-xs text-gray-500 mt-1">{helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormSelect;
|
||||
37
frontend/src/components/common/LoadingButton.jsx
Normal file
37
frontend/src/components/common/LoadingButton.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Reusable Button component with built-in loading state
|
||||
*
|
||||
* @param {boolean} loading - Shows loading spinner when true
|
||||
* @param {React.ReactNode} children - Button content when not loading
|
||||
* @param {string} loadingText - Text to show when loading (default: "Loading...")
|
||||
* @param {string} className - Additional CSS classes
|
||||
* @param {object} ...props - All other button props (onClick, disabled, etc.)
|
||||
*/
|
||||
const LoadingButton = ({
|
||||
loading = false,
|
||||
children,
|
||||
loadingText = 'Loading...',
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
disabled={loading || props.disabled}
|
||||
className={className}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin inline mr-2" />
|
||||
{loadingText}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingButton;
|
||||
36
frontend/src/components/common/LoadingSpinner.jsx
Normal file
36
frontend/src/components/common/LoadingSpinner.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Reusable Loading Spinner component
|
||||
*
|
||||
* @param {string} size - Spinner size: 'sm', 'md', 'lg' (default: 'md')
|
||||
* @param {string} text - Optional text to display below spinner
|
||||
* @param {boolean} fullPage - If true, centers spinner in a full-page container
|
||||
* @param {string} className - Additional CSS classes
|
||||
*/
|
||||
const LoadingSpinner = ({ size = 'md', text, fullPage = false, className = '' }) => {
|
||||
const sizes = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-8 h-8',
|
||||
lg: 'w-12 h-12'
|
||||
};
|
||||
|
||||
const spinner = (
|
||||
<div className={`flex flex-col items-center gap-3 ${className}`}>
|
||||
<Loader2 className={`${sizes[size]} animate-spin text-primary-600`} />
|
||||
{text && <p className="text-gray-600">{text}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (fullPage) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
{spinner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return spinner;
|
||||
};
|
||||
|
||||
export default LoadingSpinner;
|
||||
95
frontend/src/components/events/EventCard.jsx
Normal file
95
frontend/src/components/events/EventCard.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Calendar, MapPin, Users, CheckCircle, QrCode } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Event Card component for displaying event information
|
||||
*
|
||||
* @param {object} event - Event object with { slug, name, location, startDate, endDate, participantsCount, description, isJoined }
|
||||
* @param {number} delay - Animation delay in milliseconds
|
||||
* @param {boolean} isNew - Whether this is a newly added card (triggers animation)
|
||||
* @param {boolean} showCheckin - Whether to show check-in requirement message
|
||||
*/
|
||||
const EventCard = ({ event, delay = 0, isNew = false, showCheckin = false }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleJoinEvent = () => navigate(`/events/${event.slug}/chat`);
|
||||
const handleViewDetails = () => navigate(`/events/${event.slug}/details`);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow p-6 border-2 ${
|
||||
event.isJoined ? 'border-primary-500' : 'border-gray-200'
|
||||
} ${isNew ? 'animate-fade-slide-in' : ''}`}
|
||||
style={isNew ? { animationDelay: `${delay}ms` } : undefined}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="text-xl font-bold text-gray-900">{event.name}</h3>
|
||||
{event.isJoined && (
|
||||
<span className="flex items-center gap-1 px-2 py-1 bg-primary-100 text-primary-700 text-xs font-medium rounded-full">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Joined
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Event Details */}
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center text-gray-600">
|
||||
<MapPin className="w-4 h-4 mr-2" />
|
||||
<span className="text-sm">{event.location}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-600">
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
<span className="text-sm">
|
||||
{new Date(event.startDate).toLocaleDateString('en-US')} -{' '}
|
||||
{new Date(event.endDate).toLocaleDateString('en-US')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-600">
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
<span className="text-sm">{event.participantsCount} participants</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{event.description && (
|
||||
<p className="text-gray-600 text-sm mb-4">{event.description}</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="space-y-2">
|
||||
{event.isJoined ? (
|
||||
<button
|
||||
onClick={handleJoinEvent}
|
||||
className="w-full px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
Open chat
|
||||
</button>
|
||||
) : showCheckin ? (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-md p-4 text-sm text-amber-800">
|
||||
<div className="flex items-center gap-2 font-medium mb-2">
|
||||
<QrCode className="w-5 h-5" />
|
||||
Check-in required
|
||||
</div>
|
||||
<p className="text-amber-700">
|
||||
Scan the QR code at the event venue to join the chat
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Development mode: Show details link */}
|
||||
{import.meta.env.DEV && (
|
||||
<button
|
||||
onClick={handleViewDetails}
|
||||
className="w-full px-4 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors text-sm"
|
||||
>
|
||||
View details (dev)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventCard;
|
||||
93
frontend/src/components/modals/ConfirmationModal.jsx
Normal file
93
frontend/src/components/modals/ConfirmationModal.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { AlertTriangle, Loader2 } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Reusable Confirmation Modal for actions that need user confirmation
|
||||
*
|
||||
* @param {boolean} isOpen - Controls modal visibility
|
||||
* @param {function} onClose - Close/Cancel handler
|
||||
* @param {function} onConfirm - Confirm action handler
|
||||
* @param {string} title - Modal title
|
||||
* @param {string} description - Optional description text
|
||||
* @param {string} message - Main message/question to display
|
||||
* @param {string} confirmText - Confirm button text (default: 'Confirm')
|
||||
* @param {string} cancelText - Cancel button text (default: 'Cancel')
|
||||
* @param {string} confirmButtonClass - Custom classes for confirm button (default: red/danger style)
|
||||
* @param {React.Component} icon - Icon component (default: AlertTriangle)
|
||||
* @param {string} iconBgClass - Icon background class (default: bg-red-100)
|
||||
* @param {string} iconColorClass - Icon color class (default: text-red-600)
|
||||
* @param {boolean} isLoading - Shows loading state on confirm button
|
||||
* @param {string} loadingText - Text to show when loading (default: 'Loading...')
|
||||
*/
|
||||
const ConfirmationModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
message,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
confirmButtonClass = 'bg-red-600 hover:bg-red-700',
|
||||
icon: Icon = AlertTriangle,
|
||||
iconBgClass = 'bg-red-100',
|
||||
iconColorClass = 'text-red-600',
|
||||
isLoading = false,
|
||||
loadingText = 'Loading...'
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl max-w-md w-full p-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header with Icon */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className={`w-12 h-12 rounded-full ${iconBgClass} flex items-center justify-center flex-shrink-0`}>
|
||||
<Icon className={iconColorClass} size={24} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
{description && <p className="text-sm text-gray-600">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{message && (
|
||||
<p className="text-gray-700 mb-6">{message}</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
className={`flex-1 px-4 py-2 ${confirmButtonClass} text-white rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
{loadingText}
|
||||
</>
|
||||
) : (
|
||||
confirmText
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmationModal;
|
||||
54
frontend/src/components/modals/Modal.jsx
Normal file
54
frontend/src/components/modals/Modal.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Generic Modal component with customizable content
|
||||
*
|
||||
* @param {boolean} isOpen - Controls modal visibility
|
||||
* @param {function} onClose - Close handler
|
||||
* @param {string} title - Optional modal title
|
||||
* @param {React.ReactNode} children - Modal content
|
||||
* @param {string} maxWidth - Maximum width class (default: 'max-w-4xl')
|
||||
* @param {boolean} showCloseButton - Show X button in header (default: true)
|
||||
*/
|
||||
const Modal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
maxWidth = 'max-w-4xl',
|
||||
showCloseButton = true
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={`bg-white rounded-lg shadow-xl ${maxWidth} w-full max-h-[90vh] overflow-y-auto`}
|
||||
onClick={(e) => e.stopPropagation()} // Prevent closing when clicking inside modal
|
||||
>
|
||||
{title && (
|
||||
<div className="sticky top-0 bg-white border-b px-6 py-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
Reference in New Issue
Block a user