2111 lines
78 KiB
JavaScript
2111 lines
78 KiB
JavaScript
/**
|
|
* UI management composable
|
|
* Handles dark mode, color schemes, sidebar, and other UI state
|
|
*/
|
|
|
|
export function useUI(state, utils, processedTranscription) {
|
|
const {
|
|
isDarkMode, currentColorScheme, colorSchemes, isSidebarCollapsed,
|
|
showColorSchemeModal, isUserMenuOpen, currentView, selectedRecording,
|
|
windowWidth, isMobileScreen, showAdvancedFilters, showSortOptions,
|
|
searchTipsExpanded, isMetadataExpanded, editingParticipants, editingMeetingDate,
|
|
editingSummary, tempSummaryContent, summaryMarkdownEditorInstance,
|
|
leftColumnWidth, rightColumnWidth, isResizing, playerVolume,
|
|
audioIsPlaying, audioCurrentTime, audioDuration, audioIsMuted, audioIsLoading,
|
|
editingNotes, tempNotesContent, transcriptionViewMode,
|
|
notesMarkdownEditor, markdownEditorInstance, autoSaveTimer, csrfToken,
|
|
summaryMarkdownEditor, recordingNotesEditor, recordingMarkdownEditorInstance,
|
|
recordingNotes, showDownloadMenu, currentPlayingSegmentIndex, followPlayerMode,
|
|
playbackRate, showSpeedMenu, playbackSpeeds, modalPlaybackRate, speedMenuPosition,
|
|
videoFullscreen, fullscreenControlsVisible, fullscreenControlsTimer, videoCollapsed
|
|
} = state;
|
|
|
|
const autoSaveDelay = 2000; // 2 seconds
|
|
|
|
const { showToast, nextTick, t } = utils;
|
|
const { ref, computed, watch } = Vue;
|
|
|
|
// isMobile computed
|
|
const isMobile = computed(() => windowWidth.value < 768);
|
|
|
|
// Toggle dark mode
|
|
const toggleDarkMode = () => {
|
|
isDarkMode.value = !isDarkMode.value;
|
|
localStorage.setItem('darkMode', isDarkMode.value);
|
|
|
|
if (isDarkMode.value) {
|
|
document.documentElement.classList.add('dark');
|
|
} else {
|
|
document.documentElement.classList.remove('dark');
|
|
}
|
|
|
|
// Re-apply current color scheme for new mode
|
|
applyColorScheme(currentColorScheme.value);
|
|
};
|
|
|
|
// Initialize dark mode from storage
|
|
const initializeDarkMode = () => {
|
|
const savedMode = localStorage.getItem('darkMode');
|
|
if (savedMode !== null) {
|
|
isDarkMode.value = savedMode === 'true';
|
|
} else {
|
|
// Check system preference
|
|
isDarkMode.value = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
}
|
|
|
|
if (isDarkMode.value) {
|
|
document.documentElement.classList.add('dark');
|
|
} else {
|
|
document.documentElement.classList.remove('dark');
|
|
}
|
|
};
|
|
|
|
// Apply a color scheme
|
|
const applyColorScheme = (schemeId, mode = null) => {
|
|
const targetMode = mode || (isDarkMode.value ? 'dark' : 'light');
|
|
const scheme = colorSchemes[targetMode].find(s => s.id === schemeId);
|
|
|
|
if (!scheme) {
|
|
console.warn(`Color scheme '${schemeId}' not found for mode '${targetMode}'`);
|
|
return;
|
|
}
|
|
|
|
// Remove all theme classes
|
|
const allThemeClasses = [
|
|
...colorSchemes.light.map(s => s.class),
|
|
...colorSchemes.dark.map(s => s.class)
|
|
].filter(c => c !== '');
|
|
|
|
document.documentElement.classList.remove(...allThemeClasses);
|
|
|
|
// Add new theme class if not default
|
|
if (scheme.class) {
|
|
document.documentElement.classList.add(scheme.class);
|
|
}
|
|
|
|
currentColorScheme.value = schemeId;
|
|
localStorage.setItem('colorScheme', schemeId);
|
|
};
|
|
|
|
// Initialize color scheme from storage
|
|
const initializeColorScheme = () => {
|
|
const savedScheme = localStorage.getItem('colorScheme');
|
|
if (savedScheme) {
|
|
applyColorScheme(savedScheme);
|
|
} else {
|
|
// Apply default scheme
|
|
applyColorScheme('blue');
|
|
}
|
|
};
|
|
|
|
// Open color scheme modal
|
|
const openColorSchemeModal = () => {
|
|
showColorSchemeModal.value = true;
|
|
isUserMenuOpen.value = false;
|
|
};
|
|
|
|
// Close color scheme modal
|
|
const closeColorSchemeModal = () => {
|
|
showColorSchemeModal.value = false;
|
|
};
|
|
|
|
// Select a color scheme
|
|
const selectColorScheme = (schemeId) => {
|
|
applyColorScheme(schemeId);
|
|
showToast(t('messages.colorSchemeApplied'), 'fa-palette');
|
|
};
|
|
|
|
// Reset to default color scheme
|
|
const resetColorScheme = () => {
|
|
applyColorScheme('blue');
|
|
showToast(t('messages.colorSchemeReset'), 'fa-undo');
|
|
};
|
|
|
|
// Toggle sidebar
|
|
const toggleSidebar = () => {
|
|
isSidebarCollapsed.value = !isSidebarCollapsed.value;
|
|
localStorage.setItem('sidebarCollapsed', isSidebarCollapsed.value);
|
|
};
|
|
|
|
// Initialize sidebar state
|
|
const initializeSidebar = () => {
|
|
const saved = localStorage.getItem('sidebarCollapsed');
|
|
if (saved !== null) {
|
|
isSidebarCollapsed.value = saved === 'true';
|
|
}
|
|
};
|
|
|
|
// Switch to upload view
|
|
const switchToUploadView = () => {
|
|
currentView.value = 'upload';
|
|
if (isMobileScreen.value) {
|
|
isSidebarCollapsed.value = true;
|
|
}
|
|
};
|
|
|
|
// Switch to detail view
|
|
const switchToDetailView = () => {
|
|
currentView.value = 'detail';
|
|
};
|
|
|
|
// Switch to recording view
|
|
const switchToRecordingView = () => {
|
|
currentView.value = 'recording';
|
|
if (isMobileScreen.value) {
|
|
isSidebarCollapsed.value = true;
|
|
}
|
|
};
|
|
|
|
// Set global error
|
|
const setGlobalError = (message, duration = 7000) => {
|
|
if (state.globalError) {
|
|
state.globalError.value = message;
|
|
if (duration > 0) {
|
|
setTimeout(() => {
|
|
if (state.globalError.value === message) {
|
|
state.globalError.value = null;
|
|
}
|
|
}, duration);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Format file size
|
|
const formatFileSize = (bytes) => {
|
|
if (!bytes) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
};
|
|
|
|
// Format display date
|
|
const formatDisplayDate = (dateString) => {
|
|
if (!dateString) return '';
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString(undefined, {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
};
|
|
|
|
// Format short date
|
|
const formatShortDate = (dateString) => {
|
|
if (!dateString) return '';
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diff = now - date;
|
|
const oneDay = 24 * 60 * 60 * 1000;
|
|
|
|
if (diff < oneDay) {
|
|
return date.toLocaleTimeString(undefined, {
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
} else if (diff < 7 * oneDay) {
|
|
return date.toLocaleDateString(undefined, {
|
|
weekday: 'short'
|
|
});
|
|
} else {
|
|
return date.toLocaleDateString(undefined, {
|
|
month: 'short',
|
|
day: 'numeric'
|
|
});
|
|
}
|
|
};
|
|
|
|
// Format status
|
|
const formatStatus = (status) => {
|
|
if (!status || status === 'COMPLETED') return '';
|
|
const statusMap = {
|
|
'PENDING': t('status.queued'),
|
|
'QUEUED': t('status.queued'),
|
|
'PROCESSING': t('status.processing'),
|
|
'TRANSCRIBING': t('status.transcribing'),
|
|
'SUMMARIZING': t('status.summarizing'),
|
|
'FAILED': t('status.failed'),
|
|
'UPLOADING': t('status.uploading')
|
|
};
|
|
return statusMap[status] || status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
|
|
};
|
|
|
|
// Get status class
|
|
const getStatusClass = (status) => {
|
|
switch(status) {
|
|
case 'PENDING': return 'status-pending';
|
|
case 'QUEUED': return 'status-pending';
|
|
case 'PROCESSING': return 'status-processing';
|
|
case 'SUMMARIZING': return 'status-summarizing';
|
|
case 'COMPLETED': return '';
|
|
case 'FAILED': return 'status-failed';
|
|
default: return 'status-pending';
|
|
}
|
|
};
|
|
|
|
// Format time (seconds to HH:MM:SS)
|
|
const formatTime = (seconds) => {
|
|
if (!seconds && seconds !== 0) return '00:00';
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
|
|
if (h > 0) {
|
|
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
}
|
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
// Format duration in seconds to human readable
|
|
const formatDuration = (seconds) => {
|
|
if (!seconds && seconds !== 0) return '';
|
|
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
|
|
const parts = [];
|
|
if (h > 0) parts.push(`${h}h`);
|
|
if (m > 0) parts.push(`${m}m`);
|
|
if (s > 0 || parts.length === 0) parts.push(`${s}s`);
|
|
|
|
return parts.join(' ');
|
|
};
|
|
|
|
// Format processing duration
|
|
const formatProcessingDuration = (seconds) => {
|
|
if (!seconds) return '';
|
|
if (seconds < 60) {
|
|
return `${Math.round(seconds)}s`;
|
|
} else if (seconds < 3600) {
|
|
const m = Math.floor(seconds / 60);
|
|
const s = Math.round(seconds % 60);
|
|
return `${m}m ${s}s`;
|
|
} else {
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.round((seconds % 3600) / 60);
|
|
return `${h}h ${m}m`;
|
|
}
|
|
};
|
|
|
|
// --- Inline Editing ---
|
|
const saveInlineEdit = async (field) => {
|
|
if (!selectedRecording.value) return;
|
|
|
|
const fullPayload = {
|
|
id: selectedRecording.value.id,
|
|
title: selectedRecording.value.title,
|
|
participants: selectedRecording.value.participants,
|
|
notes: selectedRecording.value.notes,
|
|
summary: selectedRecording.value.summary,
|
|
meeting_date: selectedRecording.value.meeting_date
|
|
};
|
|
|
|
try {
|
|
const csrfTokenValue = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
|
const response = await fetch('/save', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': csrfTokenValue
|
|
},
|
|
body: JSON.stringify(fullPayload)
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (!response.ok) throw new Error(data.error || 'Failed to save metadata');
|
|
|
|
// Update the recording with returned HTML
|
|
if (data.recording) {
|
|
if (field === 'notes' && data.recording.notes_html) {
|
|
selectedRecording.value.notes_html = data.recording.notes_html;
|
|
} else if (field === 'summary' && data.recording.summary_html) {
|
|
selectedRecording.value.summary_html = data.recording.summary_html;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
showToast(t('messages.failedToSave', { error: error.message }), 'fa-exclamation-circle', 3000, 'error');
|
|
}
|
|
};
|
|
|
|
const toggleEditParticipants = () => {
|
|
editingParticipants.value = !editingParticipants.value;
|
|
if (!editingParticipants.value) {
|
|
saveInlineEdit('participants');
|
|
}
|
|
};
|
|
|
|
const toggleEditMeetingDate = () => {
|
|
editingMeetingDate.value = !editingMeetingDate.value;
|
|
if (!editingMeetingDate.value) {
|
|
saveInlineEdit('meeting_date');
|
|
}
|
|
};
|
|
|
|
const toggleEditTitle = () => {
|
|
if (!selectedRecording.value) return;
|
|
|
|
// Check if user has permission to edit
|
|
if (selectedRecording.value.can_edit === false) {
|
|
showToast(t('messages.noPermissionToEdit'), 'fa-exclamation-circle', 3000, 'error');
|
|
return;
|
|
}
|
|
|
|
if (!state.editingTitle.value) {
|
|
// Start editing
|
|
state.originalTitle.value = selectedRecording.value.title || '';
|
|
state.editingTitle.value = true;
|
|
nextTick(() => {
|
|
// Focus the input field
|
|
const titleInput = document.querySelector('input[ref="titleInput"]');
|
|
if (titleInput) {
|
|
titleInput.focus();
|
|
titleInput.select();
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
const saveTitle = async () => {
|
|
if (!selectedRecording.value) return;
|
|
|
|
state.editingTitle.value = false;
|
|
|
|
// Only save if title changed
|
|
if (selectedRecording.value.title !== state.originalTitle.value) {
|
|
await saveInlineEdit('title');
|
|
}
|
|
};
|
|
|
|
const cancelEditTitle = () => {
|
|
if (!selectedRecording.value) return;
|
|
|
|
// Restore original title
|
|
selectedRecording.value.title = state.originalTitle.value;
|
|
state.editingTitle.value = false;
|
|
};
|
|
|
|
const toggleEditSummary = () => {
|
|
editingSummary.value = !editingSummary.value;
|
|
if (editingSummary.value) {
|
|
tempSummaryContent.value = selectedRecording.value?.summary || '';
|
|
nextTick(() => {
|
|
initializeSummaryMarkdownEditor();
|
|
});
|
|
}
|
|
};
|
|
|
|
const cancelEditSummary = () => {
|
|
if (summaryMarkdownEditorInstance.value) {
|
|
summaryMarkdownEditorInstance.value.toTextArea();
|
|
summaryMarkdownEditorInstance.value = null;
|
|
}
|
|
editingSummary.value = false;
|
|
// Restore original content
|
|
if (selectedRecording.value) {
|
|
selectedRecording.value.summary = tempSummaryContent.value;
|
|
}
|
|
};
|
|
|
|
const saveEditSummary = async () => {
|
|
if (summaryMarkdownEditorInstance.value) {
|
|
selectedRecording.value.summary = summaryMarkdownEditorInstance.value.value();
|
|
summaryMarkdownEditorInstance.value.toTextArea();
|
|
summaryMarkdownEditorInstance.value = null;
|
|
}
|
|
editingSummary.value = false;
|
|
await saveInlineEdit('summary');
|
|
};
|
|
|
|
const initializeSummaryMarkdownEditor = () => {
|
|
if (!summaryMarkdownEditor.value) return;
|
|
|
|
try {
|
|
summaryMarkdownEditorInstance.value = new EasyMDE({
|
|
element: summaryMarkdownEditor.value,
|
|
spellChecker: false,
|
|
autofocus: true,
|
|
placeholder: t('form.enterSummaryMarkdown'),
|
|
initialValue: selectedRecording.value?.summary || '',
|
|
status: false,
|
|
toolbar: [
|
|
"bold", "italic", "heading", "|",
|
|
"quote", "unordered-list", "ordered-list", "|",
|
|
"link", "image", "|",
|
|
"preview", "side-by-side", "fullscreen"
|
|
],
|
|
previewClass: ["editor-preview", "notes-preview"],
|
|
theme: isDarkMode.value ? "dark" : "light"
|
|
});
|
|
|
|
// Add auto-save functionality
|
|
summaryMarkdownEditorInstance.value.codemirror.on('change', () => {
|
|
if (autoSaveTimer.value) {
|
|
clearTimeout(autoSaveTimer.value);
|
|
}
|
|
autoSaveTimer.value = setTimeout(() => {
|
|
autoSaveSummary();
|
|
}, autoSaveDelay);
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to initialize summary markdown editor:', error);
|
|
editingSummary.value = true;
|
|
}
|
|
};
|
|
|
|
const autoSaveSummary = async () => {
|
|
if (summaryMarkdownEditorInstance.value && editingSummary.value) {
|
|
// Just save the content to the model, don't exit edit mode
|
|
selectedRecording.value.summary = summaryMarkdownEditorInstance.value.value();
|
|
// Silently save to backend without changing UI state
|
|
try {
|
|
const payload = {
|
|
id: selectedRecording.value.id,
|
|
title: selectedRecording.value.title,
|
|
participants: selectedRecording.value.participants,
|
|
notes: selectedRecording.value.notes,
|
|
summary: selectedRecording.value.summary,
|
|
meeting_date: selectedRecording.value.meeting_date
|
|
};
|
|
const response = await fetch('/save', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': csrfToken.value
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
const data = await response.json();
|
|
if (response.ok && data.recording) {
|
|
// Update the HTML rendered versions if they exist
|
|
if (data.recording.summary_html) {
|
|
selectedRecording.value.summary_html = data.recording.summary_html;
|
|
}
|
|
} else {
|
|
console.error('Failed to auto-save summary');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error auto-saving summary:', error);
|
|
}
|
|
}
|
|
};
|
|
|
|
const toggleTranscriptionViewMode = () => {
|
|
transcriptionViewMode.value = transcriptionViewMode.value === 'simple' ? 'bubble' : 'simple';
|
|
localStorage.setItem('transcriptionViewMode', transcriptionViewMode.value);
|
|
};
|
|
|
|
const toggleEditNotes = () => {
|
|
editingNotes.value = !editingNotes.value;
|
|
if (editingNotes.value) {
|
|
tempNotesContent.value = selectedRecording.value?.notes || '';
|
|
// Initialize markdown editor when entering edit mode
|
|
nextTick(() => {
|
|
initializeMarkdownEditor();
|
|
});
|
|
}
|
|
};
|
|
|
|
const cancelEditNotes = () => {
|
|
if (markdownEditorInstance.value) {
|
|
markdownEditorInstance.value.toTextArea();
|
|
markdownEditorInstance.value = null;
|
|
}
|
|
editingNotes.value = false;
|
|
// Restore original content
|
|
if (selectedRecording.value) {
|
|
selectedRecording.value.notes = tempNotesContent.value;
|
|
}
|
|
};
|
|
|
|
const saveEditNotes = async () => {
|
|
if (markdownEditorInstance.value) {
|
|
// Get the markdown content from the editor
|
|
selectedRecording.value.notes = markdownEditorInstance.value.value();
|
|
markdownEditorInstance.value.toTextArea();
|
|
markdownEditorInstance.value = null;
|
|
}
|
|
editingNotes.value = false;
|
|
await saveInlineEdit('notes');
|
|
};
|
|
|
|
const initializeMarkdownEditor = () => {
|
|
if (!notesMarkdownEditor.value) return;
|
|
|
|
try {
|
|
markdownEditorInstance.value = new EasyMDE({
|
|
element: notesMarkdownEditor.value,
|
|
spellChecker: false,
|
|
autofocus: true,
|
|
placeholder: t('form.enterNotesMarkdown'),
|
|
initialValue: selectedRecording.value?.notes || '',
|
|
status: false,
|
|
toolbar: [
|
|
"bold", "italic", "heading", "|",
|
|
"quote", "unordered-list", "ordered-list", "|",
|
|
"link", "image", "|",
|
|
"preview", "side-by-side", "fullscreen"
|
|
],
|
|
previewClass: ["editor-preview", "notes-preview"],
|
|
theme: isDarkMode.value ? "dark" : "light"
|
|
});
|
|
|
|
// Add auto-save functionality
|
|
markdownEditorInstance.value.codemirror.on('change', () => {
|
|
if (autoSaveTimer.value) {
|
|
clearTimeout(autoSaveTimer.value);
|
|
}
|
|
autoSaveTimer.value = setTimeout(() => {
|
|
autoSaveNotes();
|
|
}, autoSaveDelay);
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to initialize markdown editor:', error);
|
|
// Fallback to regular textarea editing
|
|
editingNotes.value = true;
|
|
}
|
|
};
|
|
|
|
const autoSaveNotes = async () => {
|
|
if (markdownEditorInstance.value && editingNotes.value) {
|
|
// Just save the content to the model, don't exit edit mode
|
|
selectedRecording.value.notes = markdownEditorInstance.value.value();
|
|
// Silently save to backend without changing UI state
|
|
try {
|
|
const payload = {
|
|
id: selectedRecording.value.id,
|
|
title: selectedRecording.value.title,
|
|
participants: selectedRecording.value.participants,
|
|
notes: selectedRecording.value.notes,
|
|
summary: selectedRecording.value.summary,
|
|
meeting_date: selectedRecording.value.meeting_date
|
|
};
|
|
const response = await fetch('/save', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': csrfToken.value
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
const data = await response.json();
|
|
if (response.ok && data.recording) {
|
|
// Update the HTML rendered versions if they exist
|
|
if (data.recording.notes_html) {
|
|
selectedRecording.value.notes_html = data.recording.notes_html;
|
|
}
|
|
} else {
|
|
console.error('Failed to auto-save notes');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error auto-saving notes:', error);
|
|
}
|
|
}
|
|
};
|
|
|
|
const clickToEditNotes = () => {
|
|
// Allow clicking on empty notes area to start editing
|
|
if (!editingNotes.value && (!selectedRecording.value?.notes || selectedRecording.value.notes.trim() === '')) {
|
|
toggleEditNotes();
|
|
}
|
|
};
|
|
|
|
const clickToEditSummary = () => {
|
|
// Allow clicking on empty summary area to start editing
|
|
if (!editingSummary.value && (!selectedRecording.value?.summary || selectedRecording.value.summary.trim() === '')) {
|
|
toggleEditSummary();
|
|
}
|
|
};
|
|
|
|
const downloadNotes = async () => {
|
|
if (!selectedRecording.value || !selectedRecording.value.notes) {
|
|
showToast(t('messages.noNotesAvailableDownload'), 'fa-exclamation-circle');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/recording/${selectedRecording.value.id}/download/notes`);
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
showToast(error.error || t('messages.notesDownloadFailed'), 'fa-exclamation-circle');
|
|
return;
|
|
}
|
|
|
|
// Create blob and download
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${selectedRecording.value.title || 'notes'}.md`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
document.body.removeChild(a);
|
|
|
|
showToast(t('messages.notesDownloadSuccess'));
|
|
} catch (error) {
|
|
showToast(t('messages.notesDownloadFailed'), 'fa-exclamation-circle');
|
|
}
|
|
};
|
|
|
|
const downloadEventICS = async (event) => {
|
|
if (!event || !event.id) {
|
|
showToast(t('messages.invalidEventData'), 'fa-exclamation-circle');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/event/${event.id}/ics`);
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
showToast(error.error || t('messages.eventDownloadFailed'), 'fa-exclamation-circle');
|
|
return;
|
|
}
|
|
|
|
// Create blob and download
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.style.display = 'none';
|
|
a.href = url;
|
|
a.download = `${event.title || 'event'}.ics`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
document.body.removeChild(a);
|
|
|
|
showToast(t('messages.eventDownloadSuccess', { title: event.title }), 'fa-calendar-check', 3000);
|
|
} catch (error) {
|
|
console.error('Download failed:', error);
|
|
showToast(t('messages.eventDownloadFailed'), 'fa-exclamation-circle');
|
|
}
|
|
};
|
|
|
|
const downloadICS = async () => {
|
|
if (!selectedRecording.value || !selectedRecording.value.events || selectedRecording.value.events.length === 0) {
|
|
showToast(t('messages.noEventsToExport'), 'fa-exclamation-circle');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/recording/${selectedRecording.value.id}/events/ics`);
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
showToast(error.error || t('messages.eventsExportFailed'), 'fa-exclamation-circle');
|
|
return;
|
|
}
|
|
|
|
// Create blob and download
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.style.display = 'none';
|
|
a.href = url;
|
|
a.download = `events-${selectedRecording.value.title || selectedRecording.value.id}.ics`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
document.body.removeChild(a);
|
|
|
|
showToast(t('messages.eventsExportSuccess', { count: selectedRecording.value.events.length }), 'fa-calendar-check');
|
|
} catch (error) {
|
|
console.error('Download all events ICS error:', error);
|
|
showToast(t('messages.eventsExportFailed'), 'fa-exclamation-circle');
|
|
}
|
|
};
|
|
|
|
const deleteEvent = async (event) => {
|
|
if (!event || !event.id) {
|
|
showToast(t('messages.invalidEventData'), 'fa-exclamation-circle');
|
|
return;
|
|
}
|
|
|
|
// Confirm deletion
|
|
if (!confirm(t('events.confirmDelete', { title: event.title }) || `Delete event "${event.title}"?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/event/${event.id}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]')?.content || ''
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
showToast(error.error || t('events.deleteFailed') || 'Failed to delete event', 'fa-exclamation-circle');
|
|
return;
|
|
}
|
|
|
|
// Remove event from local state
|
|
if (selectedRecording.value && selectedRecording.value.events) {
|
|
selectedRecording.value.events = selectedRecording.value.events.filter(e => e.id !== event.id);
|
|
}
|
|
|
|
showToast(t('events.deleted') || 'Event deleted', 'fa-check-circle');
|
|
} catch (error) {
|
|
console.error('Delete event failed:', error);
|
|
showToast(t('events.deleteFailed') || 'Failed to delete event', 'fa-exclamation-circle');
|
|
}
|
|
};
|
|
|
|
const formatEventDateTime = (dateTimeStr) => {
|
|
if (!dateTimeStr) return '';
|
|
try {
|
|
const date = new Date(dateTimeStr);
|
|
const options = {
|
|
weekday: 'short',
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
};
|
|
return date.toLocaleString(undefined, options);
|
|
} catch (e) {
|
|
return dateTimeStr;
|
|
}
|
|
};
|
|
|
|
// --- Column Resizing ---
|
|
const startColumnResize = (event) => {
|
|
isResizing.value = true;
|
|
const startX = event.clientX;
|
|
const startLeftWidth = leftColumnWidth.value;
|
|
|
|
const handleMouseMove = (e) => {
|
|
if (!isResizing.value) return;
|
|
|
|
const container = document.getElementById('mainContentColumns');
|
|
if (!container) return;
|
|
|
|
const containerRect = container.getBoundingClientRect();
|
|
const deltaX = e.clientX - startX;
|
|
const containerWidth = containerRect.width;
|
|
const deltaPercent = (deltaX / containerWidth) * 100;
|
|
|
|
let newLeftWidth = startLeftWidth + deltaPercent;
|
|
newLeftWidth = Math.max(20, Math.min(80, newLeftWidth));
|
|
|
|
leftColumnWidth.value = newLeftWidth;
|
|
rightColumnWidth.value = 100 - newLeftWidth;
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
isResizing.value = false;
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
|
document.removeEventListener('mouseup', handleMouseUp);
|
|
|
|
localStorage.setItem('transcriptColumnWidth', leftColumnWidth.value);
|
|
localStorage.setItem('summaryColumnWidth', rightColumnWidth.value);
|
|
};
|
|
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
document.addEventListener('mouseup', handleMouseUp);
|
|
event.preventDefault();
|
|
};
|
|
|
|
// --- Audio Player ---
|
|
const seekAudio = (time, context = 'main') => {
|
|
let audioPlayer = null;
|
|
if (context === 'modal') {
|
|
audioPlayer = document.querySelector('audio.speaker-modal-transcript') || document.querySelector('video.speaker-modal-transcript');
|
|
} else {
|
|
audioPlayer = document.querySelector('.main-content-area audio') || document.querySelector('.main-content-area video');
|
|
}
|
|
|
|
if (!audioPlayer) {
|
|
audioPlayer = document.querySelector('audio') || document.querySelector('video');
|
|
}
|
|
|
|
if (audioPlayer && isFinite(time)) {
|
|
const wasPlaying = !audioPlayer.paused;
|
|
try {
|
|
audioPlayer.currentTime = time;
|
|
if (wasPlaying) {
|
|
audioPlayer.play().catch(e => console.warn('Play after seek failed:', e));
|
|
}
|
|
} catch (e) {
|
|
console.warn('Seek failed:', e);
|
|
}
|
|
}
|
|
};
|
|
|
|
const seekAudioFromEvent = (event) => {
|
|
const segmentElement = event.target.closest('[data-start-time]');
|
|
if (!segmentElement) return;
|
|
|
|
const time = parseFloat(segmentElement.dataset.startTime);
|
|
if (isNaN(time)) return;
|
|
|
|
const isInSpeakerModal = event.target.closest('.speaker-modal-transcript') !== null;
|
|
const context = isInSpeakerModal ? 'modal' : 'main';
|
|
|
|
seekAudio(time, context);
|
|
};
|
|
|
|
const onPlayerVolumeChange = (event) => {
|
|
const newVolume = event.target.volume;
|
|
playerVolume.value = newVolume;
|
|
localStorage.setItem('playerVolume', newVolume);
|
|
};
|
|
|
|
// --- Custom Audio Player Controls ---
|
|
const getAudioElement = () => {
|
|
// First check fullscreen overlay (teleported to body)
|
|
const fullscreenMedia = document.querySelector('.video-fullscreen-overlay video');
|
|
if (fullscreenMedia) return fullscreenMedia;
|
|
// Then check for audio/video in visible modals (z-50 class) - these take priority
|
|
const modalMedia = document.querySelector('.fixed.z-50 audio') || document.querySelector('.fixed.z-50 video');
|
|
if (modalMedia) {
|
|
return modalMedia;
|
|
}
|
|
// Fall back to main player in right column (desktop) or detail view (mobile)
|
|
return document.querySelector('#rightMainColumn audio') ||
|
|
document.querySelector('#rightMainColumn video') ||
|
|
document.querySelector('.detail-view audio') ||
|
|
document.querySelector('.detail-view video') ||
|
|
document.querySelector('audio') ||
|
|
document.querySelector('video');
|
|
};
|
|
|
|
const toggleAudioPlayback = () => {
|
|
const audio = getAudioElement();
|
|
if (!audio) return;
|
|
|
|
if (audio.paused) {
|
|
audio.play();
|
|
} else {
|
|
audio.pause();
|
|
}
|
|
};
|
|
|
|
const toggleAudioMute = () => {
|
|
const audio = getAudioElement();
|
|
if (!audio) return;
|
|
|
|
audio.muted = !audio.muted;
|
|
audioIsMuted.value = audio.muted;
|
|
};
|
|
|
|
const setAudioVolume = (volume) => {
|
|
const audio = getAudioElement();
|
|
if (!audio) return;
|
|
|
|
audio.volume = Math.max(0, Math.min(1, volume));
|
|
playerVolume.value = audio.volume;
|
|
localStorage.setItem('playerVolume', audio.volume);
|
|
|
|
if (audio.volume === 0) {
|
|
audio.muted = true;
|
|
audioIsMuted.value = true;
|
|
} else if (audio.muted) {
|
|
audio.muted = false;
|
|
audioIsMuted.value = false;
|
|
}
|
|
};
|
|
|
|
const seekAudioTo = (time) => {
|
|
const audio = getAudioElement();
|
|
if (!audio || !isFinite(time)) return;
|
|
|
|
// Use our tracked duration (server-side) as fallback if browser duration is broken
|
|
const maxTime = audioDuration.value || (isFinite(audio.duration) ? audio.duration : time);
|
|
try {
|
|
audio.currentTime = Math.max(0, Math.min(time, maxTime));
|
|
} catch (e) {
|
|
console.warn('Seek failed:', e);
|
|
}
|
|
};
|
|
|
|
const seekAudioByPercent = (percent) => {
|
|
const audio = getAudioElement();
|
|
// Use our tracked duration (server-side) which works for WebM files without duration metadata
|
|
const dur = audioDuration.value || audio?.duration;
|
|
if (!audio || !dur || !isFinite(dur)) return;
|
|
|
|
const time = (percent / 100) * dur;
|
|
try {
|
|
audio.currentTime = time;
|
|
} catch (e) {
|
|
console.warn('Seek by percent failed:', e);
|
|
}
|
|
};
|
|
|
|
// Progress bar drag state
|
|
const isDraggingProgress = ref(false);
|
|
const dragPreviewPercent = ref(0);
|
|
|
|
// Handle progress bar drag - supports both mouse and touch, only seeks on release
|
|
const startProgressDrag = (event) => {
|
|
const bar = event.currentTarget.querySelector('.progress-track') || event.currentTarget.querySelector('.h-2') || event.currentTarget;
|
|
const rect = bar.getBoundingClientRect();
|
|
const isTouch = event.type === 'touchstart';
|
|
|
|
const getPercent = (evt) => {
|
|
const clientX = isTouch ? evt.touches[0].clientX : evt.clientX;
|
|
return Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
|
|
};
|
|
|
|
const getPercentFromEnd = (evt) => {
|
|
const clientX = isTouch ? evt.changedTouches[0].clientX : evt.clientX;
|
|
return Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
|
|
};
|
|
|
|
// Start dragging - show preview
|
|
isDraggingProgress.value = true;
|
|
dragPreviewPercent.value = getPercent(event);
|
|
|
|
const onMove = (evt) => {
|
|
evt.preventDefault();
|
|
const clientX = isTouch ? evt.touches[0].clientX : evt.clientX;
|
|
dragPreviewPercent.value = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
|
|
};
|
|
|
|
const onUp = (evt) => {
|
|
document.removeEventListener(isTouch ? 'touchmove' : 'mousemove', onMove);
|
|
document.removeEventListener(isTouch ? 'touchend' : 'mouseup', onUp);
|
|
// Seek to final position on release
|
|
seekAudioByPercent(dragPreviewPercent.value);
|
|
isDraggingProgress.value = false;
|
|
};
|
|
|
|
document.addEventListener(isTouch ? 'touchmove' : 'mousemove', onMove, { passive: false });
|
|
document.addEventListener(isTouch ? 'touchend' : 'mouseup', onUp);
|
|
};
|
|
|
|
const handleAudioPlayPause = (event) => {
|
|
audioIsPlaying.value = !event.target.paused;
|
|
};
|
|
|
|
const handleAudioLoadedMetadata = (event) => {
|
|
const duration = event.target.duration;
|
|
// Only set browser duration if we don't already have a server-side duration
|
|
// Server-side duration (from ffprobe) is more reliable for formats like WebM
|
|
if (!audioDuration.value || audioDuration.value === 0) {
|
|
if (duration && isFinite(duration) && duration > 0) {
|
|
audioDuration.value = duration;
|
|
}
|
|
}
|
|
audioIsLoading.value = false;
|
|
|
|
// Apply saved playback rate when audio loads
|
|
if (playbackRate.value !== 1) {
|
|
event.target.playbackRate = playbackRate.value;
|
|
}
|
|
};
|
|
|
|
const handleAudioEnded = () => {
|
|
audioIsPlaying.value = false;
|
|
audioCurrentTime.value = 0;
|
|
};
|
|
|
|
const handleCustomAudioTimeUpdate = (event) => {
|
|
audioCurrentTime.value = event.target.currentTime;
|
|
|
|
// Fallback: if duration wasn't set yet, try to get it now
|
|
if (!audioDuration.value || audioDuration.value === 0) {
|
|
const duration = event.target.duration;
|
|
if (duration && isFinite(duration) && duration > 0) {
|
|
audioDuration.value = duration;
|
|
}
|
|
}
|
|
|
|
// Also call the existing handler for segment tracking
|
|
handleAudioTimeUpdate(event);
|
|
};
|
|
|
|
const handleAudioWaiting = () => {
|
|
audioIsLoading.value = true;
|
|
};
|
|
|
|
const handleAudioCanPlay = (event) => {
|
|
audioIsLoading.value = false;
|
|
|
|
// Fallback: try to get duration if not set yet
|
|
if (!audioDuration.value || audioDuration.value === 0) {
|
|
const duration = event.target.duration;
|
|
if (duration && isFinite(duration) && duration > 0) {
|
|
audioDuration.value = duration;
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleAudioDurationChange = (event) => {
|
|
// WebM and some other formats may initially report Infinity duration
|
|
// This handler catches when the actual duration becomes available
|
|
// Only set if we don't already have a server-side duration
|
|
if (!audioDuration.value || audioDuration.value === 0) {
|
|
const duration = event.target.duration;
|
|
if (duration && isFinite(duration) && duration > 0) {
|
|
audioDuration.value = duration;
|
|
}
|
|
}
|
|
};
|
|
|
|
const formatAudioTime = (seconds) => {
|
|
if (!seconds || isNaN(seconds)) return '0:00';
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
// --- Playback Speed Control ---
|
|
const formatPlaybackRate = (rate) => {
|
|
if (rate === 1) return '1x';
|
|
if (rate === Math.floor(rate)) return `${rate}x`;
|
|
return `${rate}x`;
|
|
};
|
|
|
|
const setPlaybackRate = (rate) => {
|
|
playbackRate.value = rate;
|
|
localStorage.setItem('playbackRate', rate);
|
|
|
|
// Apply to all audio and video elements
|
|
document.querySelectorAll('audio, video').forEach(el => {
|
|
el.playbackRate = rate;
|
|
});
|
|
};
|
|
|
|
const cyclePlaybackRate = () => {
|
|
const currentIndex = playbackSpeeds.indexOf(playbackRate.value);
|
|
const nextIndex = (currentIndex + 1) % playbackSpeeds.length;
|
|
setPlaybackRate(playbackSpeeds[nextIndex]);
|
|
};
|
|
|
|
const cycleModalPlaybackRate = () => {
|
|
const currentIndex = playbackSpeeds.indexOf(modalPlaybackRate.value);
|
|
const nextIndex = (currentIndex + 1) % playbackSpeeds.length;
|
|
modalPlaybackRate.value = playbackSpeeds[nextIndex];
|
|
|
|
// Apply to modal audio elements
|
|
const modalAudio = document.querySelector('.speaker-modal-transcript')?.closest('.fixed')?.querySelector('audio') ||
|
|
document.querySelector('.speaker-modal-transcript')?.closest('.fixed')?.querySelector('video') ||
|
|
document.querySelector('[ref="speakerModalAudioRef"]') ||
|
|
document.querySelector('[ref="asrEditorAudioRef"]');
|
|
if (modalAudio) {
|
|
modalAudio.playbackRate = modalPlaybackRate.value;
|
|
}
|
|
};
|
|
|
|
const initializePlaybackRate = () => {
|
|
const savedRate = localStorage.getItem('playbackRate');
|
|
if (savedRate) {
|
|
const rate = parseFloat(savedRate);
|
|
if (playbackSpeeds.includes(rate)) {
|
|
playbackRate.value = rate;
|
|
}
|
|
}
|
|
};
|
|
|
|
const updateSpeedMenuPosition = (buttonEl) => {
|
|
if (!buttonEl) return;
|
|
const rect = buttonEl.getBoundingClientRect();
|
|
const menuWidth = 52;
|
|
const menuMaxHeight = 160;
|
|
const gap = 4;
|
|
const safeZone = 60; // Account for header/navbar
|
|
|
|
// Check available space above and below
|
|
const spaceAbove = rect.top - safeZone;
|
|
const spaceBelow = window.innerHeight - rect.bottom - 8;
|
|
|
|
// Decide direction: prefer above, but use below if not enough space
|
|
const showBelow = spaceAbove < menuMaxHeight && spaceBelow > spaceAbove;
|
|
|
|
const right = window.innerWidth - rect.right;
|
|
|
|
if (showBelow) {
|
|
// Position below the button
|
|
speedMenuPosition.value = {
|
|
right: `${Math.max(8, right)}px`,
|
|
top: `${rect.bottom + gap}px`,
|
|
bottom: 'auto',
|
|
maxHeight: `${Math.min(menuMaxHeight, spaceBelow)}px`
|
|
};
|
|
} else {
|
|
// Position above the button
|
|
speedMenuPosition.value = {
|
|
right: `${Math.max(8, right)}px`,
|
|
bottom: `${window.innerHeight - rect.top + gap}px`,
|
|
top: 'auto',
|
|
maxHeight: `${Math.min(menuMaxHeight, spaceAbove)}px`
|
|
};
|
|
}
|
|
};
|
|
|
|
const audioProgressPercent = computed(() => {
|
|
// Use preview position while dragging for smooth UI
|
|
if (isDraggingProgress.value) {
|
|
return dragPreviewPercent.value;
|
|
}
|
|
if (!audioDuration.value) return 0;
|
|
return (audioCurrentTime.value / audioDuration.value) * 100;
|
|
});
|
|
|
|
// Preview time display while dragging
|
|
const displayCurrentTime = computed(() => {
|
|
if (isDraggingProgress.value && audioDuration.value) {
|
|
return (dragPreviewPercent.value / 100) * audioDuration.value;
|
|
}
|
|
return audioCurrentTime.value;
|
|
});
|
|
|
|
// Reset audio player state (called when recording changes)
|
|
const resetAudioPlayerState = () => {
|
|
audioIsPlaying.value = false;
|
|
audioCurrentTime.value = 0;
|
|
audioDuration.value = 0;
|
|
audioIsMuted.value = false;
|
|
audioIsLoading.value = false;
|
|
};
|
|
|
|
// --- Active Segment Tracking ---
|
|
|
|
// Binary search to find the segment containing the current time
|
|
// Returns the index of the last segment where startTime <= currentTime
|
|
// O(log n) instead of O(n) - critical for long transcriptions (4500+ segments)
|
|
const binarySearchSegment = (segments, currentTime) => {
|
|
if (segments.length === 0) return null;
|
|
|
|
let low = 0;
|
|
let high = segments.length - 1;
|
|
let result = null;
|
|
|
|
while (low <= high) {
|
|
const mid = Math.floor((low + high) / 2);
|
|
const startTime = segments[mid].startTime || segments[mid].start_time;
|
|
|
|
if (startTime === undefined) {
|
|
// Skip segments without timing info
|
|
high = mid - 1;
|
|
continue;
|
|
}
|
|
|
|
if (startTime <= currentTime) {
|
|
result = mid; // This segment is a candidate
|
|
low = mid + 1; // Look for later segments that might also match
|
|
} else {
|
|
high = mid - 1; // Current time is before this segment
|
|
}
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
const handleAudioTimeUpdate = (event) => {
|
|
const transcription = processedTranscription.value;
|
|
|
|
if (!transcription || !transcription.isJson) {
|
|
return;
|
|
}
|
|
|
|
const audioElement = event.target;
|
|
const currentTime = audioElement.currentTime;
|
|
|
|
// Find the segment that contains the current time
|
|
const segments = transcription.simpleSegments || [];
|
|
|
|
if (segments.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Find the active segment index using binary search - O(log n)
|
|
const activeIndex = binarySearchSegment(segments, currentTime);
|
|
|
|
// Only update if changed
|
|
if (activeIndex !== currentPlayingSegmentIndex.value) {
|
|
currentPlayingSegmentIndex.value = activeIndex;
|
|
|
|
// Scroll to active segment if follow mode is enabled
|
|
if (followPlayerMode.value && activeIndex !== null) {
|
|
scrollToActiveSegment(activeIndex);
|
|
}
|
|
}
|
|
};
|
|
|
|
const scrollToActiveSegment = (segmentIndex) => {
|
|
// Find the active segment element
|
|
const segments = document.querySelectorAll('.transcript-segment[data-segment-index], .speaker-segment[data-segment-index], .speaker-bubble[data-segment-index]');
|
|
if (segments[segmentIndex]) {
|
|
segments[segmentIndex].scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'center'
|
|
});
|
|
}
|
|
};
|
|
|
|
const toggleFollowPlayerMode = () => {
|
|
followPlayerMode.value = !followPlayerMode.value;
|
|
localStorage.setItem('followPlayerMode', followPlayerMode.value);
|
|
|
|
if (followPlayerMode.value) {
|
|
showToast(t('messages.followPlayerEnabled'), 'fa-link');
|
|
// Scroll to current position if we have an active segment
|
|
if (currentPlayingSegmentIndex.value !== null) {
|
|
scrollToActiveSegment(currentPlayingSegmentIndex.value);
|
|
}
|
|
} else {
|
|
showToast(t('messages.followPlayerDisabled'), 'fa-unlink');
|
|
}
|
|
};
|
|
|
|
// --- Video Fullscreen ---
|
|
const handleFullscreenKeydown = (e) => {
|
|
if (e.key === 'Escape' || e.key === 'f') {
|
|
exitVideoFullscreen();
|
|
} else if (e.key === ' ' || e.key === 'k') {
|
|
e.preventDefault();
|
|
toggleAudioPlayback();
|
|
} else if (e.key === 'ArrowLeft') {
|
|
e.preventDefault();
|
|
seekAudioTo(audioCurrentTime.value - 10);
|
|
} else if (e.key === 'ArrowRight') {
|
|
e.preventDefault();
|
|
seekAudioTo(audioCurrentTime.value + 10);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
const audio = getAudioElement();
|
|
if (audio) setAudioVolume(Math.min(1, audio.volume + 0.1));
|
|
} else if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
const audio = getAudioElement();
|
|
if (audio) setAudioVolume(Math.max(0, audio.volume - 0.1));
|
|
}
|
|
};
|
|
|
|
const resetFullscreenControlsTimer = () => {
|
|
fullscreenControlsVisible.value = true;
|
|
if (fullscreenControlsTimer.value) {
|
|
clearTimeout(fullscreenControlsTimer.value);
|
|
}
|
|
fullscreenControlsTimer.value = setTimeout(() => {
|
|
if (audioIsPlaying.value) {
|
|
fullscreenControlsVisible.value = false;
|
|
}
|
|
}, 3000);
|
|
};
|
|
|
|
const handleFullscreenMouseMove = () => {
|
|
resetFullscreenControlsTimer();
|
|
};
|
|
|
|
const enterVideoFullscreen = () => {
|
|
videoFullscreen.value = true;
|
|
videoCollapsed.value = false;
|
|
fullscreenControlsVisible.value = true;
|
|
resetFullscreenControlsTimer();
|
|
document.addEventListener('keydown', handleFullscreenKeydown);
|
|
document.body.style.overflow = 'hidden';
|
|
};
|
|
|
|
const exitVideoFullscreen = () => {
|
|
videoFullscreen.value = false;
|
|
fullscreenControlsVisible.value = true;
|
|
if (fullscreenControlsTimer.value) {
|
|
clearTimeout(fullscreenControlsTimer.value);
|
|
fullscreenControlsTimer.value = null;
|
|
}
|
|
document.removeEventListener('keydown', handleFullscreenKeydown);
|
|
document.body.style.overflow = '';
|
|
};
|
|
|
|
// --- Copy Functions ---
|
|
const animateCopyButton = (button) => {
|
|
if (!button) return;
|
|
const icon = button.querySelector('i');
|
|
if (icon) {
|
|
const originalClass = icon.className;
|
|
icon.className = 'fas fa-check';
|
|
setTimeout(() => {
|
|
icon.className = originalClass;
|
|
}, 2000);
|
|
}
|
|
};
|
|
|
|
const fallbackCopyTextToClipboard = (text) => {
|
|
const textArea = document.createElement('textarea');
|
|
textArea.value = text;
|
|
textArea.style.position = 'fixed';
|
|
textArea.style.left = '-999999px';
|
|
document.body.appendChild(textArea);
|
|
textArea.select();
|
|
try {
|
|
document.execCommand('copy');
|
|
showToast(t('messages.copiedSuccessfully'));
|
|
} catch (err) {
|
|
showToast(t('messages.copyFailed'), 'fa-exclamation-circle');
|
|
}
|
|
document.body.removeChild(textArea);
|
|
};
|
|
|
|
const copyTranscription = (event) => {
|
|
if (!selectedRecording.value || !selectedRecording.value.transcription) {
|
|
showToast(t('messages.noTranscriptionToCopy'), 'fa-exclamation-circle');
|
|
return;
|
|
}
|
|
|
|
const button = event?.currentTarget;
|
|
let textToCopy = '';
|
|
|
|
try {
|
|
const transcriptionData = JSON.parse(selectedRecording.value.transcription);
|
|
if (Array.isArray(transcriptionData)) {
|
|
const wasDiarized = transcriptionData.some(segment => segment.speaker);
|
|
if (wasDiarized) {
|
|
textToCopy = transcriptionData.map(segment => {
|
|
return `[${segment.speaker}]: ${segment.text || segment.sentence}`;
|
|
}).join('\n');
|
|
} else {
|
|
textToCopy = transcriptionData.map(segment => segment.text || segment.sentence).join('\n');
|
|
}
|
|
} else {
|
|
textToCopy = selectedRecording.value.transcription;
|
|
}
|
|
} catch (e) {
|
|
textToCopy = selectedRecording.value.transcription;
|
|
}
|
|
|
|
animateCopyButton(button);
|
|
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
navigator.clipboard.writeText(textToCopy)
|
|
.then(() => showToast(t('messages.transcriptionCopied')))
|
|
.catch(() => fallbackCopyTextToClipboard(textToCopy));
|
|
} else {
|
|
fallbackCopyTextToClipboard(textToCopy);
|
|
}
|
|
};
|
|
|
|
const copySummary = (event) => {
|
|
if (!selectedRecording.value || !selectedRecording.value.summary) {
|
|
showToast(t('messages.noSummaryToCopy'), 'fa-exclamation-circle');
|
|
return;
|
|
}
|
|
const button = event?.currentTarget;
|
|
animateCopyButton(button);
|
|
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
navigator.clipboard.writeText(selectedRecording.value.summary)
|
|
.then(() => showToast(t('messages.summaryCopied')))
|
|
.catch(() => fallbackCopyTextToClipboard(selectedRecording.value.summary));
|
|
} else {
|
|
fallbackCopyTextToClipboard(selectedRecording.value.summary);
|
|
}
|
|
};
|
|
|
|
const copyNotes = (event) => {
|
|
if (!selectedRecording.value || !selectedRecording.value.notes) {
|
|
showToast(t('messages.noNotesToCopy'), 'fa-exclamation-circle');
|
|
return;
|
|
}
|
|
const button = event?.currentTarget;
|
|
animateCopyButton(button);
|
|
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
navigator.clipboard.writeText(selectedRecording.value.notes)
|
|
.then(() => showToast(t('messages.notesCopied')))
|
|
.catch(() => fallbackCopyTextToClipboard(selectedRecording.value.notes));
|
|
} else {
|
|
fallbackCopyTextToClipboard(selectedRecording.value.notes);
|
|
}
|
|
};
|
|
|
|
// --- Download Functions ---
|
|
const downloadSummary = async () => {
|
|
if (!selectedRecording.value || !selectedRecording.value.summary) {
|
|
showToast(t('messages.noSummaryToDownload'), 'fa-exclamation-circle');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/recording/${selectedRecording.value.id}/download/summary`);
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
showToast(error.error || t('messages.summaryDownloadFailed'), 'fa-exclamation-circle');
|
|
return;
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.style.display = 'none';
|
|
a.href = url;
|
|
|
|
const contentDisposition = response.headers.get('Content-Disposition');
|
|
let filename = 'summary.docx';
|
|
if (contentDisposition) {
|
|
const utf8Match = /filename\*=utf-8''(.+)/.exec(contentDisposition);
|
|
if (utf8Match) {
|
|
filename = decodeURIComponent(utf8Match[1]);
|
|
} else {
|
|
const regularMatch = /filename="(.+)"/.exec(contentDisposition);
|
|
if (regularMatch) {
|
|
filename = regularMatch[1];
|
|
}
|
|
}
|
|
}
|
|
a.download = filename;
|
|
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
document.body.removeChild(a);
|
|
|
|
showToast(t('messages.summaryDownloadSuccess'));
|
|
} catch (error) {
|
|
showToast(t('messages.summaryDownloadFailed'), 'fa-exclamation-circle');
|
|
}
|
|
};
|
|
|
|
|
|
const downloadTranscriptWord = async () => {
|
|
if (!selectedRecording.value || !selectedRecording.value.transcription) {
|
|
showToast(t('messages.noTranscriptionToDownload'), 'fa-exclamation-circle');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/recording/${selectedRecording.value.id}/download/transcript/word`);
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
showToast(error.error || 'Erreur lors du téléchargement Word', 'fa-exclamation-circle');
|
|
return;
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.style.display = 'none';
|
|
a.href = url;
|
|
|
|
const contentDisposition = response.headers.get('Content-Disposition');
|
|
let filename = 'transcript.docx';
|
|
if (contentDisposition) {
|
|
const utf8Match = /filename\*=utf-8''(.+)/.exec(contentDisposition);
|
|
if (utf8Match) {
|
|
filename = decodeURIComponent(utf8Match[1]);
|
|
} else {
|
|
const regularMatch = /filename="(.+)"/.exec(contentDisposition);
|
|
if (regularMatch) {
|
|
filename = regularMatch[1];
|
|
}
|
|
}
|
|
}
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
document.body.removeChild(a);
|
|
|
|
showToast('Transcription Word téléchargée !');
|
|
} catch (error) {
|
|
showToast('Erreur lors du téléchargement Word', 'fa-exclamation-circle');
|
|
}
|
|
};
|
|
|
|
const downloadTranscript = async () => {
|
|
if (!selectedRecording.value || !selectedRecording.value.transcription) {
|
|
showToast(t('messages.noTranscriptionToDownload'), 'fa-exclamation-circle');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// First, fetch available templates
|
|
const templatesResponse = await fetch('/api/transcript-templates');
|
|
let templates = [];
|
|
if (templatesResponse.ok) {
|
|
templates = await templatesResponse.json();
|
|
}
|
|
|
|
// If there are templates, show a selection dialog
|
|
let templateId = null;
|
|
if (templates.length > 0) {
|
|
// Create a simple modal for template selection
|
|
const modal = document.createElement('div');
|
|
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
|
|
modal.innerHTML = `
|
|
<div class="bg-[var(--bg-secondary)] rounded-lg p-6 max-w-md w-full mx-4">
|
|
<h3 class="text-lg font-semibold mb-4">${t('transcriptTemplates.selectTemplate')}</h3>
|
|
<div class="space-y-2 max-h-60 overflow-y-auto">
|
|
${templates.map(tmpl => `
|
|
<button class="template-option w-full text-left p-3 rounded border border-[var(--border-primary)] hover:bg-[var(--bg-tertiary)] ${tmpl.is_default ? 'ring-2 ring-[var(--ring-focus)]' : ''}" data-template-id="${tmpl.id}">
|
|
<div class="font-medium">${tmpl.name}</div>
|
|
${tmpl.description ? `<div class="text-sm text-[var(--text-muted)]">${tmpl.description}</div>` : ''}
|
|
${tmpl.is_default ? `<div class="text-xs text-[var(--text-accent)] mt-1"><i class="fas fa-star mr-1"></i>${t('transcriptTemplates.default')}</div>` : ''}
|
|
</button>
|
|
`).join('')}
|
|
</div>
|
|
<div class="mt-4 flex gap-2">
|
|
<button class="cancel-btn px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-accent-light)]">${t('transcriptTemplates.cancel')}</button>
|
|
<button class="download-without-template-btn px-4 py-2 bg-[var(--bg-accent)] text-white rounded hover:bg-[var(--bg-accent-hover)]">${t('transcriptTemplates.downloadWithoutTemplate')}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(modal);
|
|
|
|
// Wait for user selection
|
|
await new Promise((resolve) => {
|
|
modal.querySelectorAll('.template-option').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
templateId = btn.dataset.templateId;
|
|
modal.remove();
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
modal.querySelector('.cancel-btn').addEventListener('click', () => {
|
|
templateId = 'cancelled';
|
|
modal.remove();
|
|
resolve();
|
|
});
|
|
|
|
modal.querySelector('.download-without-template-btn').addEventListener('click', () => {
|
|
templateId = 'none';
|
|
modal.remove();
|
|
resolve();
|
|
});
|
|
|
|
modal.addEventListener('click', (e) => {
|
|
if (e.target === modal) {
|
|
templateId = 'cancelled';
|
|
modal.remove();
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
|
|
if (templateId === null || templateId === undefined || templateId === 'cancelled') {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// If templateId is 'none', download raw transcript without any template
|
|
if (templateId === 'none') {
|
|
let rawText = '';
|
|
try {
|
|
const transcriptionData = JSON.parse(selectedRecording.value.transcription);
|
|
if (Array.isArray(transcriptionData)) {
|
|
rawText = transcriptionData.map(segment => {
|
|
const speaker = segment.speaker || 'Unknown';
|
|
const text = segment.sentence || '';
|
|
return `${speaker}: ${text}`;
|
|
}).join('\n');
|
|
} else {
|
|
rawText = selectedRecording.value.transcription;
|
|
}
|
|
} catch (e) {
|
|
rawText = selectedRecording.value.transcription;
|
|
}
|
|
|
|
const blob = new Blob([rawText], { type: 'text/plain;charset=utf-8' });
|
|
const downloadUrl = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = downloadUrl;
|
|
a.download = `${selectedRecording.value.title || 'transcript'}_raw.txt`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(downloadUrl);
|
|
|
|
showToast(t('messages.transcriptDownloadSuccess'));
|
|
return;
|
|
}
|
|
|
|
// Download the transcript with the selected template
|
|
const url = templateId
|
|
? `/recording/${selectedRecording.value.id}/download/transcript?template_id=${templateId}`
|
|
: `/recording/${selectedRecording.value.id}/download/transcript`;
|
|
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
throw new Error(t('messages.transcriptDownloadFailed'));
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const contentDisposition = response.headers.get('content-disposition');
|
|
let filename = 'transcript.txt';
|
|
if (contentDisposition) {
|
|
const matches = contentDisposition.match(/filename="([^"]+)"/);
|
|
if (matches && matches[1]) {
|
|
filename = matches[1];
|
|
}
|
|
}
|
|
|
|
const downloadUrl = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = downloadUrl;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(downloadUrl);
|
|
|
|
showToast(t('messages.transcriptDownloadSuccess'));
|
|
} catch (error) {
|
|
console.error('Error downloading transcript:', error);
|
|
showToast(t('messages.transcriptDownloadFailed'), 'fa-exclamation-circle');
|
|
}
|
|
};
|
|
|
|
// Download with default template (no modal)
|
|
const downloadWithDefaultTemplate = async () => {
|
|
if (!selectedRecording.value || !selectedRecording.value.transcription) {
|
|
showToast(t('messages.noTranscriptionToDownload'), 'fa-exclamation-circle');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Download using the default template (server will use user's default)
|
|
const response = await fetch(`/recording/${selectedRecording.value.id}/download/transcript`);
|
|
if (!response.ok) {
|
|
throw new Error(t('messages.transcriptDownloadFailed'));
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const contentDisposition = response.headers.get('content-disposition');
|
|
let filename = 'transcript.txt';
|
|
if (contentDisposition) {
|
|
const matches = contentDisposition.match(/filename="([^"]+)"/);
|
|
if (matches && matches[1]) {
|
|
filename = matches[1];
|
|
}
|
|
}
|
|
|
|
const downloadUrl = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = downloadUrl;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(downloadUrl);
|
|
|
|
showToast(t('messages.transcriptDownloadSuccess'));
|
|
} catch (error) {
|
|
console.error('Error downloading transcript:', error);
|
|
showToast(t('messages.transcriptDownloadFailed'), 'fa-exclamation-circle');
|
|
}
|
|
};
|
|
|
|
// Show template selector modal (reuses the modal from downloadTranscript)
|
|
const showTemplateSelector = async () => {
|
|
// This calls the full downloadTranscript which shows the modal
|
|
await downloadTranscript();
|
|
};
|
|
|
|
// Initialize UI settings from localStorage
|
|
const initializeUI = () => {
|
|
// Load saved column widths
|
|
const savedLeftWidth = localStorage.getItem('transcriptColumnWidth');
|
|
const savedRightWidth = localStorage.getItem('summaryColumnWidth');
|
|
if (savedLeftWidth && savedRightWidth) {
|
|
leftColumnWidth.value = parseFloat(savedLeftWidth);
|
|
rightColumnWidth.value = parseFloat(savedRightWidth);
|
|
}
|
|
|
|
// Load saved transcription view mode
|
|
const savedViewMode = localStorage.getItem('transcriptionViewMode');
|
|
if (savedViewMode) {
|
|
transcriptionViewMode.value = savedViewMode;
|
|
}
|
|
|
|
// Load saved player volume
|
|
const savedVolume = localStorage.getItem('playerVolume');
|
|
if (savedVolume) {
|
|
playerVolume.value = parseFloat(savedVolume);
|
|
}
|
|
|
|
// Load saved follow player mode
|
|
const savedFollowMode = localStorage.getItem('followPlayerMode');
|
|
if (savedFollowMode !== null) {
|
|
followPlayerMode.value = savedFollowMode === 'true';
|
|
}
|
|
|
|
// Load saved playback rate
|
|
initializePlaybackRate();
|
|
|
|
// Watch for recording changes to reset active segment and audio player state
|
|
watch(selectedRecording, (newRecording) => {
|
|
if (videoFullscreen.value) exitVideoFullscreen();
|
|
currentPlayingSegmentIndex.value = null;
|
|
resetAudioPlayerState();
|
|
// Use server-side duration if available (more reliable than browser metadata)
|
|
if (newRecording && newRecording.audio_duration) {
|
|
audioDuration.value = newRecording.audio_duration;
|
|
}
|
|
});
|
|
|
|
// Set up global click handler to close dropdowns when clicking outside
|
|
setupGlobalClickHandler();
|
|
};
|
|
|
|
/**
|
|
* Set up a global click handler to close all dropdowns when clicking outside
|
|
* This provides elegant UX by closing menus when users click elsewhere
|
|
*/
|
|
const setupGlobalClickHandler = () => {
|
|
document.addEventListener('click', (event) => {
|
|
const target = event.target;
|
|
|
|
// Close user menu if clicking outside of it
|
|
if (isUserMenuOpen.value) {
|
|
const userMenuButton = target.closest('[data-user-menu-toggle]');
|
|
const userMenuDropdown = target.closest('[data-user-menu-dropdown]');
|
|
|
|
if (!userMenuButton && !userMenuDropdown) {
|
|
isUserMenuOpen.value = false;
|
|
}
|
|
}
|
|
|
|
// Close sort options if clicking outside
|
|
if (showSortOptions.value) {
|
|
const sortButton = target.closest('[data-sort-toggle]');
|
|
const sortDropdown = target.closest('[data-sort-dropdown]');
|
|
|
|
if (!sortButton && !sortDropdown) {
|
|
showSortOptions.value = false;
|
|
}
|
|
}
|
|
|
|
// Close download menu if clicking outside
|
|
if (showDownloadMenu.value) {
|
|
const downloadButton = target.closest('[data-download-toggle]');
|
|
const downloadDropdown = target.closest('[data-download-dropdown]');
|
|
|
|
if (!downloadButton && !downloadDropdown) {
|
|
showDownloadMenu.value = false;
|
|
}
|
|
}
|
|
|
|
// Close language menu if clicking outside
|
|
if (state.showLanguageMenu && state.showLanguageMenu.value) {
|
|
const languageButton = target.closest('[data-language-toggle]');
|
|
const languageDropdown = target.closest('[data-language-dropdown]');
|
|
|
|
if (!languageButton && !languageDropdown) {
|
|
state.showLanguageMenu.value = false;
|
|
}
|
|
}
|
|
|
|
// Close speed menu if clicking outside
|
|
if (showSpeedMenu.value) {
|
|
const speedButton = target.closest('[data-speed-toggle]');
|
|
const speedDropdown = target.closest('[data-speed-dropdown]');
|
|
|
|
if (!speedButton && !speedDropdown) {
|
|
showSpeedMenu.value = false;
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
// Initialize recording notes markdown editor
|
|
const initializeRecordingNotesEditor = () => {
|
|
if (!recordingNotesEditor.value) return;
|
|
|
|
// Destroy existing instance if any
|
|
if (recordingMarkdownEditorInstance.value) {
|
|
recordingMarkdownEditorInstance.value.toTextArea();
|
|
recordingMarkdownEditorInstance.value = null;
|
|
}
|
|
|
|
try {
|
|
recordingMarkdownEditorInstance.value = new EasyMDE({
|
|
element: recordingNotesEditor.value,
|
|
spellChecker: false,
|
|
autofocus: false,
|
|
placeholder: t('form.enterNotesMarkdown'),
|
|
initialValue: recordingNotes.value || '',
|
|
status: false,
|
|
toolbar: [
|
|
"bold", "italic", "heading", "|",
|
|
"quote", "unordered-list", "ordered-list", "|",
|
|
"link", "|",
|
|
"preview", "side-by-side", "fullscreen"
|
|
],
|
|
previewClass: ["editor-preview", "notes-preview"],
|
|
theme: isDarkMode.value ? "dark" : "light"
|
|
});
|
|
|
|
// Sync changes back to recordingNotes
|
|
recordingMarkdownEditorInstance.value.codemirror.on('change', () => {
|
|
recordingNotes.value = recordingMarkdownEditorInstance.value.value();
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to initialize recording notes markdown editor:', error);
|
|
}
|
|
};
|
|
|
|
// Destroy recording notes markdown editor
|
|
const destroyRecordingNotesEditor = () => {
|
|
if (recordingMarkdownEditorInstance.value) {
|
|
// Save current value before destroying
|
|
recordingNotes.value = recordingMarkdownEditorInstance.value.value();
|
|
recordingMarkdownEditorInstance.value.toTextArea();
|
|
recordingMarkdownEditorInstance.value = null;
|
|
}
|
|
};
|
|
|
|
// =========================================
|
|
// Participants Modal
|
|
// =========================================
|
|
|
|
const openParticipantsModal = async () => {
|
|
if (!selectedRecording.value) return;
|
|
|
|
// Parse current participants into array
|
|
const participants = selectedRecording.value.participants
|
|
? selectedRecording.value.participants.split(',').map(p => p.trim()).filter(Boolean)
|
|
: [];
|
|
|
|
state.editingParticipantsList.value = participants.map(name => ({ name }));
|
|
|
|
// Fetch speakers from database for autocomplete
|
|
try {
|
|
const response = await fetch('/speakers');
|
|
if (response.ok) {
|
|
const speakers = await response.json();
|
|
state.allParticipants.value = speakers.map(s => s.name).sort();
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch speakers:', e);
|
|
state.allParticipants.value = [];
|
|
}
|
|
|
|
state.editingParticipantSuggestions.value = {};
|
|
state.showEditParticipantsModal.value = true;
|
|
};
|
|
|
|
const closeEditParticipantsModal = () => {
|
|
state.showEditParticipantsModal.value = false;
|
|
state.editingParticipantsList.value = [];
|
|
};
|
|
|
|
const addParticipant = () => {
|
|
state.editingParticipantsList.value.push({ name: '' });
|
|
};
|
|
|
|
const removeParticipant = (index) => {
|
|
state.editingParticipantsList.value.splice(index, 1);
|
|
delete state.editingParticipantSuggestions.value[index];
|
|
};
|
|
|
|
const filterParticipantSuggestions = (index) => {
|
|
// Close all other dropdowns first
|
|
closeAllParticipantSuggestions();
|
|
|
|
const query = state.editingParticipantsList.value[index]?.name?.toLowerCase().trim() || '';
|
|
if (query === '') {
|
|
// Show all participants when field is empty/focused
|
|
state.editingParticipantSuggestions.value[index] = [...state.allParticipants.value];
|
|
} else {
|
|
state.editingParticipantSuggestions.value[index] = state.allParticipants.value.filter(
|
|
p => p.toLowerCase().includes(query)
|
|
);
|
|
}
|
|
};
|
|
|
|
const selectParticipantSuggestion = (index, name) => {
|
|
state.editingParticipantsList.value[index].name = name;
|
|
state.editingParticipantSuggestions.value[index] = [];
|
|
};
|
|
|
|
const closeParticipantSuggestions = (index) => {
|
|
state.editingParticipantSuggestions.value[index] = [];
|
|
};
|
|
|
|
const closeParticipantSuggestionsDelayed = (index) => {
|
|
setTimeout(() => closeParticipantSuggestions(index), 200);
|
|
};
|
|
|
|
const closeAllParticipantSuggestions = () => {
|
|
state.editingParticipantSuggestions.value = {};
|
|
};
|
|
|
|
const getParticipantDropdownPosition = (index) => {
|
|
// Find the input element for this index and calculate position
|
|
const inputs = document.querySelectorAll('.max-w-md input[placeholder="' + t('form.participantNamePlaceholder') + '"]');
|
|
if (inputs[index]) {
|
|
const rect = inputs[index].getBoundingClientRect();
|
|
return {
|
|
top: rect.bottom + 2 + 'px',
|
|
left: rect.left + 'px',
|
|
width: rect.width + 'px'
|
|
};
|
|
}
|
|
return { top: '0px', left: '0px', width: '200px' };
|
|
};
|
|
|
|
const saveParticipants = async () => {
|
|
if (!selectedRecording.value) return;
|
|
|
|
// Join participant names with comma
|
|
const participantsString = state.editingParticipantsList.value
|
|
.map(p => p.name.trim())
|
|
.filter(Boolean)
|
|
.join(', ');
|
|
|
|
// Update the recording
|
|
selectedRecording.value.participants = participantsString;
|
|
|
|
// Use the same save endpoint as inline editing
|
|
const fullPayload = {
|
|
id: selectedRecording.value.id,
|
|
title: selectedRecording.value.title,
|
|
participants: selectedRecording.value.participants,
|
|
notes: selectedRecording.value.notes,
|
|
summary: selectedRecording.value.summary,
|
|
meeting_date: selectedRecording.value.meeting_date
|
|
};
|
|
|
|
try {
|
|
const response = await fetch('/save', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': csrfToken.value
|
|
},
|
|
body: JSON.stringify(fullPayload)
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (!response.ok) throw new Error(data.error || t('messages.failedToSaveParticipants'));
|
|
|
|
showToast(t('common.changesSaved'), 'fa-check-circle');
|
|
closeEditParticipantsModal();
|
|
} catch (error) {
|
|
console.error('Save error:', error);
|
|
utils.setGlobalError(t('messages.saveParticipantsFailed', { error: error.message }));
|
|
}
|
|
};
|
|
|
|
return {
|
|
// Initialization
|
|
initializeUI,
|
|
toggleDarkMode,
|
|
initializeDarkMode,
|
|
applyColorScheme,
|
|
initializeColorScheme,
|
|
openColorSchemeModal,
|
|
closeColorSchemeModal,
|
|
selectColorScheme,
|
|
resetColorScheme,
|
|
toggleSidebar,
|
|
initializeSidebar,
|
|
switchToUploadView,
|
|
switchToDetailView,
|
|
switchToRecordingView,
|
|
setGlobalError,
|
|
formatFileSize,
|
|
formatDisplayDate,
|
|
formatShortDate,
|
|
formatStatus,
|
|
getStatusClass,
|
|
formatTime,
|
|
formatDuration,
|
|
formatProcessingDuration,
|
|
// Inline editing
|
|
toggleEditTitle,
|
|
saveTitle,
|
|
cancelEditTitle,
|
|
toggleEditParticipants,
|
|
toggleEditMeetingDate,
|
|
toggleEditSummary,
|
|
cancelEditSummary,
|
|
saveEditSummary,
|
|
initializeSummaryMarkdownEditor,
|
|
autoSaveSummary,
|
|
toggleEditNotes,
|
|
cancelEditNotes,
|
|
saveEditNotes,
|
|
initializeMarkdownEditor,
|
|
autoSaveNotes,
|
|
clickToEditNotes,
|
|
clickToEditSummary,
|
|
// Recording notes editor
|
|
initializeRecordingNotesEditor,
|
|
destroyRecordingNotesEditor,
|
|
downloadNotes,
|
|
downloadEventICS,
|
|
downloadICS,
|
|
deleteEvent,
|
|
formatEventDateTime,
|
|
// View mode
|
|
toggleTranscriptionViewMode,
|
|
// Column resizing
|
|
startColumnResize,
|
|
// Audio player
|
|
seekAudio,
|
|
seekAudioFromEvent,
|
|
onPlayerVolumeChange,
|
|
handleAudioTimeUpdate,
|
|
toggleFollowPlayerMode,
|
|
scrollToActiveSegment,
|
|
// Custom audio player
|
|
toggleAudioPlayback,
|
|
toggleAudioMute,
|
|
setAudioVolume,
|
|
seekAudioTo,
|
|
seekAudioByPercent,
|
|
startProgressDrag,
|
|
handleAudioPlayPause,
|
|
handleAudioLoadedMetadata,
|
|
handleAudioEnded,
|
|
handleCustomAudioTimeUpdate,
|
|
handleAudioWaiting,
|
|
handleAudioCanPlay,
|
|
handleAudioDurationChange,
|
|
formatAudioTime,
|
|
audioProgressPercent,
|
|
displayCurrentTime,
|
|
isDraggingProgress,
|
|
audioIsPlaying,
|
|
audioCurrentTime,
|
|
audioDuration,
|
|
audioIsMuted,
|
|
audioIsLoading,
|
|
resetAudioPlayerState,
|
|
// Playback speed
|
|
formatPlaybackRate,
|
|
setPlaybackRate,
|
|
cyclePlaybackRate,
|
|
cycleModalPlaybackRate,
|
|
updateSpeedMenuPosition,
|
|
// Video fullscreen
|
|
enterVideoFullscreen,
|
|
exitVideoFullscreen,
|
|
handleFullscreenMouseMove,
|
|
resetFullscreenControlsTimer,
|
|
// Copy functions
|
|
copyTranscription,
|
|
copySummary,
|
|
copyNotes,
|
|
// Download functions
|
|
downloadSummary,
|
|
downloadTranscript,
|
|
downloadTranscriptWord,
|
|
downloadWithDefaultTemplate,
|
|
showTemplateSelector,
|
|
// Participants modal
|
|
openParticipantsModal,
|
|
closeEditParticipantsModal,
|
|
addParticipant,
|
|
removeParticipant,
|
|
filterParticipantSuggestions,
|
|
selectParticipantSuggestion,
|
|
closeParticipantSuggestions,
|
|
closeParticipantSuggestionsDelayed,
|
|
closeAllParticipantSuggestions,
|
|
getParticipantDropdownPosition,
|
|
saveParticipants,
|
|
// Computed
|
|
isMobile
|
|
};
|
|
}
|