diff --git a/components/ActivityLog.js b/components/ActivityLog.js new file mode 100644 index 0000000..beff00c --- /dev/null +++ b/components/ActivityLog.js @@ -0,0 +1,172 @@ +import { useState, useEffect } from 'react'; + +export default function ActivityLog({ collectionId }) { + const [activities, setActivities] = useState([]); + const [loading, setLoading] = useState(true); + const [showAll, setShowAll] = useState(false); + + useEffect(() => { + if (collectionId) { + fetchActivities(); + } + }, [collectionId]); + + const fetchActivities = async () => { + try { + const response = await fetch(`/api/collections/${collectionId}/activity`); + if (response.ok) { + const data = await response.json(); + setActivities(data); + } + } catch (error) { + console.error('Error fetching activities:', error); + } finally { + setLoading(false); + } + }; + + const getActivityIcon = (action) => { + switch (action) { + case 'card_added': return '➕'; + case 'card_removed': return '➖'; + case 'card_quantity_updated': return '��'; + case 'collection_updated': return '✏️'; + case 'collection_deleted': return '🗑️'; + case 'user_invited': return '📧'; + case 'invitation_accepted': return '✅'; + case 'invitation_declined': return '❌'; + case 'permission_updated': return '🔐'; + case 'user_removed': return '👋'; + default: return '📝'; + } + }; + + const getActivityDescription = (activity) => { + const { action, details } = activity; + + switch (action) { + case 'card_added': + return `added a card (qty: ${details.quantity})`; + case 'card_removed': + return details.reason === 'quantity_zero' + ? 'removed a card by setting quantity to 0' + : 'removed a card from the collection'; + case 'card_quantity_updated': + return details.oldQuantity + ? `updated card quantity from ${details.oldQuantity} to ${details.newQuantity}` + : `updated card quantity to ${details.newQuantity}`; + case 'collection_updated': + return 'updated collection details'; + case 'collection_deleted': + return 'deleted the collection'; + case 'user_invited': + return `invited ${details.email} as ${details.role}`; + case 'invitation_accepted': + return 'accepted an invitation'; + case 'invitation_declined': + return 'declined an invitation'; + case 'permission_updated': + return `updated user permissions to ${details.role}`; + case 'user_removed': + return 'removed a user from the collection'; + default: + return action.replace(/_/g, ' '); + } + }; + + const formatTimeAgo = (timestamp) => { + const now = new Date(); + const activityTime = new Date(timestamp); + const diffMs = now - activityTime; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return activityTime.toLocaleDateString(); + }; + + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+
+ ); + } + + const displayedActivities = showAll ? activities : activities.slice(0, 5); + + return ( +
+
+

+ Recent Activity +

+ {activities.length > 5 && ( + + )} +
+ +
+ {displayedActivities.map((activity) => ( +
+
+ {getActivityIcon(activity.action)} +
+
+
+

+ {activity.user_email || 'System'} + {' '} + {getActivityDescription(activity)} +

+ + {formatTimeAgo(activity.created_at)} + +
+ {activity.details && Object.keys(activity.details).length > 0 && ( +
+ {activity.action === 'collection_updated' && activity.details.name && ( + Name: "{activity.details.name}" + )} + {activity.action === 'user_invited' && activity.details.message && ( + Message: "{activity.details.message}" + )} +
+ )} +
+
+ ))} + + {activities.length === 0 && ( +
+
📝
+

+ No activity yet. Start collaborating to see updates here! +

+
+ )} +
+
+ ); +} diff --git a/components/BulkInviteModal.js b/components/BulkInviteModal.js new file mode 100644 index 0000000..9298a63 --- /dev/null +++ b/components/BulkInviteModal.js @@ -0,0 +1,220 @@ +import { useState } from 'react'; + +export default function BulkInviteModal({ collectionId, isOpen, onClose, onSuccess }) { + const [inviteData, setInviteData] = useState({ + emails: '', + role: 'viewer', + message: '' + }); + const [processing, setProcessing] = useState(false); + const [results, setResults] = useState(null); + + const handleSubmit = async (e) => { + e.preventDefault(); + setProcessing(true); + setResults(null); + + try { + // Parse emails (split by comma, semicolon, or newline) + const emailList = inviteData.emails + .split(/[,;\n]/) + .map(email => email.trim()) + .filter(email => email && email.includes('@')); + + if (emailList.length === 0) { + alert('Please enter at least one valid email address'); + setProcessing(false); + return; + } + + // Send invitations + const invitePromises = emailList.map(email => + fetch(`/api/collections/${collectionId}/permissions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email, + role: inviteData.role, + message: inviteData.message + }) + }).then(async response => ({ + email, + success: response.ok, + error: response.ok ? null : (await response.json()).error + })) + ); + + const results = await Promise.all(invitePromises); + setResults(results); + + const successCount = results.filter(r => r.success).length; + if (successCount > 0 && onSuccess) { + onSuccess(); + } + + } catch (error) { + console.error('Bulk invite error:', error); + alert('An error occurred while sending invitations'); + } finally { + setProcessing(false); + } + }; + + const handleClose = () => { + setInviteData({ emails: '', role: 'viewer', message: '' }); + setResults(null); + onClose(); + }; + + const successCount = results ? results.filter(r => r.success).length : 0; + const failureCount = results ? results.filter(r => !r.success).length : 0; + + if (!isOpen) return null; + + return ( +
+
+

+ Bulk Invite Collaborators +

+ + {!results ? ( +
+
+ +