/** * Sharing composable * Handles public and internal sharing of recordings */ export function useSharing(state, utils) { const { showShareModal, showSharesListModal, showShareDeleteModal, showUnifiedShareModal, recordingToShare, shareOptions, generatedShareLink, existingShareDetected, recordingPublicShares, isLoadingPublicShares, userShares, isLoadingShares, copiedShareId, shareToDelete, selectedRecording, recordings, internalShareUserSearch, internalShareSearchResults, internalShareRecording, internalSharePermissions, internalShareMaxPermissions, recordingInternalShares, isLoadingInternalShares, isSearchingUsers, allUsers, isLoadingAllUsers, enableInternalSharing, showUsernamesInUI } = state; const { showToast, setGlobalError } = utils; let userSearchTimeout = null; // Helper function to format share dates const formatShareDate = (dateString) => { if (!dateString) return 'Unknown date'; try { const date = new Date(dateString); const now = new Date(); const diffMs = now - date; const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); // If today if (diffDays === 0) { return 'Today at ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); } // If yesterday else if (diffDays === 1) { return 'Yesterday at ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); } // If within last week else if (diffDays < 7) { return date.toLocaleDateString('en-US', { weekday: 'long', hour: 'numeric', minute: '2-digit', hour12: true }); } // Otherwise show full date else { return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }); } } catch (e) { console.error('Error formatting date:', e); return dateString; } }; // Helper function to get color class for username (like speaker colors) const getUserColorClass = (username) => { if (!username) return 'speaker-color-1'; // Simple hash function to generate consistent color from username let hash = 0; for (let i = 0; i < username.length; i++) { hash = ((hash << 5) - hash) + username.charCodeAt(i); hash = hash & hash; // Convert to 32bit integer } // Map to color classes 1-16 const colorNum = (Math.abs(hash) % 16) + 1; return `speaker-color-${colorNum}`; }; // ========================================= // Public Sharing // ========================================= const openShareModal = async (recording) => { recordingToShare.value = recording; shareOptions.share_summary = true; shareOptions.share_notes = true; generatedShareLink.value = ''; existingShareDetected.value = false; recordingPublicShares.value = []; showShareModal.value = true; // Load all public shares for this recording isLoadingPublicShares.value = true; try { const response = await fetch(`/api/shares`); if (response.ok) { const allShares = await response.json(); // Filter to only shares for this recording and add share_url recordingPublicShares.value = allShares .filter(share => share.recording_id === recording.id) .map(share => ({ ...share, share_url: `${window.location.origin}/share/${share.public_id}` })); } } catch (error) { console.error('Error loading public shares:', error); recordingPublicShares.value = []; } finally { isLoadingPublicShares.value = false; } }; const closeShareModal = () => { showShareModal.value = false; recordingToShare.value = null; existingShareDetected.value = false; recordingPublicShares.value = []; }; const createShare = async (forceNew = false) => { const recording = recordingToShare.value || internalShareRecording.value; if (!recording) return; try { const response = await fetch(`/api/recording/${recording.id}/share`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...shareOptions, force_new: forceNew }) }); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Failed to create share link'); generatedShareLink.value = data.share_url; existingShareDetected.value = data.existing && !forceNew; // Add to the shares list (works for both share modal and unified modal) if (!data.existing) { recordingPublicShares.value.push({ ...data.share, share_url: `${window.location.origin}/share/${data.share.public_id}` }); // Update the recording's share count in the UI await refreshRecordingShareCounts(); } else if (data.existing && !recordingPublicShares.value.find(s => s.id === data.share.id)) { // If existing but not in list, add it recordingPublicShares.value.push({ ...data.share, share_url: `${window.location.origin}/share/${data.share.public_id}` }); } if (data.existing && !forceNew) { showToast('Using existing share link', 'fa-link'); } else { showToast('Share link created successfully!', 'fa-check-circle'); } } catch (error) { setGlobalError(`Failed to create share link: ${error.message}`); } }; const confirmDeletePublicShare = (share) => { shareToDelete.value = share; showShareDeleteModal.value = true; }; const deletePublicShare = async () => { if (!shareToDelete.value) return; const shareId = shareToDelete.value.id; try { const response = await fetch(`/api/share/${shareId}`, { method: 'DELETE' }); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Failed to delete share'); // Remove from the shares list (both modals use different arrays) recordingPublicShares.value = recordingPublicShares.value.filter(s => s.id !== shareId); userShares.value = userShares.value.filter(s => s.id !== shareId); // Update the recording's share count in the UI await refreshRecordingShareCounts(); showToast('Share link deleted successfully.', 'fa-check-circle'); showShareDeleteModal.value = false; shareToDelete.value = null; } catch (error) { setGlobalError(`Failed to delete share: ${error.message}`); } }; const copyPublicShareLink = (shareUrl) => { navigator.clipboard.writeText(shareUrl).then(() => { showToast('Share link copied to clipboard!', 'fa-check-circle'); }).catch(() => { setGlobalError('Failed to copy link to clipboard'); }); }; const copyPublicShareLinkWithFeedback = (shareUrl, shareId) => { navigator.clipboard.writeText(shareUrl).then(() => { copiedShareId.value = shareId; showToast('Share link copied to clipboard!', 'fa-check-circle'); // Reset after delay setTimeout(() => { copiedShareId.value = null; }, 1500); }).catch(() => { setGlobalError('Failed to copy link to clipboard'); }); }; const refreshRecordingShareCounts = async () => { // Refresh the current recording if one is selected const recording = recordingToShare.value || internalShareRecording.value || selectedRecording.value; if (!recording) return; try { const response = await fetch(`/api/recordings/${recording.id}`); if (response.ok) { const updatedRecording = await response.json(); // Update in recordings list const index = recordings.value.findIndex(r => r.id === recording.id); if (index !== -1) { // Preserve reactivity by updating specific fields recordings.value[index].public_share_count = updatedRecording.public_share_count || 0; recordings.value[index].shared_with_count = updatedRecording.shared_with_count || 0; } // Update selected recording if it's the same one if (selectedRecording.value && selectedRecording.value.id === recording.id) { selectedRecording.value.public_share_count = updatedRecording.public_share_count || 0; selectedRecording.value.shared_with_count = updatedRecording.shared_with_count || 0; } // Update internal share recording if it's the same one if (internalShareRecording.value && internalShareRecording.value.id === recording.id) { internalShareRecording.value.public_share_count = updatedRecording.public_share_count || 0; internalShareRecording.value.shared_with_count = updatedRecording.shared_with_count || 0; } // Update recording to share if it's the same one if (recordingToShare.value && recordingToShare.value.id === recording.id) { recordingToShare.value.public_share_count = updatedRecording.public_share_count || 0; recordingToShare.value.shared_with_count = updatedRecording.shared_with_count || 0; } } } catch (error) { console.error('Failed to refresh recording share counts:', error); } }; const copyShareLink = () => { if (!generatedShareLink.value) return; navigator.clipboard.writeText(generatedShareLink.value).then(() => { showToast('Share link copied to clipboard!'); }); }; const copyIndividualShareLink = (shareId) => { const input = document.getElementById(`share-link-${shareId}`); if (!input) return; const button = input.nextElementSibling; if (!button) return; navigator.clipboard.writeText(input.value).then(() => { copiedShareId.value = shareId; showToast('Share link copied to clipboard!', 'fa-check'); // Apply success state button.style.transition = 'background-color 0.2s ease'; button.style.backgroundColor = 'var(--bg-success, #10b981)'; // Revert after delay setTimeout(() => { button.style.backgroundColor = ''; copiedShareId.value = null; setTimeout(() => { button.style.transition = ''; }, 200); }, 1500); }).catch(err => { console.error('Failed to copy share link:', err); }); }; // ========================================= // Shares List // ========================================= const openSharesList = async () => { isLoadingShares.value = true; showSharesListModal.value = true; try { const response = await fetch('/api/shares'); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Failed to load shared items'); userShares.value = data; } catch (error) { setGlobalError(`Failed to load shared items: ${error.message}`); } finally { isLoadingShares.value = false; } }; const closeSharesList = () => { showSharesListModal.value = false; userShares.value = []; }; const updateShare = async (share) => { try { const response = await fetch(`/api/share/${share.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ share_summary: share.share_summary, share_notes: share.share_notes }) }); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Failed to update share'); showToast('Share permissions updated.', 'fa-check-circle'); } catch (error) { setGlobalError(`Failed to update share: ${error.message}`); } }; const confirmDeleteShare = (share) => { shareToDelete.value = share; showShareDeleteModal.value = true; }; const cancelDeleteShare = () => { shareToDelete.value = null; showShareDeleteModal.value = false; }; // ========================================= // Internal Sharing // ========================================= const loadAllUsers = async () => { if (!showUsernamesInUI.value) return; isLoadingAllUsers.value = true; try { const response = await fetch('/api/users/search?q='); if (!response.ok) { if (response.status === 403) { throw new Error('Internal sharing is not enabled'); } throw new Error('Failed to load users'); } const data = await response.json(); allUsers.value = data; } catch (error) { setGlobalError(`Failed to load users: ${error.message}`); allUsers.value = []; } finally { isLoadingAllUsers.value = false; } }; const searchInternalShareUsers = async () => { const query = internalShareUserSearch.value.trim(); // If SHOW_USERNAMES_IN_UI is enabled, filter allUsers locally if (showUsernamesInUI.value) { // Get list of user IDs that already have access const sharedUserIds = new Set(recordingInternalShares.value.map(share => share.user_id)); // Filter out already-shared users const availableUsers = allUsers.value.filter(user => !sharedUserIds.has(user.id)); if (query.length === 0) { internalShareSearchResults.value = availableUsers; } else { internalShareSearchResults.value = availableUsers.filter(user => user.username.toLowerCase().includes(query.toLowerCase()) || (user.email && user.email.toLowerCase().includes(query.toLowerCase())) ); } return; } // Otherwise, use server-side search if (query.length < 2) { internalShareSearchResults.value = []; return; } clearTimeout(userSearchTimeout); userSearchTimeout = setTimeout(async () => { isSearchingUsers.value = true; try { const response = await fetch(`/api/users/search?q=${encodeURIComponent(query)}`); if (!response.ok) { if (response.status === 403) { throw new Error('Internal sharing is not enabled'); } throw new Error('Failed to search users'); } const data = await response.json(); internalShareSearchResults.value = data; } catch (error) { setGlobalError(`Failed to search users: ${error.message}`); internalShareSearchResults.value = []; } finally { isSearchingUsers.value = false; } }, 300); }; const openUnifiedShareModal = async (recording) => { internalShareRecording.value = recording; internalShareUserSearch.value = ''; internalShareSearchResults.value = []; internalSharePermissions.value = { can_edit: false, can_reshare: false }; recordingPublicShares.value = []; shareOptions.share_summary = true; shareOptions.share_notes = true; // PERMISSION CEILING: Calculate maximum permissions current user can grant // If viewing a shared recording (not owner), constrain to their permissions if (recording.is_shared && recording.share_info) { internalShareMaxPermissions.value = { can_edit: recording.share_info.can_edit || false, can_reshare: recording.share_info.can_reshare || false }; } else { // Owner has unlimited permissions internalShareMaxPermissions.value = { can_edit: true, can_reshare: true }; } showUnifiedShareModal.value = true; // Load all public shares for this recording isLoadingPublicShares.value = true; try { const response = await fetch(`/api/shares`); if (response.ok) { const allShares = await response.json(); // Filter to only shares for this recording and add share_url recordingPublicShares.value = allShares .filter(share => share.recording_id === recording.id) .map(share => ({ ...share, share_url: `${window.location.origin}/share/${share.public_id}` })); } } catch (error) { console.error('Error loading public shares:', error); recordingPublicShares.value = []; } finally { isLoadingPublicShares.value = false; } // Load existing internal shares isLoadingInternalShares.value = true; try { const response = await fetch(`/api/recordings/${recording.id}/shares-internal`); if (!response.ok) { if (response.status === 403) { throw new Error('Internal sharing is not enabled'); } throw new Error('Failed to load shares'); } const data = await response.json(); recordingInternalShares.value = data.shares || []; } catch (error) { setGlobalError(`Failed to load shares: ${error.message}`); recordingInternalShares.value = []; } finally { isLoadingInternalShares.value = false; } // If SHOW_USERNAMES_IN_UI is enabled, load all users if (showUsernamesInUI.value) { await loadAllUsers(); internalShareSearchResults.value = allUsers.value; } }; const closeUnifiedShareModal = () => { showUnifiedShareModal.value = false; internalShareRecording.value = null; internalShareUserSearch.value = ''; internalShareSearchResults.value = []; recordingInternalShares.value = []; recordingPublicShares.value = []; allUsers.value = []; }; // Legacy function names for backward compatibility const openInternalShareModal = openUnifiedShareModal; const openManageInternalSharesModal = openUnifiedShareModal; const closeInternalShareModal = closeUnifiedShareModal; const closeManageInternalSharesModal = closeUnifiedShareModal; const reloadInternalShares = async () => { if (!internalShareRecording.value) return; isLoadingInternalShares.value = true; try { const response = await fetch(`/api/recordings/${internalShareRecording.value.id}/shares-internal`); if (!response.ok) { throw new Error('Failed to load shares'); } const data = await response.json(); recordingInternalShares.value = data.shares || []; } catch (error) { setGlobalError(`Failed to reload shares: ${error.message}`); } finally { isLoadingInternalShares.value = false; } }; const shareWithUsername = async () => { if (!internalShareRecording.value) return; const username = internalShareUserSearch.value.trim(); if (!username) { setGlobalError('Please enter a username'); return; } isSearchingUsers.value = true; try { // Search for the exact username const searchResponse = await fetch(`/api/users/search?q=${encodeURIComponent(username)}`); if (!searchResponse.ok) { if (searchResponse.status === 403) { throw new Error('Internal sharing is not enabled'); } throw new Error('Failed to find user'); } const users = await searchResponse.json(); if (users.length === 0) { setGlobalError(`User "${username}" not found`); return; } // Use the first matching user (should be exact match from backend) const user = users[0]; await createInternalShare(user.id, user.username); // Clear input on success internalShareUserSearch.value = ''; } catch (error) { setGlobalError(error.message || 'Failed to share with user'); } finally { isSearchingUsers.value = false; } }; const createInternalShare = async (userId, username) => { if (!internalShareRecording.value) return; try { const response = await fetch(`/api/recordings/${internalShareRecording.value.id}/share-internal`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: userId, can_edit: internalSharePermissions.value.can_edit, can_reshare: internalSharePermissions.value.can_reshare }) }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Failed to share recording'); } const displayName = showUsernamesInUI.value ? username : `User #${userId}`; showToast(`Recording shared with ${displayName}`, 'fa-share-alt'); // Reset permissions for next share internalSharePermissions.value = { can_edit: false, can_reshare: false }; // Reload shares to show the new share in the list await reloadInternalShares(); // Update the recording's share count in the UI await refreshRecordingShareCounts(); } catch (error) { setGlobalError(`Failed to share recording: ${error.message}`); } }; const revokeInternalShare = async (shareId, username) => { if (!internalShareRecording.value) return; try { const response = await fetch(`/api/internal-shares/${shareId}`, { method: 'DELETE' }); if (!response.ok) { const data = await response.json(); throw new Error(data.error || 'Failed to revoke share'); } recordingInternalShares.value = recordingInternalShares.value.filter(s => s.id !== shareId); const displayName = showUsernamesInUI.value ? username : 'User'; showToast(`Access revoked for ${displayName}`, 'fa-user-times'); // Update the recording's share count in the UI await refreshRecordingShareCounts(); } catch (error) { setGlobalError(`Failed to revoke share: ${error.message}`); } }; return { // Utilities formatShareDate, getUserColorClass, // Public sharing openShareModal, closeShareModal, createShare, copyShareLink, copyPublicShareLink, copyPublicShareLinkWithFeedback, copyIndividualShareLink, confirmDeletePublicShare, deletePublicShare, refreshRecordingShareCounts, // Shares list openSharesList, closeSharesList, updateShare, confirmDeleteShare, cancelDeleteShare, deleteShare: deletePublicShare, // Alias for template compatibility copiedShareId, // Internal sharing loadAllUsers, searchInternalShareUsers, openUnifiedShareModal, closeUnifiedShareModal, openInternalShareModal, closeInternalShareModal, openManageInternalSharesModal, closeManageInternalSharesModal, reloadInternalShares, shareWithUsername, createInternalShare, revokeInternalShare }; }