Files

660 lines
25 KiB
JavaScript

/**
* 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
};
}