Initial release: DictIA v0.8.14-alpha (fork de Speakr, AGPL-3.0)
This commit is contained in:
2603
static/js/app.modular.js
Normal file
2603
static/js/app.modular.js
Normal file
File diff suppressed because it is too large
Load Diff
17
static/js/composables/index.js
Normal file
17
static/js/composables/index.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Composables index - export all composables for easy importing
|
||||
*
|
||||
* Usage (with ES modules/build system):
|
||||
* import { useRecordings, useUpload, useUI } from './composables';
|
||||
*/
|
||||
|
||||
export { usePagination } from './usePagination.js';
|
||||
export { useUI } from './useUI.js';
|
||||
export { useFilters } from './useFilters.js';
|
||||
export { usePlayer } from './usePlayer.js';
|
||||
export { useSharing } from './useSharing.js';
|
||||
export { useTranscript } from './useTranscript.js';
|
||||
export { useChat } from './useChat.js';
|
||||
export { useAudioRecorder } from './useAudioRecorder.js';
|
||||
export { useUpload } from './useUpload.js';
|
||||
export { useRecordings } from './useRecordings.js';
|
||||
217
static/js/composables/useAudioRecorder.js
Normal file
217
static/js/composables/useAudioRecorder.js
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Audio Recorder composable
|
||||
* Handles audio recording from microphone and/or system audio
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
export function useAudioRecorder() {
|
||||
// State
|
||||
const isRecording = ref(false);
|
||||
const isPaused = ref(false);
|
||||
const audioChunks = ref([]);
|
||||
const audioBlobURL = ref(null);
|
||||
const recordingMode = ref('microphone');
|
||||
const mediaRecorder = ref(null);
|
||||
const audioContext = ref(null);
|
||||
const activeStreams = ref([]);
|
||||
const recordingDuration = ref(0);
|
||||
const recordingSize = ref(0);
|
||||
const actualBitrate = ref(128000);
|
||||
const recordingTimer = ref(null);
|
||||
const recordingNotes = ref('');
|
||||
const showRecordingDisclaimerModal = ref(false);
|
||||
const pendingRecordingMode = ref(null);
|
||||
const recordingDisclaimer = ref('');
|
||||
|
||||
// Computed
|
||||
const canRecordAudio = computed(() => navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
|
||||
const canRecordSystemAudio = computed(() => navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia);
|
||||
|
||||
const recordingTimeFormatted = computed(() => {
|
||||
const hours = Math.floor(recordingDuration.value / 3600);
|
||||
const mins = Math.floor((recordingDuration.value % 3600) / 60);
|
||||
const secs = recordingDuration.value % 60;
|
||||
if (hours > 0) {
|
||||
return hours + ':' + String(mins).padStart(2, '0') + ':' + String(secs).padStart(2, '0');
|
||||
}
|
||||
return mins + ':' + String(secs).padStart(2, '0');
|
||||
});
|
||||
|
||||
// Methods
|
||||
const startRecording = async (mode = 'microphone') => {
|
||||
if (recordingDisclaimer.value && recordingDisclaimer.value.trim()) {
|
||||
pendingRecordingMode.value = mode;
|
||||
showRecordingDisclaimerModal.value = true;
|
||||
return;
|
||||
}
|
||||
await startRecordingActual(mode);
|
||||
};
|
||||
|
||||
const acceptDisclaimer = async () => {
|
||||
showRecordingDisclaimerModal.value = false;
|
||||
if (pendingRecordingMode.value) {
|
||||
await startRecordingActual(pendingRecordingMode.value);
|
||||
pendingRecordingMode.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelDisclaimer = () => {
|
||||
showRecordingDisclaimerModal.value = false;
|
||||
pendingRecordingMode.value = null;
|
||||
};
|
||||
|
||||
const startRecordingActual = async (mode = 'microphone') => {
|
||||
recordingMode.value = mode;
|
||||
audioChunks.value = [];
|
||||
audioBlobURL.value = null;
|
||||
recordingNotes.value = '';
|
||||
activeStreams.value = [];
|
||||
recordingDuration.value = 0;
|
||||
recordingSize.value = 0;
|
||||
|
||||
try {
|
||||
let combinedStream = null;
|
||||
let micStream = null;
|
||||
let systemStream = null;
|
||||
|
||||
if (mode === 'microphone' || mode === 'both') {
|
||||
if (!canRecordAudio.value) throw new Error('Microphone not supported');
|
||||
micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
activeStreams.value.push(micStream);
|
||||
}
|
||||
|
||||
if (mode === 'system' || mode === 'both') {
|
||||
if (!canRecordSystemAudio.value) throw new Error('System audio not supported');
|
||||
try {
|
||||
systemStream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true });
|
||||
if (systemStream.getAudioTracks().length === 0) {
|
||||
systemStream.getVideoTracks().forEach(track => track.stop());
|
||||
throw new Error('System audio permission not granted');
|
||||
}
|
||||
activeStreams.value.push(systemStream);
|
||||
} catch (err) {
|
||||
if (mode === 'system') throw err;
|
||||
systemStream = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Combine streams
|
||||
if (micStream && systemStream) {
|
||||
audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const micSource = audioContext.value.createMediaStreamSource(micStream);
|
||||
const systemSource = audioContext.value.createMediaStreamSource(systemStream);
|
||||
const destination = audioContext.value.createMediaStreamDestination();
|
||||
micSource.connect(destination);
|
||||
systemSource.connect(destination);
|
||||
combinedStream = new MediaStream([destination.stream.getAudioTracks()[0]]);
|
||||
} else if (systemStream) {
|
||||
combinedStream = new MediaStream(systemStream.getAudioTracks());
|
||||
} else if (micStream) {
|
||||
combinedStream = micStream;
|
||||
}
|
||||
|
||||
if (!combinedStream) throw new Error('No audio streams available');
|
||||
|
||||
// Create MediaRecorder
|
||||
const options = { mimeType: 'audio/webm;codecs=opus', audioBitsPerSecond: 32000 };
|
||||
if (MediaRecorder.isTypeSupported(options.mimeType)) {
|
||||
mediaRecorder.value = new MediaRecorder(combinedStream, options);
|
||||
actualBitrate.value = 32000;
|
||||
} else {
|
||||
mediaRecorder.value = new MediaRecorder(combinedStream);
|
||||
actualBitrate.value = 128000;
|
||||
}
|
||||
|
||||
mediaRecorder.value.ondataavailable = (event) => {
|
||||
if (event.data && event.data.size > 0) {
|
||||
audioChunks.value.push(event.data);
|
||||
recordingSize.value += event.data.size;
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.value.onstop = () => {
|
||||
const audioBlob = new Blob(audioChunks.value, { type: mediaRecorder.value.mimeType });
|
||||
audioBlobURL.value = URL.createObjectURL(audioBlob);
|
||||
};
|
||||
|
||||
mediaRecorder.value.start(1000);
|
||||
isRecording.value = true;
|
||||
|
||||
recordingTimer.value = setInterval(() => {
|
||||
recordingDuration.value++;
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
stopAllStreams();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorder.value && isRecording.value) {
|
||||
mediaRecorder.value.stop();
|
||||
isRecording.value = false;
|
||||
isPaused.value = false;
|
||||
|
||||
if (recordingTimer.value) {
|
||||
clearInterval(recordingTimer.value);
|
||||
recordingTimer.value = null;
|
||||
}
|
||||
stopAllStreams();
|
||||
}
|
||||
};
|
||||
|
||||
const pauseRecording = () => {
|
||||
if (mediaRecorder.value && isRecording.value && !isPaused.value) {
|
||||
mediaRecorder.value.pause();
|
||||
isPaused.value = true;
|
||||
if (recordingTimer.value) {
|
||||
clearInterval(recordingTimer.value);
|
||||
recordingTimer.value = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resumeRecording = () => {
|
||||
if (mediaRecorder.value && isRecording.value && isPaused.value) {
|
||||
mediaRecorder.value.resume();
|
||||
isPaused.value = false;
|
||||
recordingTimer.value = setInterval(() => {
|
||||
recordingDuration.value++;
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
const stopAllStreams = () => {
|
||||
activeStreams.value.forEach(stream => {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
});
|
||||
activeStreams.value = [];
|
||||
|
||||
if (audioContext.value) {
|
||||
audioContext.value.close().catch(e => console.error("Error closing AudioContext:", e));
|
||||
audioContext.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const resetRecording = () => {
|
||||
stopRecording();
|
||||
audioChunks.value = [];
|
||||
audioBlobURL.value = null;
|
||||
recordingDuration.value = 0;
|
||||
recordingSize.value = 0;
|
||||
recordingNotes.value = '';
|
||||
};
|
||||
|
||||
const getRecordingBlob = () => {
|
||||
if (audioChunks.value.length === 0) return null;
|
||||
return new Blob(audioChunks.value, { type: 'audio/webm' });
|
||||
};
|
||||
|
||||
return {
|
||||
isRecording, isPaused, audioBlobURL, recordingMode, recordingDuration, recordingSize, recordingNotes,
|
||||
showRecordingDisclaimerModal, recordingDisclaimer, canRecordAudio, canRecordSystemAudio, recordingTimeFormatted,
|
||||
startRecording, stopRecording, pauseRecording, resumeRecording, resetRecording, acceptDisclaimer, cancelDisclaimer, getRecordingBlob
|
||||
};
|
||||
}
|
||||
234
static/js/composables/useChat.js
Normal file
234
static/js/composables/useChat.js
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Chat composable
|
||||
* Handles chat/inquire functionality with streaming responses
|
||||
*/
|
||||
|
||||
import { ref, reactive, nextTick } from 'vue';
|
||||
|
||||
export function useChat() {
|
||||
// State
|
||||
const chatMessages = ref([]);
|
||||
const chatInput = ref('');
|
||||
const isChatLoading = ref(false);
|
||||
const chatMessagesRef = ref(null);
|
||||
const isChatExpanded = ref(false);
|
||||
|
||||
// Methods
|
||||
const isChatScrolledToBottom = () => {
|
||||
if (!chatMessagesRef.value) return true;
|
||||
const { scrollTop, scrollHeight, clientHeight } = chatMessagesRef.value;
|
||||
const scrollableHeight = scrollHeight - clientHeight;
|
||||
if (scrollableHeight <= 0) return true;
|
||||
const scrollPercentage = scrollTop / scrollableHeight;
|
||||
return scrollPercentage >= 0.95; // Within bottom 5%
|
||||
};
|
||||
|
||||
const scrollChatToBottom = () => {
|
||||
if (chatMessagesRef.value) {
|
||||
requestAnimationFrame(() => {
|
||||
if (chatMessagesRef.value) {
|
||||
chatMessagesRef.value.scrollTop = chatMessagesRef.value.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = async (recordingId) => {
|
||||
if (!chatInput.value.trim() || isChatLoading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = chatInput.value.trim();
|
||||
|
||||
if (!Array.isArray(chatMessages.value)) {
|
||||
chatMessages.value = [];
|
||||
}
|
||||
|
||||
chatMessages.value.push({ role: 'user', content: message });
|
||||
chatInput.value = '';
|
||||
isChatLoading.value = true;
|
||||
|
||||
await nextTick();
|
||||
scrollChatToBottom();
|
||||
|
||||
let assistantMessage = null;
|
||||
|
||||
try {
|
||||
const messageHistory = chatMessages.value
|
||||
.slice(0, -1)
|
||||
.map(msg => ({ role: msg.role, content: msg.content }));
|
||||
|
||||
const response = await fetch('/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recording_id: recordingId,
|
||||
message: message,
|
||||
message_history: messageHistory
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to get chat response');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
const processStream = async () => {
|
||||
let isFirstChunk = true;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop();
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const jsonStr = line.substring(6);
|
||||
if (jsonStr) {
|
||||
try {
|
||||
const data = JSON.parse(jsonStr);
|
||||
|
||||
if (data.thinking) {
|
||||
const shouldScroll = isChatScrolledToBottom();
|
||||
|
||||
if (isFirstChunk) {
|
||||
isChatLoading.value = false;
|
||||
assistantMessage = reactive({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
html: '',
|
||||
thinking: data.thinking,
|
||||
thinkingExpanded: false
|
||||
});
|
||||
chatMessages.value.push(assistantMessage);
|
||||
isFirstChunk = false;
|
||||
} else if (assistantMessage) {
|
||||
if (assistantMessage.thinking) {
|
||||
assistantMessage.thinking += '\n\n' + data.thinking;
|
||||
} else {
|
||||
assistantMessage.thinking = data.thinking;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldScroll) {
|
||||
await nextTick();
|
||||
scrollChatToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
if (data.delta) {
|
||||
const shouldScroll = isChatScrolledToBottom();
|
||||
|
||||
if (isFirstChunk) {
|
||||
isChatLoading.value = false;
|
||||
assistantMessage = reactive({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
html: '',
|
||||
thinking: '',
|
||||
thinkingExpanded: false
|
||||
});
|
||||
chatMessages.value.push(assistantMessage);
|
||||
isFirstChunk = false;
|
||||
}
|
||||
|
||||
assistantMessage.content += data.delta;
|
||||
if (window.marked) {
|
||||
assistantMessage.html = window.marked.parse(assistantMessage.content);
|
||||
} else {
|
||||
assistantMessage.html = assistantMessage.content;
|
||||
}
|
||||
|
||||
if (shouldScroll) {
|
||||
await nextTick();
|
||||
scrollChatToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
if (data.end_of_stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing stream data:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await processStream();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Chat Error:', error);
|
||||
if (assistantMessage) {
|
||||
assistantMessage.content = `Error: ${error.message}`;
|
||||
assistantMessage.html = `<span class="text-red-500">Error: ${error.message}</span>`;
|
||||
} else {
|
||||
chatMessages.value.push({
|
||||
role: 'assistant',
|
||||
content: `Error: ${error.message}`,
|
||||
html: `<span class="text-red-500">Error: ${error.message}</span>`
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
isChatLoading.value = false;
|
||||
await nextTick();
|
||||
if (isChatScrolledToBottom()) {
|
||||
scrollChatToBottom();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearChat = () => {
|
||||
chatMessages.value = [];
|
||||
chatInput.value = '';
|
||||
isChatLoading.value = false;
|
||||
};
|
||||
|
||||
const toggleThinking = (message) => {
|
||||
if (message.thinking) {
|
||||
message.thinkingExpanded = !message.thinkingExpanded;
|
||||
}
|
||||
};
|
||||
|
||||
const setChatRef = (el) => {
|
||||
chatMessagesRef.value = el;
|
||||
};
|
||||
|
||||
const handleChatInput = (event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
// Trigger send message (caller should provide recordingId)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
chatMessages,
|
||||
chatInput,
|
||||
isChatLoading,
|
||||
chatMessagesRef,
|
||||
isChatExpanded,
|
||||
|
||||
// Methods
|
||||
sendMessage,
|
||||
clearChat,
|
||||
toggleThinking,
|
||||
setChatRef,
|
||||
scrollChatToBottom,
|
||||
handleChatInput
|
||||
};
|
||||
}
|
||||
128
static/js/composables/useFilters.js
Normal file
128
static/js/composables/useFilters.js
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Filters composable
|
||||
* Handles search, filtering, and sorting functionality
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { parseDateRange } from '../utils/dateUtils.js';
|
||||
|
||||
export function useFilters() {
|
||||
// State
|
||||
const searchQuery = ref('');
|
||||
const showAdvancedFilters = ref(false);
|
||||
const filterTags = ref([]);
|
||||
const filterFolder = ref(''); // '' = all, 'none' = no folder, or folder id
|
||||
const filterDateRange = ref({ start: '', end: '' });
|
||||
const filterDatePreset = ref('');
|
||||
const filterTextQuery = ref('');
|
||||
const showArchivedRecordings = ref(false);
|
||||
const showSharedWithMe = ref(false);
|
||||
const sortBy = ref('created_at');
|
||||
const selectedTagFilter = ref(null);
|
||||
const searchDebounceTimer = ref(null);
|
||||
|
||||
// Methods
|
||||
const toggleAdvancedFilters = () => {
|
||||
showAdvancedFilters.value = !showAdvancedFilters.value;
|
||||
};
|
||||
|
||||
const setDatePreset = (preset) => {
|
||||
filterDatePreset.value = preset;
|
||||
const range = parseDateRange(preset);
|
||||
filterDateRange.value = {
|
||||
start: range.start ? range.start.toISOString().split('T')[0] : '',
|
||||
end: range.end ? range.end.toISOString().split('T')[0] : ''
|
||||
};
|
||||
};
|
||||
|
||||
const clearDateFilter = () => {
|
||||
filterDatePreset.value = '';
|
||||
filterDateRange.value = { start: '', end: '' };
|
||||
};
|
||||
|
||||
const toggleTagFilter = (tagId) => {
|
||||
const index = filterTags.value.indexOf(tagId);
|
||||
if (index > -1) {
|
||||
filterTags.value.splice(index, 1);
|
||||
} else {
|
||||
filterTags.value.push(tagId);
|
||||
}
|
||||
};
|
||||
|
||||
const clearTagFilters = () => {
|
||||
filterTags.value = [];
|
||||
selectedTagFilter.value = null;
|
||||
};
|
||||
|
||||
const clearFolderFilter = () => {
|
||||
filterFolder.value = '';
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
filterTags.value = [];
|
||||
filterFolder.value = '';
|
||||
filterDateRange.value = { start: '', end: '' };
|
||||
filterDatePreset.value = '';
|
||||
filterTextQuery.value = '';
|
||||
selectedTagFilter.value = null;
|
||||
searchQuery.value = '';
|
||||
};
|
||||
|
||||
const toggleArchivedView = () => {
|
||||
showArchivedRecordings.value = !showArchivedRecordings.value;
|
||||
if (showArchivedRecordings.value) {
|
||||
showSharedWithMe.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSharedView = () => {
|
||||
showSharedWithMe.value = !showSharedWithMe.value;
|
||||
if (showSharedWithMe.value) {
|
||||
showArchivedRecordings.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setSortBy = (field) => {
|
||||
sortBy.value = field;
|
||||
};
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return filterTags.value.length > 0 ||
|
||||
filterFolder.value ||
|
||||
filterDateRange.value.start ||
|
||||
filterDateRange.value.end ||
|
||||
filterTextQuery.value ||
|
||||
searchQuery.value;
|
||||
});
|
||||
|
||||
return {
|
||||
// State
|
||||
searchQuery,
|
||||
showAdvancedFilters,
|
||||
filterTags,
|
||||
filterFolder,
|
||||
filterDateRange,
|
||||
filterDatePreset,
|
||||
filterTextQuery,
|
||||
showArchivedRecordings,
|
||||
showSharedWithMe,
|
||||
sortBy,
|
||||
selectedTagFilter,
|
||||
searchDebounceTimer,
|
||||
|
||||
// Computed
|
||||
hasActiveFilters,
|
||||
|
||||
// Methods
|
||||
toggleAdvancedFilters,
|
||||
setDatePreset,
|
||||
clearDateFilter,
|
||||
toggleTagFilter,
|
||||
clearTagFilters,
|
||||
clearFolderFilter,
|
||||
clearAllFilters,
|
||||
toggleArchivedView,
|
||||
toggleSharedView,
|
||||
setSortBy
|
||||
};
|
||||
}
|
||||
95
static/js/composables/usePagination.js
Normal file
95
static/js/composables/usePagination.js
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Pagination composable
|
||||
* Handles pagination state and navigation
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
export function usePagination() {
|
||||
// State
|
||||
const currentPage = ref(1);
|
||||
const perPage = ref(25);
|
||||
const totalRecordings = ref(0);
|
||||
const totalPages = ref(0);
|
||||
const hasNextPage = ref(false);
|
||||
const hasPrevPage = ref(false);
|
||||
const isLoadingMore = ref(false);
|
||||
|
||||
// Computed
|
||||
const paginationInfo = computed(() => {
|
||||
const start = (currentPage.value - 1) * perPage.value + 1;
|
||||
const end = Math.min(currentPage.value * perPage.value, totalRecordings.value);
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
total: totalRecordings.value,
|
||||
currentPage: currentPage.value,
|
||||
totalPages: totalPages.value
|
||||
};
|
||||
});
|
||||
|
||||
// Methods
|
||||
const updatePagination = (pagination) => {
|
||||
if (!pagination) {
|
||||
// Reset pagination for non-paginated views
|
||||
currentPage.value = 1;
|
||||
totalPages.value = 1;
|
||||
hasNextPage.value = false;
|
||||
hasPrevPage.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
currentPage.value = pagination.page;
|
||||
totalRecordings.value = pagination.total;
|
||||
totalPages.value = pagination.total_pages;
|
||||
hasNextPage.value = pagination.has_next;
|
||||
hasPrevPage.value = pagination.has_prev;
|
||||
};
|
||||
|
||||
const goToPage = (page) => {
|
||||
if (page < 1 || page > totalPages.value) return;
|
||||
currentPage.value = page;
|
||||
};
|
||||
|
||||
const nextPage = () => {
|
||||
if (hasNextPage.value) {
|
||||
currentPage.value++;
|
||||
}
|
||||
};
|
||||
|
||||
const prevPage = () => {
|
||||
if (hasPrevPage.value) {
|
||||
currentPage.value--;
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
currentPage.value = 1;
|
||||
totalRecordings.value = 0;
|
||||
totalPages.value = 0;
|
||||
hasNextPage.value = false;
|
||||
hasPrevPage.value = false;
|
||||
isLoadingMore.value = false;
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
currentPage,
|
||||
perPage,
|
||||
totalRecordings,
|
||||
totalPages,
|
||||
hasNextPage,
|
||||
hasPrevPage,
|
||||
isLoadingMore,
|
||||
|
||||
// Computed
|
||||
paginationInfo,
|
||||
|
||||
// Methods
|
||||
updatePagination,
|
||||
goToPage,
|
||||
nextPage,
|
||||
prevPage,
|
||||
reset
|
||||
};
|
||||
}
|
||||
147
static/js/composables/usePlayer.js
Normal file
147
static/js/composables/usePlayer.js
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Audio Player composable
|
||||
* Handles audio playback functionality
|
||||
*/
|
||||
|
||||
import { ref, computed, watch } from 'vue';
|
||||
|
||||
export function usePlayer() {
|
||||
// State
|
||||
const isPlaying = ref(false);
|
||||
const currentTime = ref(0);
|
||||
const duration = ref(0);
|
||||
const playbackRate = ref(1.0);
|
||||
const audioElement = ref(null);
|
||||
|
||||
// Computed
|
||||
const progress = computed(() => {
|
||||
if (!duration.value) return 0;
|
||||
return (currentTime.value / duration.value) * 100;
|
||||
});
|
||||
|
||||
const formattedCurrentTime = computed(() => {
|
||||
return formatTime(currentTime.value);
|
||||
});
|
||||
|
||||
const formattedDuration = computed(() => {
|
||||
return formatTime(duration.value);
|
||||
});
|
||||
|
||||
// Methods
|
||||
const formatTime = (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')}`;
|
||||
};
|
||||
|
||||
const initPlayer = (audio) => {
|
||||
audioElement.value = audio;
|
||||
|
||||
if (!audio) return;
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
duration.value = audio.duration;
|
||||
});
|
||||
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
currentTime.value = audio.currentTime;
|
||||
});
|
||||
|
||||
audio.addEventListener('play', () => {
|
||||
isPlaying.value = true;
|
||||
});
|
||||
|
||||
audio.addEventListener('pause', () => {
|
||||
isPlaying.value = false;
|
||||
});
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
isPlaying.value = false;
|
||||
currentTime.value = 0;
|
||||
});
|
||||
};
|
||||
|
||||
const play = () => {
|
||||
if (audioElement.value) {
|
||||
audioElement.value.play();
|
||||
}
|
||||
};
|
||||
|
||||
const pause = () => {
|
||||
if (audioElement.value) {
|
||||
audioElement.value.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlayPause = () => {
|
||||
if (isPlaying.value) {
|
||||
pause();
|
||||
} else {
|
||||
play();
|
||||
}
|
||||
};
|
||||
|
||||
const seek = (time) => {
|
||||
if (audioElement.value) {
|
||||
audioElement.value.currentTime = time;
|
||||
currentTime.value = time;
|
||||
}
|
||||
};
|
||||
|
||||
const seekPercent = (percent) => {
|
||||
if (audioElement.value && duration.value) {
|
||||
const time = (percent / 100) * duration.value;
|
||||
seek(time);
|
||||
}
|
||||
};
|
||||
|
||||
const skip = (seconds) => {
|
||||
if (audioElement.value) {
|
||||
const newTime = Math.max(0, Math.min(duration.value, currentTime.value + seconds));
|
||||
seek(newTime);
|
||||
}
|
||||
};
|
||||
|
||||
const setPlaybackRate = (rate) => {
|
||||
playbackRate.value = rate;
|
||||
if (audioElement.value) {
|
||||
audioElement.value.playbackRate = rate;
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
if (audioElement.value) {
|
||||
audioElement.value.pause();
|
||||
audioElement.value.currentTime = 0;
|
||||
}
|
||||
isPlaying.value = false;
|
||||
currentTime.value = 0;
|
||||
duration.value = 0;
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
playbackRate,
|
||||
audioElement,
|
||||
|
||||
// Computed
|
||||
progress,
|
||||
formattedCurrentTime,
|
||||
formattedDuration,
|
||||
|
||||
// Methods
|
||||
initPlayer,
|
||||
play,
|
||||
pause,
|
||||
togglePlayPause,
|
||||
seek,
|
||||
seekPercent,
|
||||
skip,
|
||||
setPlaybackRate,
|
||||
reset
|
||||
};
|
||||
}
|
||||
327
static/js/composables/useRecordings.js
Normal file
327
static/js/composables/useRecordings.js
Normal file
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Recordings composable
|
||||
* Handles recordings list, selection, and CRUD operations
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { apiRequest } from '../utils/apiClient.js';
|
||||
|
||||
export function useRecordings() {
|
||||
// State
|
||||
const recordings = ref([]);
|
||||
const selectedRecording = ref(null);
|
||||
const isLoadingRecordings = ref(true);
|
||||
const globalError = ref(null);
|
||||
const currentView = ref('upload');
|
||||
const availableTags = ref([]);
|
||||
const selectedTagIds = ref([]);
|
||||
const showTagModal = ref(false);
|
||||
const showDeleteModal = ref(false);
|
||||
const recordingToDelete = ref(null);
|
||||
|
||||
// Computed
|
||||
const completedRecordings = computed(() => {
|
||||
return recordings.value.filter(r => r.status === 'COMPLETED');
|
||||
});
|
||||
|
||||
const processingRecordings = computed(() => {
|
||||
return recordings.value.filter(r => ['PENDING', 'PROCESSING', 'SUMMARIZING'].includes(r.status));
|
||||
});
|
||||
|
||||
const hasRecordings = computed(() => recordings.value.length > 0);
|
||||
|
||||
// Methods
|
||||
const loadRecordings = async (page = 1, filters = {}) => {
|
||||
globalError.value = null;
|
||||
isLoadingRecordings.value = true;
|
||||
|
||||
try {
|
||||
let endpoint = '/api/recordings';
|
||||
if (filters.archived) {
|
||||
endpoint = '/api/recordings/archived';
|
||||
} else if (filters.sharedWithMe) {
|
||||
endpoint = '/api/recordings/shared-with-me';
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
per_page: '25'
|
||||
});
|
||||
|
||||
if (filters.query) {
|
||||
params.set('q', filters.query.trim());
|
||||
}
|
||||
|
||||
const response = await fetch(`${endpoint}?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to load recordings');
|
||||
}
|
||||
|
||||
const recordingsList = filters.archived || filters.sharedWithMe ? data : data.recordings;
|
||||
|
||||
if (!Array.isArray(recordingsList)) {
|
||||
throw new Error('Invalid response format');
|
||||
}
|
||||
|
||||
recordings.value = recordingsList;
|
||||
|
||||
// Restore last selected recording
|
||||
const lastRecordingId = localStorage.getItem('lastSelectedRecordingId');
|
||||
if (lastRecordingId && recordingsList.length > 0) {
|
||||
const recordingToSelect = recordingsList.find(r => r.id == lastRecordingId);
|
||||
if (recordingToSelect) {
|
||||
selectRecording(recordingToSelect);
|
||||
}
|
||||
}
|
||||
|
||||
return filters.archived || filters.sharedWithMe ? null : data.pagination;
|
||||
|
||||
} catch (error) {
|
||||
globalError.value = error.message;
|
||||
throw error;
|
||||
} finally {
|
||||
isLoadingRecordings.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectRecording = async (recording) => {
|
||||
if (!recording) return;
|
||||
|
||||
selectedRecording.value = recording;
|
||||
currentView.value = 'recording';
|
||||
localStorage.setItem('lastSelectedRecordingId', recording.id);
|
||||
|
||||
// Load full recording details if needed
|
||||
if (!recording.transcription && recording.status === 'COMPLETED') {
|
||||
try {
|
||||
const data = await apiRequest(`/api/recordings/${recording.id}`);
|
||||
Object.assign(selectedRecording.value, data);
|
||||
} catch (error) {
|
||||
console.error('Error loading recording details:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deselectRecording = () => {
|
||||
selectedRecording.value = null;
|
||||
currentView.value = 'upload';
|
||||
localStorage.removeItem('lastSelectedRecordingId');
|
||||
};
|
||||
|
||||
const deleteRecording = async (recordingId) => {
|
||||
try {
|
||||
await apiRequest(`/api/recordings/${recordingId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
recordings.value = recordings.value.filter(r => r.id !== recordingId);
|
||||
|
||||
if (selectedRecording.value && selectedRecording.value.id === recordingId) {
|
||||
deselectRecording();
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
globalError.value = error.message;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const archiveRecording = async (recordingId) => {
|
||||
try {
|
||||
await apiRequest(`/api/recordings/${recordingId}/archive`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
recordings.value = recordings.value.filter(r => r.id !== recordingId);
|
||||
|
||||
if (selectedRecording.value && selectedRecording.value.id === recordingId) {
|
||||
deselectRecording();
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
globalError.value = error.message;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const unarchiveRecording = async (recordingId) => {
|
||||
try {
|
||||
await apiRequest(`/api/recordings/${recordingId}/unarchive`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
recordings.value = recordings.value.filter(r => r.id !== recordingId);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
globalError.value = error.message;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const updateRecording = async (recordingId, updates) => {
|
||||
try {
|
||||
const data = await apiRequest(`/api/recordings/${recordingId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
|
||||
const index = recordings.value.findIndex(r => r.id === recordingId);
|
||||
if (index > -1) {
|
||||
Object.assign(recordings.value[index], data.recording || data);
|
||||
}
|
||||
|
||||
if (selectedRecording.value && selectedRecording.value.id === recordingId) {
|
||||
Object.assign(selectedRecording.value, data.recording || data);
|
||||
}
|
||||
|
||||
return data.recording || data;
|
||||
} catch (error) {
|
||||
globalError.value = error.message;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const regenerateSummary = async (recordingId, customPrompt = null) => {
|
||||
try {
|
||||
const body = customPrompt ? { custom_prompt: customPrompt } : {};
|
||||
const data = await apiRequest(`/api/recordings/${recordingId}/regenerate-summary`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (selectedRecording.value && selectedRecording.value.id === recordingId) {
|
||||
selectedRecording.value.status = 'SUMMARIZING';
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
globalError.value = error.message;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const loadTags = async () => {
|
||||
try {
|
||||
const data = await apiRequest('/api/tags');
|
||||
availableTags.value = data;
|
||||
} catch (error) {
|
||||
console.error('Error loading tags:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const addTagToRecording = async (recordingId, tagId) => {
|
||||
try {
|
||||
const data = await apiRequest(`/api/recordings/${recordingId}/tags`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tag_id: tagId })
|
||||
});
|
||||
|
||||
if (selectedRecording.value && selectedRecording.value.id === recordingId) {
|
||||
selectedRecording.value.tags = data.tags || [];
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
globalError.value = error.message;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const removeTagFromRecording = async (recordingId, tagId) => {
|
||||
try {
|
||||
await apiRequest(`/api/recordings/${recordingId}/tags/${tagId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (selectedRecording.value && selectedRecording.value.id === recordingId) {
|
||||
selectedRecording.value.tags = selectedRecording.value.tags.filter(t => t.id !== tagId);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
globalError.value = error.message;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleHighlight = async (recordingId) => {
|
||||
const recording = recordings.value.find(r => r.id === recordingId);
|
||||
if (!recording) return;
|
||||
|
||||
const newValue = !recording.is_highlighted;
|
||||
|
||||
try {
|
||||
await updateRecording(recordingId, { is_highlighted: newValue });
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const setGlobalError = (message) => {
|
||||
globalError.value = message;
|
||||
};
|
||||
|
||||
const clearGlobalError = () => {
|
||||
globalError.value = null;
|
||||
};
|
||||
|
||||
const confirmDelete = (recording) => {
|
||||
recordingToDelete.value = recording;
|
||||
showDeleteModal.value = true;
|
||||
};
|
||||
|
||||
const cancelDelete = () => {
|
||||
recordingToDelete.value = null;
|
||||
showDeleteModal.value = false;
|
||||
};
|
||||
|
||||
const executeDelete = async () => {
|
||||
if (recordingToDelete.value) {
|
||||
await deleteRecording(recordingToDelete.value.id);
|
||||
cancelDelete();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
recordings,
|
||||
selectedRecording,
|
||||
isLoadingRecordings,
|
||||
globalError,
|
||||
currentView,
|
||||
availableTags,
|
||||
selectedTagIds,
|
||||
showTagModal,
|
||||
showDeleteModal,
|
||||
recordingToDelete,
|
||||
|
||||
// Computed
|
||||
completedRecordings,
|
||||
processingRecordings,
|
||||
hasRecordings,
|
||||
|
||||
// Methods
|
||||
loadRecordings,
|
||||
selectRecording,
|
||||
deselectRecording,
|
||||
deleteRecording,
|
||||
archiveRecording,
|
||||
unarchiveRecording,
|
||||
updateRecording,
|
||||
regenerateSummary,
|
||||
loadTags,
|
||||
addTagToRecording,
|
||||
removeTagFromRecording,
|
||||
toggleHighlight,
|
||||
setGlobalError,
|
||||
clearGlobalError,
|
||||
confirmDelete,
|
||||
cancelDelete,
|
||||
executeDelete
|
||||
};
|
||||
}
|
||||
195
static/js/composables/useSharing.js
Normal file
195
static/js/composables/useSharing.js
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Sharing composable
|
||||
* Handles public and internal sharing functionality
|
||||
*/
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { apiRequest } from '../utils/apiClient.js';
|
||||
|
||||
export function useSharing() {
|
||||
// State
|
||||
const showShareModal = ref(false);
|
||||
const showInternalShareModal = ref(false);
|
||||
const shareUrl = ref('');
|
||||
const shareSettings = ref({
|
||||
shareSummary: true,
|
||||
shareNotes: true
|
||||
});
|
||||
const internalShareSettings = ref({
|
||||
userId: null,
|
||||
canEdit: false,
|
||||
canReshare: false
|
||||
});
|
||||
const isLoadingShare = ref(false);
|
||||
const shareError = ref(null);
|
||||
|
||||
// Methods
|
||||
const openShareModal = async (recording) => {
|
||||
showShareModal.value = true;
|
||||
shareError.value = null;
|
||||
isLoadingShare.value = true;
|
||||
|
||||
try {
|
||||
const data = await apiRequest(`/api/recording/${recording.id}/share`);
|
||||
|
||||
if (data.exists) {
|
||||
shareUrl.value = data.share_url;
|
||||
shareSettings.value = {
|
||||
shareSummary: data.share.share_summary,
|
||||
shareNotes: data.share.share_notes
|
||||
};
|
||||
} else {
|
||||
shareUrl.value = '';
|
||||
}
|
||||
} catch (error) {
|
||||
shareError.value = error.message;
|
||||
} finally {
|
||||
isLoadingShare.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const createShare = async (recordingId) => {
|
||||
isLoadingShare.value = true;
|
||||
shareError.value = null;
|
||||
|
||||
try {
|
||||
const data = await apiRequest(`/api/recording/${recordingId}/share`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(shareSettings.value)
|
||||
});
|
||||
|
||||
shareUrl.value = data.share_url;
|
||||
return data;
|
||||
} catch (error) {
|
||||
shareError.value = error.message;
|
||||
throw error;
|
||||
} finally {
|
||||
isLoadingShare.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateShare = async (shareId) => {
|
||||
isLoadingShare.value = true;
|
||||
shareError.value = null;
|
||||
|
||||
try {
|
||||
const data = await apiRequest(`/api/share/${shareId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(shareSettings.value)
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
shareError.value = error.message;
|
||||
throw error;
|
||||
} finally {
|
||||
isLoadingShare.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteShare = async (shareId) => {
|
||||
isLoadingShare.value = true;
|
||||
shareError.value = null;
|
||||
|
||||
try {
|
||||
await apiRequest(`/api/share/${shareId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
shareUrl.value = '';
|
||||
} catch (error) {
|
||||
shareError.value = error.message;
|
||||
throw error;
|
||||
} finally {
|
||||
isLoadingShare.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const copyShareUrl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl.value);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const openInternalShareModal = (recording) => {
|
||||
showInternalShareModal.value = true;
|
||||
shareError.value = null;
|
||||
internalShareSettings.value = {
|
||||
userId: null,
|
||||
canEdit: false,
|
||||
canReshare: false
|
||||
};
|
||||
};
|
||||
|
||||
const shareInternally = async (recordingId) => {
|
||||
isLoadingShare.value = true;
|
||||
shareError.value = null;
|
||||
|
||||
try {
|
||||
const data = await apiRequest(`/api/recordings/${recordingId}/share-internal`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
user_id: internalShareSettings.value.userId,
|
||||
can_edit: internalShareSettings.value.canEdit,
|
||||
can_reshare: internalShareSettings.value.canReshare
|
||||
})
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
shareError.value = error.message;
|
||||
throw error;
|
||||
} finally {
|
||||
isLoadingShare.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const revokeInternalShare = async (shareId) => {
|
||||
isLoadingShare.value = true;
|
||||
shareError.value = null;
|
||||
|
||||
try {
|
||||
await apiRequest(`/api/internal-shares/${shareId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
} catch (error) {
|
||||
shareError.value = error.message;
|
||||
throw error;
|
||||
} finally {
|
||||
isLoadingShare.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeShareModal = () => {
|
||||
showShareModal.value = false;
|
||||
showInternalShareModal.value = false;
|
||||
shareUrl.value = '';
|
||||
shareError.value = null;
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
showShareModal,
|
||||
showInternalShareModal,
|
||||
shareUrl,
|
||||
shareSettings,
|
||||
internalShareSettings,
|
||||
isLoadingShare,
|
||||
shareError,
|
||||
|
||||
// Methods
|
||||
openShareModal,
|
||||
createShare,
|
||||
updateShare,
|
||||
deleteShare,
|
||||
copyShareUrl,
|
||||
openInternalShareModal,
|
||||
shareInternally,
|
||||
revokeInternalShare,
|
||||
closeShareModal
|
||||
};
|
||||
}
|
||||
209
static/js/composables/useTranscript.js
Normal file
209
static/js/composables/useTranscript.js
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Transcript composable
|
||||
* Handles transcript viewing and editing functionality
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { apiRequest } from '../utils/apiClient.js';
|
||||
|
||||
export function useTranscript() {
|
||||
// State
|
||||
const selectedTab = ref('summary');
|
||||
const isEditingTranscript = ref(false);
|
||||
const editedTranscription = ref('');
|
||||
const isEditingSummary = ref(false);
|
||||
const editedSummary = ref('');
|
||||
const isEditingNotes = ref(false);
|
||||
const editedNotes = ref('');
|
||||
const isInlineEditingTitle = ref(false);
|
||||
const editedTitle = ref('');
|
||||
const isSavingChanges = ref(false);
|
||||
const transcriptSearchQuery = ref('');
|
||||
const highlightedText = ref('');
|
||||
|
||||
// Methods
|
||||
const setTab = (tab) => {
|
||||
selectedTab.value = tab;
|
||||
};
|
||||
|
||||
const startEditingTranscript = (recording) => {
|
||||
isEditingTranscript.value = true;
|
||||
editedTranscription.value = recording.transcription || '';
|
||||
};
|
||||
|
||||
const cancelEditingTranscript = () => {
|
||||
isEditingTranscript.value = false;
|
||||
editedTranscription.value = '';
|
||||
};
|
||||
|
||||
const saveTranscript = async (recordingId) => {
|
||||
isSavingChanges.value = true;
|
||||
|
||||
try {
|
||||
const data = await apiRequest(`/api/recordings/${recordingId}/transcript`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
transcription: editedTranscription.value
|
||||
})
|
||||
});
|
||||
|
||||
isEditingTranscript.value = false;
|
||||
return data.recording;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
isSavingChanges.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const startEditingSummary = (recording) => {
|
||||
isEditingSummary.value = true;
|
||||
editedSummary.value = recording.summary || '';
|
||||
};
|
||||
|
||||
const cancelEditingSummary = () => {
|
||||
isEditingSummary.value = false;
|
||||
editedSummary.value = '';
|
||||
};
|
||||
|
||||
const saveSummary = async (recordingId) => {
|
||||
isSavingChanges.value = true;
|
||||
|
||||
try {
|
||||
const data = await apiRequest(`/api/recordings/${recordingId}/summary`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
summary: editedSummary.value
|
||||
})
|
||||
});
|
||||
|
||||
isEditingSummary.value = false;
|
||||
return data.recording;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
isSavingChanges.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const startEditingNotes = (recording) => {
|
||||
isEditingNotes.value = true;
|
||||
editedNotes.value = recording.notes || '';
|
||||
};
|
||||
|
||||
const cancelEditingNotes = () => {
|
||||
isEditingNotes.value = false;
|
||||
editedNotes.value = '';
|
||||
};
|
||||
|
||||
const saveNotes = async (recordingId) => {
|
||||
isSavingChanges.value = true;
|
||||
|
||||
try {
|
||||
const data = await apiRequest(`/api/recordings/${recordingId}/notes`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
notes: editedNotes.value
|
||||
})
|
||||
});
|
||||
|
||||
isEditingNotes.value = false;
|
||||
return data.recording;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
isSavingChanges.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const startEditingTitle = (recording) => {
|
||||
isInlineEditingTitle.value = true;
|
||||
editedTitle.value = recording.title || '';
|
||||
};
|
||||
|
||||
const cancelEditingTitle = () => {
|
||||
isInlineEditingTitle.value = false;
|
||||
editedTitle.value = '';
|
||||
};
|
||||
|
||||
const saveTitle = async (recordingId) => {
|
||||
isSavingChanges.value = true;
|
||||
|
||||
try {
|
||||
const data = await apiRequest(`/api/recordings/${recordingId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
title: editedTitle.value
|
||||
})
|
||||
});
|
||||
|
||||
isInlineEditingTitle.value = false;
|
||||
return data.recording;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
isSavingChanges.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const searchInTranscript = (text, query) => {
|
||||
if (!query) {
|
||||
highlightedText.value = text;
|
||||
return text;
|
||||
}
|
||||
|
||||
const regex = new RegExp(`(${query})`, 'gi');
|
||||
highlightedText.value = text.replace(regex, '<mark>$1</mark>');
|
||||
return highlightedText.value;
|
||||
};
|
||||
|
||||
const exportTranscript = async (recordingId, format) => {
|
||||
try {
|
||||
const response = await fetch(`/api/recordings/${recordingId}/export/${format}`);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `transcript.${format}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
selectedTab,
|
||||
isEditingTranscript,
|
||||
editedTranscription,
|
||||
isEditingSummary,
|
||||
editedSummary,
|
||||
isEditingNotes,
|
||||
editedNotes,
|
||||
isInlineEditingTitle,
|
||||
editedTitle,
|
||||
isSavingChanges,
|
||||
transcriptSearchQuery,
|
||||
highlightedText,
|
||||
|
||||
// Methods
|
||||
setTab,
|
||||
startEditingTranscript,
|
||||
cancelEditingTranscript,
|
||||
saveTranscript,
|
||||
startEditingSummary,
|
||||
cancelEditingSummary,
|
||||
saveSummary,
|
||||
startEditingNotes,
|
||||
cancelEditingNotes,
|
||||
saveNotes,
|
||||
startEditingTitle,
|
||||
cancelEditingTitle,
|
||||
saveTitle,
|
||||
searchInTranscript,
|
||||
exportTranscript
|
||||
};
|
||||
}
|
||||
147
static/js/composables/useUI.js
Normal file
147
static/js/composables/useUI.js
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* UI State composable
|
||||
* Handles all UI-related state (theme, sidebar, modals, etc.)
|
||||
*/
|
||||
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
|
||||
export function useUI() {
|
||||
// State
|
||||
const browser = ref('unknown');
|
||||
const isSidebarCollapsed = ref(false);
|
||||
const searchTipsExpanded = ref(false);
|
||||
const isUserMenuOpen = ref(false);
|
||||
const isDarkMode = ref(false);
|
||||
const currentColorScheme = ref('blue');
|
||||
const showColorSchemeModal = ref(false);
|
||||
const windowWidth = ref(window.innerWidth);
|
||||
const mobileTab = ref('transcript');
|
||||
const isMetadataExpanded = ref(false);
|
||||
const currentLanguage = ref('en');
|
||||
const currentLanguageName = ref('English');
|
||||
const availableLanguages = ref([]);
|
||||
const showLanguageMenu = ref(false);
|
||||
|
||||
// Computed
|
||||
const isMobile = computed(() => windowWidth.value < 768);
|
||||
const isTablet = computed(() => windowWidth.value >= 768 && windowWidth.value < 1024);
|
||||
const isDesktop = computed(() => windowWidth.value >= 1024);
|
||||
|
||||
const colorSchemes = [
|
||||
{ name: 'blue', label: 'Blue', primary: '#3b82f6', hover: '#2563eb' },
|
||||
{ name: 'purple', label: 'Purple', primary: '#8b5cf6', hover: '#7c3aed' },
|
||||
{ name: 'green', label: 'Green', primary: '#10b981', hover: '#059669' },
|
||||
{ name: 'orange', label: 'Orange', primary: '#f59e0b', hover: '#d97706' },
|
||||
{ name: 'pink', label: 'Pink', primary: '#ec4899', hover: '#db2777' },
|
||||
{ name: 'red', label: 'Red', primary: '#ef4444', hover: '#dc2626' }
|
||||
];
|
||||
|
||||
// Methods
|
||||
const detectBrowser = () => {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
if (userAgent.indexOf('firefox') > -1) browser.value = 'firefox';
|
||||
else if (userAgent.indexOf('chrome') > -1 && userAgent.indexOf('edge') === -1) browser.value = 'chrome';
|
||||
else if (userAgent.indexOf('safari') > -1 && userAgent.indexOf('chrome') === -1) browser.value = 'safari';
|
||||
else if (userAgent.indexOf('edge') > -1) browser.value = 'edge';
|
||||
else browser.value = 'unknown';
|
||||
};
|
||||
|
||||
const toggleSidebar = () => {
|
||||
isSidebarCollapsed.value = !isSidebarCollapsed.value;
|
||||
localStorage.setItem('sidebarCollapsed', isSidebarCollapsed.value.toString());
|
||||
};
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
isDarkMode.value = !isDarkMode.value;
|
||||
document.documentElement.classList.toggle('dark', isDarkMode.value);
|
||||
localStorage.setItem('darkMode', isDarkMode.value ? 'enabled' : 'disabled');
|
||||
};
|
||||
|
||||
const setColorScheme = (scheme) => {
|
||||
currentColorScheme.value = scheme;
|
||||
document.documentElement.setAttribute('data-color-scheme', scheme);
|
||||
localStorage.setItem('colorScheme', scheme);
|
||||
};
|
||||
|
||||
const loadUIPreferences = () => {
|
||||
// Load dark mode
|
||||
const savedDarkMode = localStorage.getItem('darkMode');
|
||||
if (savedDarkMode === 'enabled') {
|
||||
isDarkMode.value = true;
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
|
||||
// Load color scheme
|
||||
const savedScheme = localStorage.getItem('colorScheme');
|
||||
if (savedScheme && colorSchemes.find(s => s.name === savedScheme)) {
|
||||
setColorScheme(savedScheme);
|
||||
}
|
||||
|
||||
// Load sidebar state
|
||||
const savedSidebar = localStorage.getItem('sidebarCollapsed');
|
||||
if (savedSidebar === 'true') {
|
||||
isSidebarCollapsed.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
windowWidth.value = window.innerWidth;
|
||||
};
|
||||
|
||||
const toggleUserMenu = () => {
|
||||
isUserMenuOpen.value = !isUserMenuOpen.value;
|
||||
};
|
||||
|
||||
const closeUserMenu = () => {
|
||||
isUserMenuOpen.value = false;
|
||||
};
|
||||
|
||||
const setMobileTab = (tab) => {
|
||||
mobileTab.value = tab;
|
||||
};
|
||||
|
||||
const toggleMetadata = () => {
|
||||
isMetadataExpanded.value = !isMetadataExpanded.value;
|
||||
};
|
||||
|
||||
// Initialize
|
||||
onMounted(() => {
|
||||
detectBrowser();
|
||||
loadUIPreferences();
|
||||
window.addEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
return {
|
||||
// State
|
||||
browser,
|
||||
isSidebarCollapsed,
|
||||
searchTipsExpanded,
|
||||
isUserMenuOpen,
|
||||
isDarkMode,
|
||||
currentColorScheme,
|
||||
showColorSchemeModal,
|
||||
windowWidth,
|
||||
mobileTab,
|
||||
isMetadataExpanded,
|
||||
currentLanguage,
|
||||
currentLanguageName,
|
||||
availableLanguages,
|
||||
showLanguageMenu,
|
||||
|
||||
// Computed
|
||||
isMobile,
|
||||
isTablet,
|
||||
isDesktop,
|
||||
colorSchemes,
|
||||
|
||||
// Methods
|
||||
toggleSidebar,
|
||||
toggleDarkMode,
|
||||
setColorScheme,
|
||||
loadUIPreferences,
|
||||
toggleUserMenu,
|
||||
closeUserMenu,
|
||||
setMobileTab,
|
||||
toggleMetadata
|
||||
};
|
||||
}
|
||||
280
static/js/composables/useUpload.js
Normal file
280
static/js/composables/useUpload.js
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Upload composable
|
||||
* Handles file upload queue and processing
|
||||
*/
|
||||
|
||||
import { ref, computed, nextTick } from 'vue';
|
||||
import { uploadFile } from '../utils/apiClient.js';
|
||||
|
||||
export function useUpload() {
|
||||
// State
|
||||
const uploadQueue = ref([]);
|
||||
const currentlyProcessingFile = ref(null);
|
||||
const processingProgress = ref(0);
|
||||
const processingMessage = ref('');
|
||||
const isProcessingActive = ref(false);
|
||||
const pollInterval = ref(null);
|
||||
const progressPopupMinimized = ref(false);
|
||||
const progressPopupClosed = ref(false);
|
||||
const maxFileSizeMB = ref(250);
|
||||
const chunkingEnabled = ref(true);
|
||||
const dragover = ref(false);
|
||||
|
||||
// Computed
|
||||
const hasQueuedFiles = computed(() => {
|
||||
return uploadQueue.value.some(item => item.status === 'queued');
|
||||
});
|
||||
|
||||
const processingCount = computed(() => {
|
||||
return uploadQueue.value.filter(item => item.status === 'processing' || item.status === 'queued').length;
|
||||
});
|
||||
|
||||
const completedCount = computed(() => {
|
||||
return uploadQueue.value.filter(item => item.status === 'completed').length;
|
||||
});
|
||||
|
||||
const errorCount = computed(() => {
|
||||
return uploadQueue.value.filter(item => item.status === 'error').length;
|
||||
});
|
||||
|
||||
// Methods
|
||||
const addFilesToQueue = (files) => {
|
||||
const maxFileSize = maxFileSizeMB.value * 1024 * 1024;
|
||||
|
||||
for (const file of files) {
|
||||
if (file.size > maxFileSize) {
|
||||
uploadQueue.value.push({
|
||||
file,
|
||||
status: 'error',
|
||||
error: `File exceeds maximum size of ${maxFileSizeMB.value}MB`,
|
||||
clientId: Date.now() + Math.random()
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const isAudio = file.type.startsWith('audio/') ||
|
||||
file.type.startsWith('video/') ||
|
||||
/\.(mp3|wav|ogg|m4a|flac|webm|weba|mp4|mov|avi|mkv)$/i.test(file.name);
|
||||
|
||||
if (!isAudio) {
|
||||
uploadQueue.value.push({
|
||||
file,
|
||||
status: 'error',
|
||||
error: 'File type not supported',
|
||||
clientId: Date.now() + Math.random()
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
uploadQueue.value.push({
|
||||
file,
|
||||
status: 'queued',
|
||||
recordingId: null,
|
||||
clientId: Date.now() + Math.random(),
|
||||
error: null
|
||||
});
|
||||
}
|
||||
|
||||
if (!isProcessingActive.value && hasQueuedFiles.value) {
|
||||
startProcessingQueue();
|
||||
}
|
||||
};
|
||||
|
||||
const startProcessingQueue = async () => {
|
||||
if (isProcessingActive.value) return;
|
||||
|
||||
const nextItem = uploadQueue.value.find(item => item.status === 'queued');
|
||||
if (!nextItem) {
|
||||
isProcessingActive.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessingActive.value = true;
|
||||
currentlyProcessingFile.value = nextItem;
|
||||
nextItem.status = 'uploading';
|
||||
processingProgress.value = 0;
|
||||
processingMessage.value = 'Uploading...';
|
||||
|
||||
try {
|
||||
const data = await uploadFile('/api/recordings/upload', nextItem.file, (progress) => {
|
||||
processingProgress.value = progress;
|
||||
processingMessage.value = `Uploading... ${Math.round(progress)}%`;
|
||||
});
|
||||
|
||||
nextItem.recordingId = data.recording_id;
|
||||
nextItem.status = 'processing';
|
||||
processingMessage.value = 'Processing...';
|
||||
|
||||
// Start polling for status
|
||||
pollProcessingStatus(nextItem);
|
||||
|
||||
} catch (error) {
|
||||
nextItem.status = 'error';
|
||||
nextItem.error = error.message;
|
||||
currentlyProcessingFile.value = null;
|
||||
isProcessingActive.value = false;
|
||||
|
||||
// Continue with next file
|
||||
if (hasQueuedFiles.value) {
|
||||
await nextTick();
|
||||
startProcessingQueue();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pollProcessingStatus = (queueItem) => {
|
||||
if (pollInterval.value) {
|
||||
clearInterval(pollInterval.value);
|
||||
}
|
||||
|
||||
pollInterval.value = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/recordings/${queueItem.recordingId}/status`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'COMPLETED') {
|
||||
clearInterval(pollInterval.value);
|
||||
pollInterval.value = null;
|
||||
|
||||
queueItem.status = 'completed';
|
||||
currentlyProcessingFile.value = null;
|
||||
isProcessingActive.value = false;
|
||||
processingProgress.value = 100;
|
||||
processingMessage.value = 'Complete!';
|
||||
|
||||
// Continue with next file
|
||||
if (hasQueuedFiles.value) {
|
||||
await nextTick();
|
||||
startProcessingQueue();
|
||||
}
|
||||
|
||||
} else if (data.status === 'ERROR') {
|
||||
clearInterval(pollInterval.value);
|
||||
pollInterval.value = null;
|
||||
|
||||
queueItem.status = 'error';
|
||||
queueItem.error = data.error_message || 'Processing failed';
|
||||
currentlyProcessingFile.value = null;
|
||||
isProcessingActive.value = false;
|
||||
|
||||
// Continue with next file
|
||||
if (hasQueuedFiles.value) {
|
||||
await nextTick();
|
||||
startProcessingQueue();
|
||||
}
|
||||
|
||||
} else {
|
||||
// Still processing
|
||||
if (data.status === 'SUMMARIZING') {
|
||||
processingMessage.value = 'Generating summary...';
|
||||
processingProgress.value = 80;
|
||||
} else {
|
||||
processingMessage.value = 'Transcribing...';
|
||||
processingProgress.value = 50;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error polling status:', error);
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const removeFromQueue = (clientId) => {
|
||||
const index = uploadQueue.value.findIndex(item => item.clientId === clientId);
|
||||
if (index > -1) {
|
||||
uploadQueue.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCompletedFromQueue = () => {
|
||||
uploadQueue.value = uploadQueue.value.filter(item =>
|
||||
item.status !== 'completed' && item.status !== 'error'
|
||||
);
|
||||
};
|
||||
|
||||
const handleDragEnter = (event) => {
|
||||
event.preventDefault();
|
||||
dragover.value = true;
|
||||
};
|
||||
|
||||
const handleDragLeave = (event) => {
|
||||
event.preventDefault();
|
||||
dragover.value = false;
|
||||
};
|
||||
|
||||
const handleDrop = (event) => {
|
||||
event.preventDefault();
|
||||
dragover.value = false;
|
||||
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
addFilesToQueue(files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (event) => {
|
||||
const files = Array.from(event.target.files);
|
||||
if (files.length > 0) {
|
||||
addFilesToQueue(files);
|
||||
}
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const minimizeProgressPopup = () => {
|
||||
progressPopupMinimized.value = true;
|
||||
};
|
||||
|
||||
const maximizeProgressPopup = () => {
|
||||
progressPopupMinimized.value = false;
|
||||
};
|
||||
|
||||
const closeProgressPopup = () => {
|
||||
progressPopupClosed.value = true;
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/config');
|
||||
const data = await response.json();
|
||||
maxFileSizeMB.value = data.max_file_size_mb || 250;
|
||||
chunkingEnabled.value = data.chunking_enabled !== false;
|
||||
} catch (error) {
|
||||
console.error('Error loading config:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
uploadQueue,
|
||||
currentlyProcessingFile,
|
||||
processingProgress,
|
||||
processingMessage,
|
||||
isProcessingActive,
|
||||
progressPopupMinimized,
|
||||
progressPopupClosed,
|
||||
maxFileSizeMB,
|
||||
chunkingEnabled,
|
||||
dragover,
|
||||
|
||||
// Computed
|
||||
hasQueuedFiles,
|
||||
processingCount,
|
||||
completedCount,
|
||||
errorCount,
|
||||
|
||||
// Methods
|
||||
addFilesToQueue,
|
||||
startProcessingQueue,
|
||||
removeFromQueue,
|
||||
clearCompletedFromQueue,
|
||||
handleDragEnter,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
handleFileSelect,
|
||||
minimizeProgressPopup,
|
||||
maximizeProgressPopup,
|
||||
closeProgressPopup,
|
||||
loadConfig
|
||||
};
|
||||
}
|
||||
78
static/js/config/push-config.js
Normal file
78
static/js/config/push-config.js
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Push Notification Configuration
|
||||
*
|
||||
* AUTO-CONFIGURATION:
|
||||
* ------------------
|
||||
* Push notifications are now auto-configured!
|
||||
*
|
||||
* On first server startup:
|
||||
* 1. VAPID keys are automatically generated (requires pywebpush)
|
||||
* 2. Keys are saved to /config/vapid_keys.json (persists across restarts)
|
||||
* 3. Public key is served via /api/push/config
|
||||
* 4. Client fetches config dynamically
|
||||
*
|
||||
* No manual configuration needed - just make sure:
|
||||
* - pywebpush is installed: pip install pywebpush
|
||||
* - /config directory is mounted as Docker volume (for persistence)
|
||||
*/
|
||||
|
||||
// Cached config fetched from server
|
||||
let cachedConfig = null;
|
||||
|
||||
/**
|
||||
* Fetch push notification config from server
|
||||
*/
|
||||
export async function getPushConfig() {
|
||||
if (cachedConfig) {
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/push/config');
|
||||
if (!response.ok) {
|
||||
console.warn('[Push Config] Failed to fetch config:', response.status);
|
||||
return { enabled: false, public_key: null };
|
||||
}
|
||||
|
||||
cachedConfig = await response.json();
|
||||
console.log('[Push Config] Loaded from server:', cachedConfig.enabled ? 'enabled' : 'disabled');
|
||||
return cachedConfig;
|
||||
} catch (error) {
|
||||
console.error('[Push Config] Error fetching config:', error);
|
||||
return { enabled: false, public_key: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if push notifications are enabled
|
||||
*/
|
||||
export async function isPushEnabled() {
|
||||
const config = await getPushConfig();
|
||||
return config.enabled && !!config.public_key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VAPID public key from server
|
||||
*/
|
||||
export async function getPublicKey() {
|
||||
const config = await getPushConfig();
|
||||
return config.public_key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a base64 string to Uint8Array (required for push subscription)
|
||||
*/
|
||||
export function urlBase64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
206
static/js/csrf-refresh.js
Normal file
206
static/js/csrf-refresh.js
Normal file
@@ -0,0 +1,206 @@
|
||||
// CSRF Token Management with Auto-Refresh
|
||||
class CSRFManager {
|
||||
constructor() {
|
||||
this.token = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
this.refreshPromise = null;
|
||||
this.setupFetchInterceptor();
|
||||
}
|
||||
|
||||
async refreshToken() {
|
||||
// Prevent multiple simultaneous refresh requests
|
||||
if (this.refreshPromise) {
|
||||
return this.refreshPromise;
|
||||
}
|
||||
|
||||
this.refreshPromise = this.performTokenRefresh();
|
||||
try {
|
||||
const newToken = await this.refreshPromise;
|
||||
return newToken;
|
||||
} finally {
|
||||
this.refreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async performTokenRefresh() {
|
||||
try {
|
||||
console.log('Refreshing CSRF token...');
|
||||
|
||||
// Use the original fetch to avoid recursion
|
||||
const originalFetch = window.originalFetch || fetch;
|
||||
const response = await originalFetch('/api/csrf-token', {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to refresh CSRF token: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Expected JSON response but got ${contentType}. Response: ${text.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.csrf_token) {
|
||||
this.token = data.csrf_token;
|
||||
// Update the meta tag for any other code that might read it
|
||||
const metaTag = document.querySelector('meta[name="csrf-token"]');
|
||||
if (metaTag) {
|
||||
metaTag.setAttribute('content', this.token);
|
||||
}
|
||||
|
||||
// Update Vue.js reactive token if available
|
||||
if (window.app && window.app.csrfToken !== undefined) {
|
||||
window.app.csrfToken = this.token;
|
||||
}
|
||||
|
||||
console.log('CSRF token refreshed successfully');
|
||||
return this.token;
|
||||
} else {
|
||||
throw new Error('No CSRF token in response');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh CSRF token:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
setupFetchInterceptor() {
|
||||
// Store original fetch if not already stored
|
||||
if (!window.originalFetch) {
|
||||
window.originalFetch = window.fetch;
|
||||
}
|
||||
|
||||
const originalFetch = window.originalFetch;
|
||||
const self = this;
|
||||
|
||||
window.fetch = async function(url, options = {}) {
|
||||
// Skip CSRF token for the token refresh endpoint to avoid recursion
|
||||
if (url.includes('/api/csrf-token')) {
|
||||
return originalFetch(url, options);
|
||||
}
|
||||
|
||||
// Add CSRF token to headers for API requests
|
||||
const newOptions = { ...options };
|
||||
if (url.startsWith('/api/') || url.startsWith('/upload') || url.startsWith('/save') ||
|
||||
url.startsWith('/recording/') || url.startsWith('/chat') || url.startsWith('/speakers')) {
|
||||
|
||||
newOptions.headers = {
|
||||
'X-CSRFToken': self.token,
|
||||
...newOptions.headers
|
||||
};
|
||||
}
|
||||
|
||||
// Make the request
|
||||
let response = await originalFetch(url, newOptions);
|
||||
|
||||
// Check for CSRF token expiration
|
||||
if ((response.status === 400 || response.status === 403) &&
|
||||
(url.startsWith('/api/') || url.startsWith('/upload') || url.startsWith('/save') ||
|
||||
url.startsWith('/recording/') || url.startsWith('/chat') || url.startsWith('/speakers'))) {
|
||||
|
||||
try {
|
||||
// Try to parse as JSON first
|
||||
const responseClone = response.clone();
|
||||
let isCSRFError = false;
|
||||
|
||||
try {
|
||||
const errorData = await responseClone.json();
|
||||
const errorMessage = errorData.error || '';
|
||||
isCSRFError = errorMessage.toLowerCase().includes('csrf') ||
|
||||
errorMessage.toLowerCase().includes('token');
|
||||
} catch (jsonError) {
|
||||
// If JSON parsing fails, check if it's an HTML error page
|
||||
const textResponse = await response.clone().text();
|
||||
isCSRFError = textResponse.toLowerCase().includes('csrf') ||
|
||||
textResponse.toLowerCase().includes('token') ||
|
||||
textResponse.includes('<!doctype') || // HTML error page
|
||||
textResponse.includes('<html');
|
||||
}
|
||||
|
||||
if (isCSRFError) {
|
||||
console.log('CSRF token expired, attempting refresh and retry...');
|
||||
|
||||
try {
|
||||
// Refresh the token
|
||||
await self.refreshToken();
|
||||
|
||||
// Retry the original request with the new token
|
||||
newOptions.headers['X-CSRFToken'] = self.token;
|
||||
response = await originalFetch(url, newOptions);
|
||||
|
||||
if (response.ok) {
|
||||
console.log('Request succeeded after CSRF token refresh');
|
||||
} else {
|
||||
console.warn('Request still failed after CSRF token refresh:', response.status);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.error('Failed to refresh CSRF token during retry:', refreshError);
|
||||
// Return the original failed response
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('Could not parse error response for CSRF check:', parseError);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
// Method to manually get current token
|
||||
getToken() {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
// Method to manually refresh token (for periodic refresh)
|
||||
async manualRefresh() {
|
||||
try {
|
||||
await this.refreshToken();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Manual CSRF token refresh failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize CSRF manager when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.csrfManager = new CSRFManager();
|
||||
|
||||
// Set up periodic token refresh every 45 minutes (before 1-hour expiration)
|
||||
setInterval(() => {
|
||||
if (window.csrfManager) {
|
||||
console.log('Performing periodic CSRF token refresh...');
|
||||
window.csrfManager.manualRefresh();
|
||||
}
|
||||
}, 45 * 60 * 1000); // 45 minutes
|
||||
|
||||
// Refresh CSRF token when page becomes visible again (wake from sleep, tab switch)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && window.csrfManager) {
|
||||
console.log('[CSRF] Page visible again, refreshing token...');
|
||||
window.csrfManager.manualRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
// Heartbeat gap detection: if setInterval drifts > 2 min, system was asleep
|
||||
let lastHeartbeat = Date.now();
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
const drift = now - lastHeartbeat - 60000;
|
||||
if (drift > 120000) {
|
||||
console.log(`[CSRF] Heartbeat drift ${Math.round(drift / 1000)}s detected, refreshing token...`);
|
||||
if (window.csrfManager) {
|
||||
window.csrfManager.manualRefresh();
|
||||
}
|
||||
}
|
||||
lastHeartbeat = now;
|
||||
}, 60000);
|
||||
});
|
||||
297
static/js/i18n.js
Normal file
297
static/js/i18n.js
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Lightweight i18n (internationalization) system for Speakr
|
||||
* Handles loading and managing translations with template variable support
|
||||
*/
|
||||
|
||||
class I18n {
|
||||
constructor() {
|
||||
this.translations = {};
|
||||
this.currentLocale = 'en';
|
||||
this.fallbackLocale = 'en';
|
||||
this.loadedLocales = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize i18n with default locale
|
||||
* @param {string} locale - Initial locale code (e.g., 'en', 'es', 'fr', 'zh')
|
||||
*/
|
||||
async init(locale = 'en') {
|
||||
// Get saved locale from localStorage or use browser language
|
||||
const savedLocale = localStorage.getItem('preferredLanguage');
|
||||
const browserLocale = navigator.language.split('-')[0];
|
||||
|
||||
this.currentLocale = savedLocale || locale || browserLocale || 'en';
|
||||
|
||||
// Load the initial locale
|
||||
await this.loadLocale(this.currentLocale);
|
||||
|
||||
// Load fallback locale if different
|
||||
if (this.currentLocale !== this.fallbackLocale) {
|
||||
await this.loadLocale(this.fallbackLocale);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load translations for a specific locale
|
||||
* @param {string} locale - Locale code to load
|
||||
*/
|
||||
async loadLocale(locale) {
|
||||
if (this.loadedLocales.has(locale)) {
|
||||
return; // Already loaded
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/static/locales/${locale}.json`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load locale: ${locale}`);
|
||||
}
|
||||
|
||||
const translations = await response.json();
|
||||
this.translations[locale] = translations;
|
||||
this.loadedLocales.add(locale);
|
||||
|
||||
console.log(`Loaded locale: ${locale}`);
|
||||
} catch (error) {
|
||||
console.error(`Error loading locale ${locale}:`, error);
|
||||
|
||||
// If failed to load requested locale and it's not the fallback, try fallback
|
||||
if (locale !== this.fallbackLocale) {
|
||||
console.log(`Failed to load ${locale}, will use ${this.fallbackLocale} as fallback`);
|
||||
// Don't change currentLocale - keep user's preference
|
||||
// Just ensure fallback translations are available
|
||||
await this.loadLocale(this.fallbackLocale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the current locale
|
||||
* @param {string} locale - New locale code
|
||||
*/
|
||||
async setLocale(locale) {
|
||||
await this.loadLocale(locale);
|
||||
this.currentLocale = locale;
|
||||
localStorage.setItem('preferredLanguage', locale);
|
||||
|
||||
// Dispatch custom event for locale change
|
||||
window.dispatchEvent(new CustomEvent('localeChanged', { detail: { locale } }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current locale
|
||||
* @returns {string} Current locale code
|
||||
*/
|
||||
getLocale() {
|
||||
return this.currentLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available locales
|
||||
* @returns {Array} List of available locale codes
|
||||
*/
|
||||
getAvailableLocales() {
|
||||
return [
|
||||
{ code: 'en', name: 'English', nativeName: 'English' },
|
||||
{ code: 'es', name: 'Spanish', nativeName: 'Español' },
|
||||
{ code: 'fr', name: 'French', nativeName: 'Français' },
|
||||
{ code: 'zh', name: 'Chinese', nativeName: '中文' },
|
||||
{ code: 'ru', name: 'Russian', nativeName: 'Русский' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a key with optional parameters
|
||||
* @param {string} key - Translation key (e.g., 'common.save' or 'nav.upload')
|
||||
* @param {Object} params - Optional parameters for template replacement
|
||||
* @param {string} locale - Optional specific locale (defaults to current)
|
||||
* @returns {string} Translated text
|
||||
*/
|
||||
t(key, params = {}, locale = null) {
|
||||
const targetLocale = locale || this.currentLocale;
|
||||
|
||||
// Get translation from current locale or fallback
|
||||
let translation = this.getNestedTranslation(targetLocale, key);
|
||||
|
||||
if (!translation && targetLocale !== this.fallbackLocale) {
|
||||
translation = this.getNestedTranslation(this.fallbackLocale, key);
|
||||
}
|
||||
|
||||
if (!translation) {
|
||||
console.warn(`Translation not found for key: ${key}`);
|
||||
return key; // Return the key itself as fallback
|
||||
}
|
||||
|
||||
// Replace template variables
|
||||
return this.interpolate(translation, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nested translation value from object
|
||||
* @param {string} locale - Locale to search in
|
||||
* @param {string} key - Dot-separated key path
|
||||
* @returns {string|null} Translation value or null
|
||||
*/
|
||||
getNestedTranslation(locale, key) {
|
||||
if (!this.translations[locale]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keys = key.split('.');
|
||||
let value = this.translations[locale];
|
||||
|
||||
for (const k of keys) {
|
||||
if (value && typeof value === 'object' && k in value) {
|
||||
value = value[k];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace template variables in translation string
|
||||
* @param {string} text - Text with placeholders like {{variable}}
|
||||
* @param {Object} params - Parameters to replace
|
||||
* @returns {string} Interpolated text
|
||||
*/
|
||||
interpolate(text, params) {
|
||||
return text.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||
return params.hasOwnProperty(key) ? params[key] : match;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle pluralization
|
||||
* @param {string} key - Base translation key
|
||||
* @param {number} count - Count for pluralization
|
||||
* @param {Object} params - Additional parameters
|
||||
* @returns {string} Translated text with proper pluralization
|
||||
*/
|
||||
tc(key, count, params = {}) {
|
||||
const pluralKey = count === 1 ? key : `${key}Plural`;
|
||||
return this.t(pluralKey, { ...params, count });
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date according to locale
|
||||
* @param {Date|string} date - Date to format
|
||||
* @param {Object} options - Intl.DateTimeFormat options
|
||||
* @returns {string} Formatted date string
|
||||
*/
|
||||
formatDate(date, options = {}) {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
return new Intl.DateTimeFormat(this.currentLocale, options).format(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number according to locale
|
||||
* @param {number} number - Number to format
|
||||
* @param {Object} options - Intl.NumberFormat options
|
||||
* @returns {string} Formatted number string
|
||||
*/
|
||||
formatNumber(number, options = {}) {
|
||||
return new Intl.NumberFormat(this.currentLocale, options).format(number);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size with appropriate unit
|
||||
* @param {number} bytes - Size in bytes
|
||||
* @returns {string} Formatted file size
|
||||
*/
|
||||
formatFileSize(bytes) {
|
||||
const units = ['bytes', 'kilobytes', 'megabytes', 'gigabytes'];
|
||||
const unitValues = [1, 1024, 1048576, 1073741824];
|
||||
|
||||
let unitIndex = 0;
|
||||
for (let i = unitValues.length - 1; i >= 0; i--) {
|
||||
if (bytes >= unitValues[i]) {
|
||||
unitIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const value = Math.round(bytes / unitValues[unitIndex] * 10) / 10;
|
||||
return this.t(`fileSize.${units[unitIndex]}`, { count: value });
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration with appropriate unit
|
||||
* @param {number} seconds - Duration in seconds
|
||||
* @returns {string} Formatted duration
|
||||
*/
|
||||
formatDuration(seconds) {
|
||||
if (seconds < 60) {
|
||||
return this.tc('duration.seconds', Math.round(seconds), { count: Math.round(seconds) });
|
||||
} else if (seconds < 3600) {
|
||||
const minutes = Math.round(seconds / 60);
|
||||
return this.tc('duration.minutes', minutes, { count: minutes });
|
||||
} else {
|
||||
const hours = Math.round(seconds / 3600 * 10) / 10;
|
||||
return this.tc('duration.hours', hours, { count: hours });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time (e.g., "2 hours ago")
|
||||
* @param {Date|string} date - Date to format
|
||||
* @returns {string} Formatted relative time
|
||||
*/
|
||||
formatRelativeTime(date) {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
const now = new Date();
|
||||
const diffSeconds = Math.floor((now - d) / 1000);
|
||||
|
||||
if (diffSeconds < 60) {
|
||||
return this.t('time.justNow');
|
||||
} else if (diffSeconds < 3600) {
|
||||
const minutes = Math.floor(diffSeconds / 60);
|
||||
return minutes === 1
|
||||
? this.t('time.minuteAgo')
|
||||
: this.t('time.minutesAgo', { count: minutes });
|
||||
} else if (diffSeconds < 86400) {
|
||||
const hours = Math.floor(diffSeconds / 3600);
|
||||
return hours === 1
|
||||
? this.t('time.hourAgo')
|
||||
: this.t('time.hoursAgo', { count: hours });
|
||||
} else if (diffSeconds < 604800) {
|
||||
const days = Math.floor(diffSeconds / 86400);
|
||||
return days === 1
|
||||
? this.t('time.dayAgo')
|
||||
: this.t('time.daysAgo', { count: days });
|
||||
} else if (diffSeconds < 2592000) {
|
||||
const weeks = Math.floor(diffSeconds / 604800);
|
||||
return weeks === 1
|
||||
? this.t('time.weekAgo')
|
||||
: this.t('time.weeksAgo', { count: weeks });
|
||||
} else if (diffSeconds < 31536000) {
|
||||
const months = Math.floor(diffSeconds / 2592000);
|
||||
return months === 1
|
||||
? this.t('time.monthAgo')
|
||||
: this.t('time.monthsAgo', { count: months });
|
||||
} else {
|
||||
const years = Math.floor(diffSeconds / 31536000);
|
||||
return years === 1
|
||||
? this.t('time.yearAgo')
|
||||
: this.t('time.yearsAgo', { count: years });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create global i18n instance
|
||||
const i18n = new I18n();
|
||||
|
||||
// Create a fallback t function immediately
|
||||
if (typeof window !== 'undefined') {
|
||||
// Ensure window.i18n exists with at least a basic t function
|
||||
window.i18n = i18n;
|
||||
|
||||
// Add a fallback t function if the class method isn't ready
|
||||
if (!window.i18n.t) {
|
||||
window.i18n.t = function(key, params) {
|
||||
console.warn('i18n.t called before initialization, returning key:', key);
|
||||
return key;
|
||||
};
|
||||
}
|
||||
}
|
||||
201
static/js/loading.js
Normal file
201
static/js/loading.js
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* App loading overlay management
|
||||
* Prevents FOUC (Flash of Unstyled Content) during page initialization
|
||||
*/
|
||||
|
||||
window.AppLoader = {
|
||||
initialized: false,
|
||||
readyChecks: [],
|
||||
initTime: Date.now(),
|
||||
|
||||
/**
|
||||
* Initialize the loading system
|
||||
*/
|
||||
init() {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
this.initTime = Date.now();
|
||||
|
||||
// Add loading class to body
|
||||
document.body.classList.add('app-loading');
|
||||
|
||||
// Create loading overlay if it doesn't exist
|
||||
if (!document.querySelector('.app-loading-overlay')) {
|
||||
this.createOverlay();
|
||||
}
|
||||
|
||||
// Set up ready checks
|
||||
this.setupReadyChecks();
|
||||
},
|
||||
|
||||
/**
|
||||
* Create the loading overlay element
|
||||
*/
|
||||
createOverlay() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'app-loading-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="app-loading-content">
|
||||
<div class="app-loading-spinner"></div>
|
||||
<div class="app-loading-text">Loading...</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a ready check condition
|
||||
*/
|
||||
addReadyCheck(checkFn) {
|
||||
this.readyChecks.push(checkFn);
|
||||
},
|
||||
|
||||
/**
|
||||
* Setup default ready checks
|
||||
*/
|
||||
setupReadyChecks() {
|
||||
// Check if DOM is ready
|
||||
this.addReadyCheck(() => {
|
||||
return document.readyState === 'complete' || document.readyState === 'interactive';
|
||||
});
|
||||
|
||||
// Check if styles are loaded (optional - don't block on this)
|
||||
this.addReadyCheck(() => {
|
||||
try {
|
||||
const styles = document.querySelector('link[href*="styles.css"]');
|
||||
// If stylesheet isn't found or loaded, continue anyway after 2 seconds
|
||||
return !styles || styles.sheet || (Date.now() - this.initTime) > 2000;
|
||||
} catch (e) {
|
||||
console.warn('Error checking stylesheet:', e);
|
||||
return true; // Don't block on stylesheet errors
|
||||
}
|
||||
});
|
||||
|
||||
// Check if theme is initialized (optional - don't block on this)
|
||||
this.addReadyCheck(() => {
|
||||
try {
|
||||
const computed = window.getComputedStyle(document.documentElement);
|
||||
const bgPrimary = computed.getPropertyValue('--bg-primary').trim();
|
||||
// Accept if property exists or if 2 seconds have passed
|
||||
return bgPrimary !== '' || (Date.now() - this.initTime) > 2000;
|
||||
} catch (e) {
|
||||
console.warn('Error checking CSS variables:', e);
|
||||
return true; // Don't block on CSS variable errors
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if all conditions are met
|
||||
*/
|
||||
isReady() {
|
||||
if (this.readyChecks.length === 0) return true;
|
||||
return this.readyChecks.every(check => {
|
||||
try {
|
||||
return check();
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide the loading overlay
|
||||
*/
|
||||
hide() {
|
||||
try {
|
||||
// Remove app-loading class immediately to show content
|
||||
document.body.classList.remove('app-loading');
|
||||
|
||||
// Find all loading overlays (might be multiple)
|
||||
const overlays = document.querySelectorAll('.app-loading-overlay');
|
||||
|
||||
if (overlays.length > 0) {
|
||||
overlays.forEach(overlay => {
|
||||
// Force display none immediately in Firefox
|
||||
overlay.style.display = 'none';
|
||||
|
||||
// Then do graceful removal
|
||||
overlay.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
try {
|
||||
overlay.remove();
|
||||
} catch (e) {
|
||||
console.warn('Could not remove overlay:', e);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Loader hidden successfully');
|
||||
} catch (error) {
|
||||
console.error('Error hiding loader:', error);
|
||||
// Force hide everything as last resort
|
||||
document.body.classList.remove('app-loading');
|
||||
const overlays = document.querySelectorAll('.app-loading-overlay');
|
||||
overlays.forEach(o => {
|
||||
o.style.display = 'none';
|
||||
try { o.remove(); } catch (e) {}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Wait for app to be ready then hide overlay
|
||||
*/
|
||||
async waitForReady(timeout = 5000) {
|
||||
const startTime = Date.now();
|
||||
let hideExecuted = false;
|
||||
|
||||
const safeHide = () => {
|
||||
if (!hideExecuted) {
|
||||
hideExecuted = true;
|
||||
try {
|
||||
this.hide();
|
||||
} catch (error) {
|
||||
console.error('Error hiding loader:', error);
|
||||
// Force hide even if error occurs
|
||||
const overlay = document.querySelector('.app-loading-overlay');
|
||||
if (overlay) overlay.remove();
|
||||
document.body.classList.remove('app-loading');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const checkReady = () => {
|
||||
try {
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (this.isReady()) {
|
||||
console.log('App ready, hiding loader');
|
||||
safeHide();
|
||||
} else if (elapsed > timeout) {
|
||||
console.warn('Loader timeout reached, forcing hide');
|
||||
safeHide();
|
||||
} else {
|
||||
requestAnimationFrame(checkReady);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in checkReady:', error);
|
||||
safeHide();
|
||||
}
|
||||
};
|
||||
|
||||
// Hard timeout as absolute failsafe - hide after 10 seconds no matter what
|
||||
setTimeout(() => {
|
||||
if (!hideExecuted) {
|
||||
console.warn('Hard timeout reached (10s), forcing loader hide');
|
||||
safeHide();
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
// Start checking after a minimum display time
|
||||
setTimeout(checkReady, 300);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-initialize on script load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => AppLoader.init());
|
||||
} else {
|
||||
AppLoader.init();
|
||||
}
|
||||
895
static/js/modules/composables/audio.js
Normal file
895
static/js/modules/composables/audio.js
Normal file
@@ -0,0 +1,895 @@
|
||||
/**
|
||||
* Audio recording composable
|
||||
* Handles microphone/system audio recording with visualizers and wake lock
|
||||
*/
|
||||
|
||||
import * as RecordingDB from '../db/recording-persistence.js';
|
||||
import * as IncognitoStorage from '../db/incognito-storage.js';
|
||||
|
||||
export function useAudio(state, utils) {
|
||||
const {
|
||||
isRecording, mediaRecorder, audioContext, analyser, micAnalyser, systemAnalyser,
|
||||
audioChunks, recordingTime, recordingInterval, recordingMode, audioBlobURL,
|
||||
estimatedFileSize, actualBitrate, recordingNotes, recordingQuality,
|
||||
maxRecordingMB, fileSizeWarningShown, sizeCheckInterval, recordingDisclaimer,
|
||||
showRecordingDisclaimerModal, pendingRecordingMode, currentView, isDarkMode, wakeLock, animationFrameId,
|
||||
activeStreams, visualizer, micVisualizer, systemVisualizer, canRecordAudio,
|
||||
canRecordSystemAudio, systemAudioSupported, systemAudioError, globalError,
|
||||
selectedTagIds, selectedFolderId, asrLanguage, asrMinSpeakers, asrMaxSpeakers, uploadQueue,
|
||||
progressPopupMinimized, progressPopupClosed,
|
||||
// Incognito mode
|
||||
enableIncognitoMode, incognitoMode, incognitoRecording, incognitoProcessing,
|
||||
processingMessage, processingProgress, selectedRecording
|
||||
} = state;
|
||||
|
||||
const { showToast, setGlobalError, formatFileSize, startUploadQueue } = utils;
|
||||
|
||||
// Local state for pending streams and chunk tracking
|
||||
let pendingDisplayStream = null;
|
||||
let currentChunkIndex = 0;
|
||||
|
||||
// iOS detection
|
||||
const isiOS = () => {
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
||||
};
|
||||
|
||||
// Silent audio for iOS wake lock alternative
|
||||
let silentAudio = null;
|
||||
|
||||
// Create silent audio using data URL (1 second of silence)
|
||||
const createSilentAudio = () => {
|
||||
if (!silentAudio) {
|
||||
// Base64 encoded 1-second silent MP3
|
||||
const silentMp3 = 'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA//tQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWGluZwAAAA8AAAACAAADhAC7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7v////////////////////////////////////////////////////////////AAAAAExhdmM1OC4xMwAAAAAAAAAAAAAAACQCgAAAAAAAAAOEfxVqYQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//sQZAAP8AAAaQAAAAgAAA0gAAABAAABpAAAACAAADSAAAAETEFNRTMuMTAwVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//sQZDwP8AAAaQAAAAgAAA0gAAABAAABpAAAACAAADSAAAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVU=';
|
||||
silentAudio = new Audio(silentMp3);
|
||||
silentAudio.loop = true;
|
||||
silentAudio.volume = 0.01; // Very low volume, almost silent
|
||||
}
|
||||
return silentAudio;
|
||||
};
|
||||
|
||||
// Start iOS wake lock (play silent audio)
|
||||
const startiOSWakeLock = async () => {
|
||||
try {
|
||||
const audio = createSilentAudio();
|
||||
await audio.play();
|
||||
console.log('[iOS Wake Lock] Silent audio playing to prevent sleep');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('[iOS Wake Lock] Failed to start silent audio:', error);
|
||||
showToast('iOS wake lock may not work - keep screen active', 'warning');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Stop iOS wake lock (stop silent audio)
|
||||
const stopiOSWakeLock = () => {
|
||||
if (silentAudio) {
|
||||
silentAudio.pause();
|
||||
silentAudio.currentTime = 0;
|
||||
console.log('[iOS Wake Lock] Silent audio stopped');
|
||||
}
|
||||
};
|
||||
|
||||
// Acquire wake lock to prevent screen from sleeping during recording
|
||||
const acquireWakeLock = async () => {
|
||||
// iOS doesn't support Wake Lock API - use silent audio instead
|
||||
if (isiOS()) {
|
||||
return await startiOSWakeLock();
|
||||
}
|
||||
|
||||
// Android/Desktop: use native Wake Lock API
|
||||
try {
|
||||
if ('wakeLock' in navigator) {
|
||||
wakeLock.value = await navigator.wakeLock.request('screen');
|
||||
console.log('[WakeLock] Acquired - screen will stay awake during recording');
|
||||
|
||||
// Listen for wake lock release
|
||||
wakeLock.value.addEventListener('release', () => {
|
||||
console.log('[WakeLock] Released');
|
||||
});
|
||||
|
||||
return true;
|
||||
} else {
|
||||
console.warn('[WakeLock] Wake Lock API not supported');
|
||||
showToast('Screen may sleep during recording', 'info');
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[WakeLock] Could not acquire:', err.message);
|
||||
if (err.name === 'NotAllowedError') {
|
||||
showToast('Screen lock permission denied', 'warning');
|
||||
} else if (err.name === 'NotSupportedError') {
|
||||
showToast('Wake lock not supported on this device', 'info');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Release wake lock
|
||||
const releaseWakeLock = async () => {
|
||||
// iOS: stop silent audio
|
||||
if (isiOS()) {
|
||||
stopiOSWakeLock();
|
||||
return;
|
||||
}
|
||||
|
||||
// Android/Desktop: release native wake lock
|
||||
if (wakeLock.value) {
|
||||
try {
|
||||
await wakeLock.value.release();
|
||||
wakeLock.value = null;
|
||||
console.log('[WakeLock] Released');
|
||||
} catch (err) {
|
||||
console.warn('[WakeLock] Could not release:', err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Show recording notification
|
||||
const showRecordingNotification = async () => {
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
// Notifications handled by service worker
|
||||
}
|
||||
};
|
||||
|
||||
// Note: System audio capability detection is now handled by computed property
|
||||
// canRecordSystemAudio = computed(() => navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia)
|
||||
|
||||
// Hide recording notification
|
||||
const hideRecordingNotification = async () => {
|
||||
// Notifications cleared when recording stops
|
||||
};
|
||||
|
||||
// Handle visibility change (for wake lock re-acquisition)
|
||||
const handleVisibilityChange = async () => {
|
||||
if (document.visibilityState === 'visible' && isRecording.value) {
|
||||
console.log('[Visibility] Page visible, re-acquiring wake lock');
|
||||
const acquired = await acquireWakeLock();
|
||||
if (acquired) {
|
||||
showToast('Recording resumed - screen will stay awake', 'success');
|
||||
}
|
||||
} else if (document.visibilityState === 'hidden' && isRecording.value) {
|
||||
console.log('[Visibility] Page hidden, wake lock may be released by browser');
|
||||
}
|
||||
};
|
||||
|
||||
// Start recording
|
||||
// IMPORTANT: For Firefox, getDisplayMedia MUST be the first async call from user gesture
|
||||
const startRecording = async (mode = 'microphone') => {
|
||||
const needsDisplayMedia = mode === 'system' || mode === 'both';
|
||||
|
||||
// For system audio modes, get display media FIRST before any other operations
|
||||
// This is required for Firefox's "transient activation" security model
|
||||
if (needsDisplayMedia) {
|
||||
try {
|
||||
const displayStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: true,
|
||||
audio: true
|
||||
});
|
||||
|
||||
// Check if we got an audio track
|
||||
const audioTrack = displayStream.getAudioTracks()[0];
|
||||
if (!audioTrack) {
|
||||
displayStream.getTracks().forEach(track => track.stop());
|
||||
showToast('No audio track - check "Share audio" option', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Store stream for use after disclaimer (if any)
|
||||
pendingDisplayStream = displayStream;
|
||||
} catch (error) {
|
||||
console.error('[Recording] Failed to get display media:', error);
|
||||
if (error.name === 'NotAllowedError') {
|
||||
showToast('Screen sharing was cancelled', 'error');
|
||||
} else {
|
||||
showToast(`Failed to capture: ${error.message}`, 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Now check for disclaimer (after we've secured the display stream)
|
||||
if (recordingDisclaimer.value && recordingDisclaimer.value.trim() !== '') {
|
||||
showRecordingDisclaimerModal.value = true;
|
||||
pendingRecordingMode.value = mode;
|
||||
return;
|
||||
}
|
||||
|
||||
await startRecordingInternal(mode);
|
||||
};
|
||||
|
||||
// Accept recording disclaimer and start recording
|
||||
const acceptRecordingDisclaimer = async () => {
|
||||
showRecordingDisclaimerModal.value = false;
|
||||
await startRecordingInternal(pendingRecordingMode.value || 'microphone');
|
||||
};
|
||||
|
||||
// Cancel recording disclaimer
|
||||
const cancelRecordingDisclaimer = () => {
|
||||
showRecordingDisclaimerModal.value = false;
|
||||
// Clean up pending display stream if user cancels
|
||||
if (pendingDisplayStream) {
|
||||
pendingDisplayStream.getTracks().forEach(track => track.stop());
|
||||
pendingDisplayStream = null;
|
||||
}
|
||||
pendingRecordingMode.value = null;
|
||||
};
|
||||
|
||||
// Internal start recording function
|
||||
const startRecordingInternal = async (mode) => {
|
||||
try {
|
||||
recordingMode.value = mode;
|
||||
audioChunks.value = [];
|
||||
recordingTime.value = 0;
|
||||
estimatedFileSize.value = 0;
|
||||
fileSizeWarningShown.value = false;
|
||||
|
||||
// Initialize IndexedDB session
|
||||
currentChunkIndex = 0;
|
||||
|
||||
let stream;
|
||||
let combinedStream;
|
||||
|
||||
if (mode === 'microphone') {
|
||||
if (!canRecordAudio.value) {
|
||||
throw new Error('Microphone recording is not available. Make sure you are using HTTPS.');
|
||||
}
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
sampleRate: 48000
|
||||
}
|
||||
});
|
||||
activeStreams.value = [stream];
|
||||
|
||||
audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const source = audioContext.value.createMediaStreamSource(stream);
|
||||
analyser.value = audioContext.value.createAnalyser();
|
||||
analyser.value.fftSize = 256;
|
||||
source.connect(analyser.value);
|
||||
|
||||
} else if (mode === 'system') {
|
||||
if (!canRecordSystemAudio.value) {
|
||||
throw new Error('System audio recording is not available. Make sure you are using HTTPS.');
|
||||
}
|
||||
// Use pre-obtained display stream (required for Firefox user gesture)
|
||||
// or get it now for browsers that don't require immediate call
|
||||
const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
|
||||
|
||||
if (pendingDisplayStream) {
|
||||
stream = pendingDisplayStream;
|
||||
pendingDisplayStream = null;
|
||||
} else {
|
||||
const displayMediaConstraints = {
|
||||
video: true,
|
||||
audio: isFirefox ? true : {
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false
|
||||
}
|
||||
};
|
||||
stream = await navigator.mediaDevices.getDisplayMedia(displayMediaConstraints);
|
||||
}
|
||||
|
||||
const audioTrack = stream.getAudioTracks()[0];
|
||||
if (!audioTrack) {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
const browserName = isFirefox ? 'Firefox' : 'your browser';
|
||||
throw new Error(
|
||||
`No system audio track available. In ${browserName}, please:\n` +
|
||||
`1. Share a BROWSER TAB that is actively playing audio\n` +
|
||||
`2. Make sure "Share tab audio" checkbox is checked\n` +
|
||||
`3. The audio must be playing when you start sharing`
|
||||
);
|
||||
}
|
||||
|
||||
// Stop video track
|
||||
stream.getVideoTracks().forEach(track => track.stop());
|
||||
stream = new MediaStream([audioTrack]);
|
||||
activeStreams.value = [stream];
|
||||
|
||||
audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const source = audioContext.value.createMediaStreamSource(stream);
|
||||
analyser.value = audioContext.value.createAnalyser();
|
||||
analyser.value.fftSize = 256;
|
||||
source.connect(analyser.value);
|
||||
|
||||
} else if (mode === 'both') {
|
||||
if (!canRecordAudio.value || !canRecordSystemAudio.value) {
|
||||
throw new Error('Recording is not available. Make sure you are using HTTPS.');
|
||||
}
|
||||
const micStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
sampleRate: 48000
|
||||
}
|
||||
});
|
||||
|
||||
// Use pre-obtained display stream or get it now
|
||||
const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
|
||||
let displayStream;
|
||||
|
||||
if (pendingDisplayStream) {
|
||||
displayStream = pendingDisplayStream;
|
||||
pendingDisplayStream = null;
|
||||
} else {
|
||||
displayStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: true,
|
||||
audio: isFirefox ? true : {
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const systemAudioTrack = displayStream.getAudioTracks()[0];
|
||||
if (!systemAudioTrack) {
|
||||
micStream.getTracks().forEach(track => track.stop());
|
||||
displayStream.getTracks().forEach(track => track.stop());
|
||||
const browserName = isFirefox ? 'Firefox' : 'your browser';
|
||||
throw new Error(
|
||||
`No system audio track available. In ${browserName}, please:\n` +
|
||||
`1. Share a BROWSER TAB that is actively playing audio\n` +
|
||||
`2. Make sure "Share tab audio" checkbox is checked\n` +
|
||||
`3. The audio must be playing when you start sharing`
|
||||
);
|
||||
}
|
||||
|
||||
// Stop video tracks
|
||||
displayStream.getVideoTracks().forEach(track => track.stop());
|
||||
|
||||
// Create audio context and combine streams
|
||||
audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const destination = audioContext.value.createMediaStreamDestination();
|
||||
|
||||
const micSource = audioContext.value.createMediaStreamSource(micStream);
|
||||
const systemSource = audioContext.value.createMediaStreamSource(new MediaStream([systemAudioTrack]));
|
||||
|
||||
// Create analysers for each source
|
||||
micAnalyser.value = audioContext.value.createAnalyser();
|
||||
micAnalyser.value.fftSize = 256;
|
||||
systemAnalyser.value = audioContext.value.createAnalyser();
|
||||
systemAnalyser.value.fftSize = 256;
|
||||
|
||||
micSource.connect(micAnalyser.value);
|
||||
micSource.connect(destination);
|
||||
systemSource.connect(systemAnalyser.value);
|
||||
systemSource.connect(destination);
|
||||
|
||||
combinedStream = destination.stream;
|
||||
activeStreams.value = [micStream, displayStream];
|
||||
stream = combinedStream;
|
||||
}
|
||||
|
||||
// Determine best mime type
|
||||
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||
? 'audio/webm;codecs=opus'
|
||||
: 'audio/webm';
|
||||
|
||||
const recorder = new MediaRecorder(stream, { mimeType });
|
||||
|
||||
// Start IndexedDB recording session - convert Vue reactive objects to plain objects
|
||||
try {
|
||||
await RecordingDB.startRecordingSession({
|
||||
mode,
|
||||
notes: recordingNotes.value || '',
|
||||
tags: selectedTagIds.value ? [...selectedTagIds.value] : [], // Convert reactive array to plain array
|
||||
asrOptions: {
|
||||
language: asrLanguage.value || '',
|
||||
min_speakers: asrMinSpeakers.value || '',
|
||||
max_speakers: asrMaxSpeakers.value || ''
|
||||
},
|
||||
mimeType
|
||||
});
|
||||
} catch (dbError) {
|
||||
console.warn('[Recording] IndexedDB persistence failed, continuing without persistence:', dbError);
|
||||
}
|
||||
|
||||
recorder.ondataavailable = async (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunks.value.push(event.data);
|
||||
|
||||
// Save chunk to IndexedDB for crash recovery
|
||||
try {
|
||||
await RecordingDB.saveChunk(event.data, currentChunkIndex);
|
||||
await RecordingDB.updateRecordingMetadata({
|
||||
duration: recordingTime.value,
|
||||
notes: recordingNotes.value || ''
|
||||
});
|
||||
currentChunkIndex++;
|
||||
} catch (dbError) {
|
||||
// Don't spam console - recording continues in memory regardless
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
const blob = new Blob(audioChunks.value, { type: mimeType });
|
||||
audioBlobURL.value = URL.createObjectURL(blob);
|
||||
stopSizeMonitoring();
|
||||
};
|
||||
|
||||
mediaRecorder.value = recorder;
|
||||
recorder.start(5000); // 5-second chunks for less overhead while still enabling crash recovery
|
||||
isRecording.value = true;
|
||||
// Switch to recording view immediately so pending wake-lock/notification awaits don't block Safari rendering
|
||||
currentView.value = 'recording';
|
||||
|
||||
// Start timer
|
||||
recordingInterval.value = setInterval(() => {
|
||||
recordingTime.value++;
|
||||
}, 1000);
|
||||
|
||||
// Start size monitoring
|
||||
startSizeMonitoring();
|
||||
|
||||
// Acquire wake lock
|
||||
await acquireWakeLock();
|
||||
|
||||
// Show notification
|
||||
await showRecordingNotification();
|
||||
|
||||
// Start visualizers
|
||||
drawVisualizers();
|
||||
|
||||
// Notify service worker
|
||||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||
navigator.serviceWorker.controller.postMessage({
|
||||
type: 'RECORDING_STATE',
|
||||
isRecording: true
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Recording error:', error);
|
||||
setGlobalError(`Failed to start recording: ${error.message}`);
|
||||
|
||||
// Clean up any started streams
|
||||
if (activeStreams.value.length > 0) {
|
||||
activeStreams.value.forEach(stream => {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
});
|
||||
activeStreams.value = [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Stop recording
|
||||
const stopRecording = async () => {
|
||||
if (mediaRecorder.value && isRecording.value) {
|
||||
mediaRecorder.value.stop();
|
||||
isRecording.value = false;
|
||||
|
||||
// Clear the recording timer
|
||||
if (recordingInterval.value) {
|
||||
clearInterval(recordingInterval.value);
|
||||
recordingInterval.value = null;
|
||||
}
|
||||
|
||||
stopSizeMonitoring();
|
||||
cancelAnimationFrame(animationFrameId.value);
|
||||
animationFrameId.value = null;
|
||||
|
||||
// Stop all active media streams (mic, screen share, etc.)
|
||||
if (activeStreams.value.length > 0) {
|
||||
activeStreams.value.forEach(stream => {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
});
|
||||
activeStreams.value = [];
|
||||
}
|
||||
|
||||
// Release wake lock
|
||||
await releaseWakeLock();
|
||||
|
||||
// Hide recording notification
|
||||
await hideRecordingNotification();
|
||||
|
||||
// Notify service worker
|
||||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||
navigator.serviceWorker.controller.postMessage({
|
||||
type: 'RECORDING_STATE',
|
||||
isRecording: false,
|
||||
duration: recordingTime.value
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Upload recorded audio
|
||||
const uploadRecordedAudio = async () => {
|
||||
if (!audioBlobURL.value) {
|
||||
setGlobalError("No recorded audio to upload.");
|
||||
return;
|
||||
}
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const recordedFile = new File(audioChunks.value, `recording-${timestamp}.webm`, { type: 'audio/webm' });
|
||||
|
||||
// Get selected tags as objects and create a DEEP copy to prevent reactivity issues
|
||||
const selectedTagsTemp = selectedTagIds.value.map(tagId => {
|
||||
const tag = state.availableTags.value.find(t => t.id == tagId);
|
||||
return tag || null;
|
||||
}).filter(Boolean);
|
||||
|
||||
// Deep clone to completely break reactivity chain - JSON parse/stringify removes all proxies
|
||||
const selectedTags = JSON.parse(JSON.stringify(selectedTagsTemp));
|
||||
|
||||
// Add to upload queue
|
||||
uploadQueue.value.push({
|
||||
file: recordedFile,
|
||||
notes: recordingNotes.value,
|
||||
tags: selectedTags, // Completely non-reactive deep copy
|
||||
folder_id: selectedFolderId.value,
|
||||
preserveOptions: true, // Prevents startUpload from overwriting recording's options
|
||||
asrOptions: {
|
||||
language: asrLanguage.value,
|
||||
min_speakers: asrMinSpeakers.value,
|
||||
max_speakers: asrMaxSpeakers.value
|
||||
},
|
||||
status: 'queued',
|
||||
recordingId: null,
|
||||
clientId: `client-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
error: null,
|
||||
willAutoSummarize: false, // Server will tell us via SUMMARIZING status
|
||||
// Callback to clear IndexedDB session AFTER successful upload (not before)
|
||||
onUploadSuccess: async () => {
|
||||
try {
|
||||
await RecordingDB.clearRecordingSession();
|
||||
console.log('[Recording] IndexedDB session cleared after successful upload');
|
||||
} catch (dbError) {
|
||||
console.warn('[Recording] Failed to clear IndexedDB session:', dbError);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
discardRecording();
|
||||
|
||||
// Return to upload view (main UI)
|
||||
currentView.value = 'upload';
|
||||
|
||||
// Start upload immediately
|
||||
progressPopupMinimized.value = false;
|
||||
progressPopupClosed.value = false;
|
||||
|
||||
if (startUploadQueue) {
|
||||
startUploadQueue();
|
||||
}
|
||||
};
|
||||
|
||||
// Upload recorded audio in incognito mode
|
||||
const uploadRecordedAudioIncognito = async () => {
|
||||
if (!audioBlobURL.value) {
|
||||
setGlobalError("No recorded audio to upload.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if incognito state is available
|
||||
if (!incognitoProcessing || !incognitoRecording) {
|
||||
console.warn('[Incognito] Incognito state not available, falling back to normal upload');
|
||||
uploadRecordedAudio();
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const recordedFile = new File(audioChunks.value, `recording-${timestamp}.webm`, { type: 'audio/webm' });
|
||||
|
||||
incognitoProcessing.value = true;
|
||||
processingMessage.value = 'Processing recording in incognito mode...';
|
||||
processingProgress.value = 10;
|
||||
progressPopupMinimized.value = false;
|
||||
progressPopupClosed.value = false;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', recordedFile);
|
||||
|
||||
// Add ASR options
|
||||
if (asrLanguage.value) {
|
||||
formData.append('language', asrLanguage.value);
|
||||
}
|
||||
if (asrMinSpeakers.value && asrMinSpeakers.value !== '') {
|
||||
formData.append('min_speakers', asrMinSpeakers.value.toString());
|
||||
}
|
||||
if (asrMaxSpeakers.value && asrMaxSpeakers.value !== '') {
|
||||
formData.append('max_speakers', asrMaxSpeakers.value.toString());
|
||||
}
|
||||
|
||||
// Request auto-summarization
|
||||
formData.append('auto_summarize', 'true');
|
||||
|
||||
processingMessage.value = 'Uploading recording for incognito processing...';
|
||||
processingProgress.value = 20;
|
||||
|
||||
console.log('[Incognito] Uploading recorded audio');
|
||||
|
||||
const response = await fetch('/api/recordings/incognito', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
processingProgress.value = 50;
|
||||
|
||||
// Parse response
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
const text = await response.text();
|
||||
const titleMatch = text.match(/<title>([^<]+)<\/title>/i);
|
||||
throw new Error(titleMatch?.[1] || `Server error (${response.status})`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
throw new Error(data.error || `Processing failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
processingProgress.value = 80;
|
||||
processingMessage.value = 'Processing complete!';
|
||||
|
||||
// Store result in sessionStorage
|
||||
const incognitoData = {
|
||||
id: 'incognito',
|
||||
incognito: true,
|
||||
title: data.title || 'Incognito Recording',
|
||||
transcription: data.transcription,
|
||||
summary: data.summary,
|
||||
summary_html: data.summary_html,
|
||||
created_at: data.created_at,
|
||||
original_filename: data.original_filename,
|
||||
file_size: data.file_size,
|
||||
audio_duration_seconds: data.audio_duration_seconds,
|
||||
processing_time_seconds: data.processing_time_seconds,
|
||||
status: 'COMPLETED'
|
||||
};
|
||||
|
||||
IncognitoStorage.saveIncognitoRecording(incognitoData);
|
||||
incognitoRecording.value = incognitoData;
|
||||
|
||||
// Clear IndexedDB session
|
||||
try {
|
||||
await RecordingDB.clearRecordingSession();
|
||||
} catch (dbError) {
|
||||
console.warn('[Recording] Failed to clear IndexedDB session:', dbError);
|
||||
}
|
||||
|
||||
// Clear recording state (must await so currentView='upload' completes
|
||||
// before we override it with 'detail', otherwise the deferred
|
||||
// currentView='upload' fires after 'detail' and the view watcher
|
||||
// clears incognito data thinking we navigated away)
|
||||
await discardRecording();
|
||||
|
||||
processingProgress.value = 100;
|
||||
processingMessage.value = 'Incognito recording ready!';
|
||||
|
||||
// Auto-select the incognito recording and switch to detail view
|
||||
selectedRecording.value = incognitoData;
|
||||
currentView.value = 'detail';
|
||||
|
||||
// Reset incognito mode toggle
|
||||
incognitoMode.value = false;
|
||||
|
||||
// Show toast
|
||||
showToast('Incognito recording processed - data will be lost when tab closes', 'fa-user-secret');
|
||||
|
||||
console.log('[Incognito] Recording processing complete');
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Incognito] Recording processing failed:', error);
|
||||
setGlobalError(`Incognito processing failed: ${error.message}`);
|
||||
} finally {
|
||||
incognitoProcessing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Discard recording
|
||||
const discardRecording = async () => {
|
||||
if (audioBlobURL.value) {
|
||||
URL.revokeObjectURL(audioBlobURL.value);
|
||||
}
|
||||
audioBlobURL.value = null;
|
||||
audioChunks.value = [];
|
||||
isRecording.value = false;
|
||||
recordingTime.value = 0;
|
||||
if (recordingInterval.value) clearInterval(recordingInterval.value);
|
||||
recordingNotes.value = '';
|
||||
selectedTagIds.value = [];
|
||||
asrLanguage.value = '';
|
||||
asrMinSpeakers.value = '';
|
||||
asrMaxSpeakers.value = '';
|
||||
|
||||
// Clear IndexedDB session
|
||||
try {
|
||||
await RecordingDB.clearRecordingSession();
|
||||
} catch (dbError) {
|
||||
console.warn('[Recording] Failed to clear IndexedDB session:', dbError);
|
||||
}
|
||||
|
||||
await releaseWakeLock();
|
||||
await hideRecordingNotification();
|
||||
|
||||
// Return to upload view
|
||||
currentView.value = 'upload';
|
||||
};
|
||||
|
||||
// Draw single visualizer
|
||||
const drawSingleVisualizer = (analyserNode, canvasElement) => {
|
||||
if (!analyserNode || !canvasElement) return;
|
||||
|
||||
const bufferLength = analyserNode.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
analyserNode.getByteFrequencyData(dataArray);
|
||||
|
||||
const canvasCtx = canvasElement.getContext('2d');
|
||||
const WIDTH = canvasElement.width;
|
||||
const HEIGHT = canvasElement.height;
|
||||
|
||||
canvasCtx.clearRect(0, 0, WIDTH, HEIGHT);
|
||||
|
||||
const barWidth = (WIDTH / bufferLength) * 1.5;
|
||||
let barHeight;
|
||||
let x = 0;
|
||||
|
||||
const buttonColor = getComputedStyle(document.documentElement).getPropertyValue('--bg-button').trim();
|
||||
const buttonHoverColor = getComputedStyle(document.documentElement).getPropertyValue('--bg-button-hover').trim();
|
||||
|
||||
const gradient = canvasCtx.createLinearGradient(0, 0, 0, HEIGHT);
|
||||
if (isDarkMode.value) {
|
||||
gradient.addColorStop(0, buttonColor);
|
||||
gradient.addColorStop(0.6, buttonHoverColor);
|
||||
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.2)');
|
||||
} else {
|
||||
gradient.addColorStop(0, buttonColor);
|
||||
gradient.addColorStop(0.5, buttonHoverColor);
|
||||
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.1)');
|
||||
}
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
barHeight = dataArray[i] / 2.5;
|
||||
canvasCtx.fillStyle = gradient;
|
||||
canvasCtx.fillRect(x, HEIGHT - barHeight, barWidth, barHeight);
|
||||
x += barWidth + 2;
|
||||
}
|
||||
};
|
||||
|
||||
// Draw visualizers
|
||||
const drawVisualizers = () => {
|
||||
if (!isRecording.value) {
|
||||
if (animationFrameId.value) {
|
||||
cancelAnimationFrame(animationFrameId.value);
|
||||
animationFrameId.value = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
animationFrameId.value = requestAnimationFrame(drawVisualizers);
|
||||
|
||||
if (recordingMode.value === 'both') {
|
||||
drawSingleVisualizer(micAnalyser.value, micVisualizer.value);
|
||||
drawSingleVisualizer(systemAnalyser.value, systemVisualizer.value);
|
||||
} else {
|
||||
drawSingleVisualizer(analyser.value, visualizer.value);
|
||||
}
|
||||
};
|
||||
|
||||
// Update file size estimate
|
||||
const updateFileSizeEstimate = () => {
|
||||
if (!isRecording.value || !audioChunks.value.length) return;
|
||||
|
||||
const totalSize = audioChunks.value.reduce((sum, chunk) => sum + chunk.size, 0);
|
||||
estimatedFileSize.value = totalSize;
|
||||
|
||||
if (recordingTime.value > 0) {
|
||||
actualBitrate.value = (totalSize * 8) / recordingTime.value;
|
||||
}
|
||||
|
||||
// Check for size warning
|
||||
const sizeMB = totalSize / (1024 * 1024);
|
||||
const warningThresholdMB = maxRecordingMB.value * 0.8;
|
||||
|
||||
if (sizeMB > warningThresholdMB && !fileSizeWarningShown.value) {
|
||||
fileSizeWarningShown.value = true;
|
||||
showToast(
|
||||
`Recording size is ${formatFileSize(totalSize)}. Consider stopping soon.`,
|
||||
'fa-exclamation-triangle',
|
||||
5000
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-stop if max size reached
|
||||
if (sizeMB > maxRecordingMB.value) {
|
||||
stopRecording();
|
||||
showToast(
|
||||
`Recording automatically stopped at ${formatFileSize(totalSize)}`,
|
||||
'fa-stop-circle',
|
||||
7000
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Start size monitoring
|
||||
const startSizeMonitoring = () => {
|
||||
if (sizeCheckInterval.value) {
|
||||
clearInterval(sizeCheckInterval.value);
|
||||
}
|
||||
sizeCheckInterval.value = setInterval(updateFileSizeEstimate, 2000);
|
||||
};
|
||||
|
||||
// Stop size monitoring
|
||||
const stopSizeMonitoring = () => {
|
||||
if (sizeCheckInterval.value) {
|
||||
clearInterval(sizeCheckInterval.value);
|
||||
sizeCheckInterval.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if there's an unsaved recording
|
||||
const hasUnsavedRecording = () => {
|
||||
return isRecording.value || audioBlobURL.value;
|
||||
};
|
||||
|
||||
// Recover recording from IndexedDB
|
||||
const recoverRecordingFromDB = async () => {
|
||||
try {
|
||||
const recovered = await RecordingDB.recoverRecording();
|
||||
if (!recovered) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Restore chunks
|
||||
audioChunks.value = recovered.chunks;
|
||||
|
||||
// Create blob URL
|
||||
const blob = new Blob(recovered.chunks, { type: recovered.metadata.mimeType });
|
||||
audioBlobURL.value = URL.createObjectURL(blob);
|
||||
|
||||
// Restore metadata
|
||||
recordingMode.value = recovered.metadata.mode;
|
||||
recordingNotes.value = recovered.metadata.notes;
|
||||
selectedTagIds.value = recovered.metadata.tags;
|
||||
recordingTime.value = recovered.metadata.duration;
|
||||
|
||||
if (recovered.metadata.asrOptions) {
|
||||
asrLanguage.value = recovered.metadata.asrOptions.language || '';
|
||||
asrMinSpeakers.value = recovered.metadata.asrOptions.min_speakers || '';
|
||||
asrMaxSpeakers.value = recovered.metadata.asrOptions.max_speakers || '';
|
||||
}
|
||||
|
||||
console.log('[Recording] Successfully recovered recording from IndexedDB');
|
||||
return recovered.metadata;
|
||||
} catch (error) {
|
||||
console.error('[Recording] Failed to recover recording:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// No initialization needed - system audio detection is handled by computed property
|
||||
const initializeAudio = async () => {
|
||||
// Placeholder for future initialization if needed
|
||||
};
|
||||
|
||||
return {
|
||||
startRecording,
|
||||
stopRecording,
|
||||
discardRecording,
|
||||
uploadRecordedAudio,
|
||||
uploadRecordedAudioIncognito,
|
||||
acceptRecordingDisclaimer,
|
||||
cancelRecordingDisclaimer,
|
||||
updateFileSizeEstimate,
|
||||
startSizeMonitoring,
|
||||
stopSizeMonitoring,
|
||||
drawVisualizers,
|
||||
drawSingleVisualizer,
|
||||
handleVisibilityChange,
|
||||
hasUnsavedRecording,
|
||||
acquireWakeLock,
|
||||
releaseWakeLock,
|
||||
initializeAudio,
|
||||
recoverRecordingFromDB,
|
||||
checkForRecoverableRecording: RecordingDB.checkForRecoverableRecording,
|
||||
clearRecordingSession: RecordingDB.clearRecordingSession
|
||||
};
|
||||
}
|
||||
338
static/js/modules/composables/audioPlayer.js
Normal file
338
static/js/modules/composables/audioPlayer.js
Normal file
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Audio Player Composable
|
||||
*
|
||||
* Centralized audio playback functionality for consistent behavior across the app.
|
||||
* This module handles:
|
||||
* - Playback state (playing, paused, loading)
|
||||
* - Time tracking (current time, duration)
|
||||
* - Volume/mute control
|
||||
* - Seeking with progress bar support
|
||||
* - Server-side duration support (for formats like WebM that don't report duration)
|
||||
*
|
||||
* Usage:
|
||||
* const player = useAudioPlayer(ref, computed);
|
||||
* // In template: @loadedmetadata="player.handleLoadedMetadata"
|
||||
* // When recording changes: player.setServerDuration(recording.audio_duration)
|
||||
*/
|
||||
|
||||
export function useAudioPlayer(ref, computed) {
|
||||
// --- State ---
|
||||
const isPlaying = ref(false);
|
||||
const currentTime = ref(0);
|
||||
const duration = ref(0);
|
||||
const isMuted = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const volume = ref(1.0);
|
||||
|
||||
// Progress bar drag state
|
||||
const isDragging = ref(false);
|
||||
const dragPreviewPercent = ref(0);
|
||||
|
||||
// Track if we have a reliable server-side duration
|
||||
let hasServerDuration = false;
|
||||
|
||||
// --- Computed ---
|
||||
const progressPercent = computed(() => {
|
||||
// Use preview position while dragging for smooth UI
|
||||
if (isDragging.value) {
|
||||
return dragPreviewPercent.value;
|
||||
}
|
||||
if (!duration.value) return 0;
|
||||
return (currentTime.value / duration.value) * 100;
|
||||
});
|
||||
|
||||
// Preview time display while dragging
|
||||
const displayCurrentTime = computed(() => {
|
||||
if (isDragging.value && duration.value) {
|
||||
return (dragPreviewPercent.value / 100) * duration.value;
|
||||
}
|
||||
return currentTime.value;
|
||||
});
|
||||
|
||||
// --- Duration Management ---
|
||||
|
||||
/**
|
||||
* Set duration from server-side ffprobe value.
|
||||
* This is more reliable than browser metadata for some formats (WebM, etc.)
|
||||
*/
|
||||
const setServerDuration = (serverDuration) => {
|
||||
if (serverDuration && isFinite(serverDuration) && serverDuration > 0) {
|
||||
duration.value = serverDuration;
|
||||
hasServerDuration = true;
|
||||
} else {
|
||||
hasServerDuration = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Try to set duration from browser, only if we don't have a server-side value.
|
||||
* Browser duration can be Infinity for some WebM files.
|
||||
*/
|
||||
const trySetBrowserDuration = (browserDuration) => {
|
||||
if (hasServerDuration) {
|
||||
// Don't overwrite reliable server-side duration
|
||||
return;
|
||||
}
|
||||
if (browserDuration && isFinite(browserDuration) && browserDuration > 0) {
|
||||
duration.value = browserDuration;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Event Handlers ---
|
||||
|
||||
const handlePlayPause = (event) => {
|
||||
isPlaying.value = !event.target.paused;
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = (event) => {
|
||||
trySetBrowserDuration(event.target.duration);
|
||||
isLoading.value = false;
|
||||
};
|
||||
|
||||
const handleDurationChange = (event) => {
|
||||
// WebM and some formats may initially report Infinity duration
|
||||
// This handler catches when the actual duration becomes available
|
||||
trySetBrowserDuration(event.target.duration);
|
||||
};
|
||||
|
||||
const handleTimeUpdate = (event) => {
|
||||
currentTime.value = event.target.currentTime;
|
||||
|
||||
// Fallback: if duration wasn't set yet, try to get it now
|
||||
if (!duration.value || duration.value === 0) {
|
||||
trySetBrowserDuration(event.target.duration);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnded = () => {
|
||||
isPlaying.value = false;
|
||||
currentTime.value = 0;
|
||||
};
|
||||
|
||||
const handleWaiting = () => {
|
||||
isLoading.value = true;
|
||||
};
|
||||
|
||||
const handleCanPlay = (event) => {
|
||||
isLoading.value = false;
|
||||
|
||||
// Fallback: try to get duration if not set yet
|
||||
if (!duration.value || duration.value === 0) {
|
||||
trySetBrowserDuration(event.target.duration);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeChange = (event) => {
|
||||
volume.value = event.target.volume;
|
||||
isMuted.value = event.target.muted;
|
||||
};
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
/**
|
||||
* Get the audio element. Override this for custom element selection.
|
||||
*/
|
||||
let getAudioElement = () => {
|
||||
return document.querySelector('audio[ref="audioPlayerElement"]') ||
|
||||
document.querySelector('video[ref="audioPlayerElement"]') ||
|
||||
document.querySelector('audio') ||
|
||||
document.querySelector('video');
|
||||
};
|
||||
|
||||
/**
|
||||
* Set custom audio element getter.
|
||||
*/
|
||||
const setAudioElementGetter = (getter) => {
|
||||
getAudioElement = getter;
|
||||
};
|
||||
|
||||
const play = () => {
|
||||
const audio = getAudioElement();
|
||||
if (audio) {
|
||||
audio.play().catch(err => console.warn('Play failed:', err));
|
||||
}
|
||||
};
|
||||
|
||||
const pause = () => {
|
||||
const audio = getAudioElement();
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlayback = () => {
|
||||
const audio = getAudioElement();
|
||||
if (!audio) return;
|
||||
|
||||
if (audio.paused) {
|
||||
audio.play().catch(err => console.warn('Play failed:', err));
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const seekTo = (time) => {
|
||||
const audio = getAudioElement();
|
||||
if (!audio || !isFinite(time)) return;
|
||||
|
||||
const maxTime = isFinite(audio.duration) ? audio.duration : time;
|
||||
audio.currentTime = Math.max(0, Math.min(time, maxTime));
|
||||
};
|
||||
|
||||
const seekByPercent = (percent) => {
|
||||
const audio = getAudioElement();
|
||||
if (!audio || !duration.value || !isFinite(duration.value)) return;
|
||||
|
||||
const time = (percent / 100) * duration.value;
|
||||
audio.currentTime = time;
|
||||
};
|
||||
|
||||
const setVolume = (value) => {
|
||||
const audio = getAudioElement();
|
||||
if (audio) {
|
||||
audio.volume = Math.max(0, Math.min(1, value));
|
||||
volume.value = audio.volume;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const audio = getAudioElement();
|
||||
if (!audio) return;
|
||||
|
||||
if (audio.muted || audio.volume === 0) {
|
||||
audio.muted = false;
|
||||
if (audio.volume === 0) {
|
||||
audio.volume = 0.5;
|
||||
}
|
||||
isMuted.value = false;
|
||||
} else {
|
||||
audio.muted = true;
|
||||
isMuted.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Progress Bar Drag Support ---
|
||||
|
||||
const startProgressDrag = (event) => {
|
||||
const bar = 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));
|
||||
};
|
||||
|
||||
// Start dragging - show preview
|
||||
isDragging.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 = () => {
|
||||
document.removeEventListener(isTouch ? 'touchmove' : 'mousemove', onMove);
|
||||
document.removeEventListener(isTouch ? 'touchend' : 'mouseup', onUp);
|
||||
// Seek to final position on release
|
||||
seekByPercent(dragPreviewPercent.value);
|
||||
isDragging.value = false;
|
||||
};
|
||||
|
||||
document.addEventListener(isTouch ? 'touchmove' : 'mousemove', onMove, { passive: false });
|
||||
document.addEventListener(isTouch ? 'touchend' : 'mouseup', onUp);
|
||||
};
|
||||
|
||||
// --- Utility ---
|
||||
|
||||
const formatTime = (seconds) => {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
if (hours > 0) {
|
||||
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset all player state (call when changing recordings)
|
||||
*/
|
||||
const reset = () => {
|
||||
isPlaying.value = false;
|
||||
currentTime.value = 0;
|
||||
duration.value = 0;
|
||||
isMuted.value = false;
|
||||
isLoading.value = false;
|
||||
hasServerDuration = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize with a recording object.
|
||||
* Automatically uses server-side duration if available.
|
||||
*/
|
||||
const initWithRecording = (recording) => {
|
||||
reset();
|
||||
if (recording && recording.audio_duration) {
|
||||
setServerDuration(recording.audio_duration);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
isMuted,
|
||||
isLoading,
|
||||
volume,
|
||||
isDragging,
|
||||
dragPreviewPercent,
|
||||
|
||||
// Computed
|
||||
progressPercent,
|
||||
displayCurrentTime,
|
||||
|
||||
// Duration management
|
||||
setServerDuration,
|
||||
trySetBrowserDuration,
|
||||
|
||||
// Event handlers (wire these to <audio> element)
|
||||
handlePlayPause,
|
||||
handleLoadedMetadata,
|
||||
handleDurationChange,
|
||||
handleTimeUpdate,
|
||||
handleEnded,
|
||||
handleWaiting,
|
||||
handleCanPlay,
|
||||
handleVolumeChange,
|
||||
|
||||
// Actions
|
||||
play,
|
||||
pause,
|
||||
togglePlayback,
|
||||
seekTo,
|
||||
seekByPercent,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
startProgressDrag,
|
||||
setAudioElementGetter,
|
||||
|
||||
// Utility
|
||||
formatTime,
|
||||
reset,
|
||||
initWithRecording
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standalone audio player instance.
|
||||
* Use this for pages that don't have Vue's ref/computed (like share.html).
|
||||
*/
|
||||
export function createStandalonePlayer(Vue) {
|
||||
const { ref, computed } = Vue;
|
||||
return useAudioPlayer(ref, computed);
|
||||
}
|
||||
475
static/js/modules/composables/bulk-operations.js
Normal file
475
static/js/modules/composables/bulk-operations.js
Normal file
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* Bulk Operations Composable
|
||||
* Handles bulk API operations for multiple recordings
|
||||
*/
|
||||
|
||||
const { ref, computed } = Vue;
|
||||
|
||||
export function useBulkOperations({
|
||||
selectedRecordingIds,
|
||||
selectedRecordings,
|
||||
recordings,
|
||||
selectedRecording,
|
||||
bulkActionInProgress,
|
||||
availableTags,
|
||||
availableFolders,
|
||||
showToast,
|
||||
setGlobalError,
|
||||
startReprocessingPoll
|
||||
}) {
|
||||
// Modal state
|
||||
const showBulkDeleteModal = ref(false);
|
||||
const showBulkTagModal = ref(false);
|
||||
const showBulkReprocessModal = ref(false);
|
||||
const showBulkFolderModal = ref(false);
|
||||
const bulkTagAction = ref('add'); // 'add' or 'remove'
|
||||
const bulkTagSelectedId = ref('');
|
||||
const bulkReprocessType = ref('summary'); // 'transcription' or 'summary'
|
||||
|
||||
// Get CSRF token
|
||||
const getCsrfToken = () => {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
};
|
||||
|
||||
// Helper to get selected IDs as array
|
||||
const getSelectedIds = () => {
|
||||
return Array.from(selectedRecordingIds.value);
|
||||
};
|
||||
|
||||
// =========================================
|
||||
// Bulk Delete
|
||||
// =========================================
|
||||
|
||||
const openBulkDeleteModal = () => {
|
||||
showBulkDeleteModal.value = true;
|
||||
};
|
||||
|
||||
const closeBulkDeleteModal = () => {
|
||||
showBulkDeleteModal.value = false;
|
||||
};
|
||||
|
||||
const executeBulkDelete = async () => {
|
||||
const ids = getSelectedIds();
|
||||
if (ids.length === 0) return;
|
||||
|
||||
bulkActionInProgress.value = true;
|
||||
closeBulkDeleteModal();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/recordings/bulk', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({ recording_ids: ids })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to delete recordings');
|
||||
}
|
||||
|
||||
// Remove deleted recordings from local state
|
||||
const deletedIds = new Set(data.deleted_ids || ids);
|
||||
recordings.value = recordings.value.filter(r => !deletedIds.has(r.id));
|
||||
|
||||
// Clear selected recording if it was deleted
|
||||
if (selectedRecording.value && deletedIds.has(selectedRecording.value.id)) {
|
||||
selectedRecording.value = null;
|
||||
}
|
||||
|
||||
// Remove deleted IDs from selection
|
||||
deletedIds.forEach(id => selectedRecordingIds.value.delete(id));
|
||||
|
||||
const count = deletedIds.size;
|
||||
showToast(`${count} recording${count !== 1 ? 's' : ''} deleted`, 'fa-trash', 3000, 'success');
|
||||
} catch (error) {
|
||||
console.error('Bulk delete error:', error);
|
||||
setGlobalError(`Failed to delete recordings: ${error.message}`);
|
||||
} finally {
|
||||
bulkActionInProgress.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// =========================================
|
||||
// Bulk Tag Operations
|
||||
// =========================================
|
||||
|
||||
const openBulkTagModal = (action = 'add') => {
|
||||
bulkTagAction.value = action;
|
||||
bulkTagSelectedId.value = '';
|
||||
showBulkTagModal.value = true;
|
||||
};
|
||||
|
||||
const closeBulkTagModal = () => {
|
||||
showBulkTagModal.value = false;
|
||||
bulkTagSelectedId.value = '';
|
||||
};
|
||||
|
||||
const executeBulkTag = async () => {
|
||||
const ids = getSelectedIds();
|
||||
const tagId = bulkTagSelectedId.value;
|
||||
const action = bulkTagAction.value;
|
||||
|
||||
// Validate before making API call
|
||||
if (ids.length === 0) {
|
||||
console.warn('No recordings selected for bulk tag operation');
|
||||
return;
|
||||
}
|
||||
if (!tagId && tagId !== 0) {
|
||||
console.warn('No tag selected for bulk tag operation');
|
||||
return;
|
||||
}
|
||||
|
||||
bulkActionInProgress.value = true;
|
||||
closeBulkTagModal();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/recordings/bulk-tags', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recording_ids: ids,
|
||||
tag_id: parseInt(tagId),
|
||||
action: action
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || `Failed to ${action} tag`);
|
||||
}
|
||||
|
||||
// Update local state
|
||||
const tag = availableTags.value.find(t => t.id == tagId);
|
||||
if (tag) {
|
||||
const affectedIds = new Set(data.affected_ids || ids);
|
||||
recordings.value.forEach(recording => {
|
||||
if (affectedIds.has(recording.id)) {
|
||||
if (!recording.tags) recording.tags = [];
|
||||
|
||||
if (action === 'add') {
|
||||
// Add tag if not already present
|
||||
if (!recording.tags.find(t => t.id === tag.id)) {
|
||||
recording.tags.push(tag);
|
||||
}
|
||||
} else {
|
||||
// Remove tag
|
||||
recording.tags = recording.tags.filter(t => t.id !== tag.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update selected recording if affected
|
||||
if (selectedRecording.value && affectedIds.has(selectedRecording.value.id)) {
|
||||
if (!selectedRecording.value.tags) selectedRecording.value.tags = [];
|
||||
|
||||
if (action === 'add') {
|
||||
if (!selectedRecording.value.tags.find(t => t.id === tag.id)) {
|
||||
selectedRecording.value.tags.push(tag);
|
||||
}
|
||||
} else {
|
||||
selectedRecording.value.tags = selectedRecording.value.tags.filter(t => t.id !== tag.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const count = data.affected_ids?.length || ids.length;
|
||||
const actionText = action === 'add' ? 'added to' : 'removed from';
|
||||
showToast(`Tag ${actionText} ${count} recording${count !== 1 ? 's' : ''}`, 'fa-tags', 3000, 'success');
|
||||
} catch (error) {
|
||||
console.error('Bulk tag error:', error);
|
||||
setGlobalError(`Failed to ${action} tag: ${error.message}`);
|
||||
} finally {
|
||||
bulkActionInProgress.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// =========================================
|
||||
// Bulk Reprocess
|
||||
// =========================================
|
||||
|
||||
const openBulkReprocessModal = () => {
|
||||
bulkReprocessType.value = 'summary';
|
||||
showBulkReprocessModal.value = true;
|
||||
};
|
||||
|
||||
const closeBulkReprocessModal = () => {
|
||||
showBulkReprocessModal.value = false;
|
||||
};
|
||||
|
||||
const executeBulkReprocess = async () => {
|
||||
const ids = getSelectedIds();
|
||||
if (ids.length === 0) return;
|
||||
|
||||
bulkActionInProgress.value = true;
|
||||
closeBulkReprocessModal();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/recordings/bulk-reprocess', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recording_ids: ids,
|
||||
type: bulkReprocessType.value
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to queue reprocessing');
|
||||
}
|
||||
|
||||
// Update status for queued recordings
|
||||
const queuedIds = new Set(data.queued_ids || ids);
|
||||
const newStatus = bulkReprocessType.value === 'transcription' ? 'PROCESSING' : 'SUMMARIZING';
|
||||
|
||||
recordings.value.forEach(recording => {
|
||||
if (queuedIds.has(recording.id)) {
|
||||
recording.status = newStatus;
|
||||
// Start polling for each
|
||||
if (startReprocessingPoll) {
|
||||
startReprocessingPoll(recording.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (selectedRecording.value && queuedIds.has(selectedRecording.value.id)) {
|
||||
selectedRecording.value.status = newStatus;
|
||||
}
|
||||
|
||||
const count = queuedIds.size;
|
||||
const typeText = bulkReprocessType.value === 'transcription' ? 'Transcription' : 'Summary';
|
||||
showToast(`${typeText} reprocessing queued for ${count} recording${count !== 1 ? 's' : ''}`, 'fa-sync-alt', 3000, 'success');
|
||||
} catch (error) {
|
||||
console.error('Bulk reprocess error:', error);
|
||||
setGlobalError(`Failed to queue reprocessing: ${error.message}`);
|
||||
} finally {
|
||||
bulkActionInProgress.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// =========================================
|
||||
// Bulk Toggle (Inbox/Highlight)
|
||||
// =========================================
|
||||
|
||||
const bulkToggleInbox = async (value = null) => {
|
||||
const ids = getSelectedIds();
|
||||
if (ids.length === 0) return;
|
||||
|
||||
// If no value specified, toggle based on majority
|
||||
if (value === null) {
|
||||
const inboxCount = selectedRecordings.value.filter(r => r.is_inbox).length;
|
||||
value = inboxCount < ids.length / 2;
|
||||
}
|
||||
|
||||
bulkActionInProgress.value = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/recordings/bulk-toggle', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recording_ids: ids,
|
||||
field: 'inbox',
|
||||
value: value
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to update inbox status');
|
||||
}
|
||||
|
||||
// Update local state
|
||||
const affectedIds = new Set(data.affected_ids || ids);
|
||||
recordings.value.forEach(recording => {
|
||||
if (affectedIds.has(recording.id)) {
|
||||
recording.is_inbox = value;
|
||||
}
|
||||
});
|
||||
|
||||
if (selectedRecording.value && affectedIds.has(selectedRecording.value.id)) {
|
||||
selectedRecording.value.is_inbox = value;
|
||||
}
|
||||
|
||||
const count = affectedIds.size;
|
||||
const actionText = value ? 'added to' : 'removed from';
|
||||
showToast(`${count} recording${count !== 1 ? 's' : ''} ${actionText} inbox`, 'fa-inbox', 3000, 'success');
|
||||
} catch (error) {
|
||||
console.error('Bulk toggle inbox error:', error);
|
||||
setGlobalError(`Failed to update inbox status: ${error.message}`);
|
||||
} finally {
|
||||
bulkActionInProgress.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const bulkToggleHighlight = async (value = null) => {
|
||||
const ids = getSelectedIds();
|
||||
if (ids.length === 0) return;
|
||||
|
||||
// If no value specified, toggle based on majority
|
||||
if (value === null) {
|
||||
const highlightCount = selectedRecordings.value.filter(r => r.is_highlighted).length;
|
||||
value = highlightCount < ids.length / 2;
|
||||
}
|
||||
|
||||
bulkActionInProgress.value = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/recordings/bulk-toggle', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recording_ids: ids,
|
||||
field: 'highlight',
|
||||
value: value
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to update highlight status');
|
||||
}
|
||||
|
||||
// Update local state
|
||||
const affectedIds = new Set(data.affected_ids || ids);
|
||||
recordings.value.forEach(recording => {
|
||||
if (affectedIds.has(recording.id)) {
|
||||
recording.is_highlighted = value;
|
||||
}
|
||||
});
|
||||
|
||||
if (selectedRecording.value && affectedIds.has(selectedRecording.value.id)) {
|
||||
selectedRecording.value.is_highlighted = value;
|
||||
}
|
||||
|
||||
const count = affectedIds.size;
|
||||
const actionText = value ? 'highlighted' : 'unhighlighted';
|
||||
showToast(`${count} recording${count !== 1 ? 's' : ''} ${actionText}`, 'fa-star', 3000, 'success');
|
||||
} catch (error) {
|
||||
console.error('Bulk toggle highlight error:', error);
|
||||
setGlobalError(`Failed to update highlight status: ${error.message}`);
|
||||
} finally {
|
||||
bulkActionInProgress.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// =========================================
|
||||
// Bulk Folder Assignment
|
||||
// =========================================
|
||||
|
||||
const bulkAssignFolder = async (folderId) => {
|
||||
const ids = getSelectedIds();
|
||||
if (ids.length === 0) return;
|
||||
|
||||
bulkActionInProgress.value = true;
|
||||
showBulkFolderModal.value = false;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/recordings/bulk/folder', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recording_ids: ids,
|
||||
folder_id: folderId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to update folders');
|
||||
}
|
||||
|
||||
// Update local state
|
||||
const folder = folderId ? availableFolders.value.find(f => f.id === folderId) : null;
|
||||
recordings.value.forEach(recording => {
|
||||
if (ids.includes(recording.id)) {
|
||||
recording.folder_id = folderId;
|
||||
recording.folder = folder;
|
||||
}
|
||||
});
|
||||
|
||||
// Update selected recording if affected
|
||||
if (selectedRecording.value && ids.includes(selectedRecording.value.id)) {
|
||||
selectedRecording.value.folder_id = folderId;
|
||||
selectedRecording.value.folder = folder;
|
||||
}
|
||||
|
||||
// Update folder recording counts
|
||||
if (availableFolders.value) {
|
||||
availableFolders.value.forEach(f => {
|
||||
const count = recordings.value.filter(r => r.folder_id === f.id).length;
|
||||
f.recording_count = count;
|
||||
});
|
||||
}
|
||||
|
||||
const count = data.updated_count || ids.length;
|
||||
if (folderId) {
|
||||
showToast(`${count} recording${count !== 1 ? 's' : ''} moved to "${folder?.name || 'folder'}"`, 'fa-folder', 3000, 'success');
|
||||
} else {
|
||||
showToast(`${count} recording${count !== 1 ? 's' : ''} removed from folder`, 'fa-folder-minus', 3000, 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Bulk folder assignment error:', error);
|
||||
setGlobalError(`Failed to update folders: ${error.message}`);
|
||||
} finally {
|
||||
bulkActionInProgress.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// Modal state
|
||||
showBulkDeleteModal,
|
||||
showBulkTagModal,
|
||||
showBulkReprocessModal,
|
||||
showBulkFolderModal,
|
||||
bulkTagAction,
|
||||
bulkTagSelectedId,
|
||||
bulkReprocessType,
|
||||
|
||||
// Bulk Delete
|
||||
openBulkDeleteModal,
|
||||
closeBulkDeleteModal,
|
||||
executeBulkDelete,
|
||||
|
||||
// Bulk Tag
|
||||
openBulkTagModal,
|
||||
closeBulkTagModal,
|
||||
executeBulkTag,
|
||||
|
||||
// Bulk Reprocess
|
||||
openBulkReprocessModal,
|
||||
closeBulkReprocessModal,
|
||||
executeBulkReprocess,
|
||||
|
||||
// Bulk Toggle
|
||||
bulkToggleInbox,
|
||||
bulkToggleHighlight,
|
||||
|
||||
// Bulk Folder
|
||||
bulkAssignFolder
|
||||
};
|
||||
}
|
||||
111
static/js/modules/composables/bulk-selection.js
Normal file
111
static/js/modules/composables/bulk-selection.js
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Bulk Selection Composable
|
||||
* Handles multi-select functionality for recordings
|
||||
*/
|
||||
|
||||
const { computed } = Vue;
|
||||
|
||||
export function useBulkSelection({
|
||||
selectionMode,
|
||||
selectedRecordingIds,
|
||||
recordings,
|
||||
selectedRecording,
|
||||
currentView
|
||||
}) {
|
||||
// Computed
|
||||
const selectedCount = computed(() => selectedRecordingIds.value.size);
|
||||
|
||||
const selectedRecordings = computed(() => {
|
||||
return recordings.value.filter(r => selectedRecordingIds.value.has(r.id));
|
||||
});
|
||||
|
||||
const allVisibleSelected = computed(() => {
|
||||
if (recordings.value.length === 0) return false;
|
||||
return recordings.value.every(r => selectedRecordingIds.value.has(r.id));
|
||||
});
|
||||
|
||||
const isSelected = (id) => {
|
||||
return selectedRecordingIds.value.has(id);
|
||||
};
|
||||
|
||||
// Methods
|
||||
const enterSelectionMode = () => {
|
||||
selectionMode.value = true;
|
||||
selectedRecordingIds.value = new Set();
|
||||
};
|
||||
|
||||
const exitSelectionMode = () => {
|
||||
selectionMode.value = false;
|
||||
selectedRecordingIds.value = new Set();
|
||||
};
|
||||
|
||||
const toggleSelection = (id) => {
|
||||
const newSet = new Set(selectedRecordingIds.value);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
selectedRecordingIds.value = newSet;
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
const newSet = new Set();
|
||||
recordings.value.forEach(r => newSet.add(r.id));
|
||||
selectedRecordingIds.value = newSet;
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
selectedRecordingIds.value = new Set();
|
||||
};
|
||||
|
||||
// Keyboard handler for selection mode
|
||||
const handleSelectionKeyboard = (event) => {
|
||||
if (!selectionMode.value) return;
|
||||
|
||||
// Escape to exit selection mode
|
||||
if (event.key === 'Escape') {
|
||||
exitSelectionMode();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + A to select all
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'a') {
|
||||
// Only if not in an input field
|
||||
if (document.activeElement.tagName !== 'INPUT' &&
|
||||
document.activeElement.tagName !== 'TEXTAREA' &&
|
||||
!document.activeElement.isContentEditable) {
|
||||
event.preventDefault();
|
||||
selectAll();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize keyboard listener
|
||||
const initSelectionKeyboardListeners = () => {
|
||||
document.addEventListener('keydown', handleSelectionKeyboard);
|
||||
};
|
||||
|
||||
const cleanupSelectionKeyboardListeners = () => {
|
||||
document.removeEventListener('keydown', handleSelectionKeyboard);
|
||||
};
|
||||
|
||||
return {
|
||||
// Computed
|
||||
selectedCount,
|
||||
selectedRecordings,
|
||||
allVisibleSelected,
|
||||
|
||||
// Methods
|
||||
isSelected,
|
||||
enterSelectionMode,
|
||||
exitSelectionMode,
|
||||
toggleSelection,
|
||||
selectAll,
|
||||
clearSelection,
|
||||
|
||||
// Keyboard
|
||||
initSelectionKeyboardListeners,
|
||||
cleanupSelectionKeyboardListeners
|
||||
};
|
||||
}
|
||||
380
static/js/modules/composables/chat.js
Normal file
380
static/js/modules/composables/chat.js
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Chat composable
|
||||
* Handles AI chat functionality with streaming responses
|
||||
*/
|
||||
|
||||
export function useChat(state, utils) {
|
||||
const {
|
||||
showChat, isChatMaximized, chatMessages, chatInput,
|
||||
isChatLoading, chatMessagesRef, chatInputRef, selectedRecording, csrfToken
|
||||
} = state;
|
||||
|
||||
const { showToast, setGlobalError, onChatComplete, t } = utils;
|
||||
|
||||
// Helper function to check if chat is scrolled to bottom (within bottom 5%)
|
||||
const isChatScrolledToBottom = () => {
|
||||
if (!chatMessagesRef.value) return true;
|
||||
const { scrollTop, scrollHeight, clientHeight } = chatMessagesRef.value;
|
||||
const scrollableHeight = scrollHeight - clientHeight;
|
||||
if (scrollableHeight <= 0) return true;
|
||||
const scrollPercentage = scrollTop / scrollableHeight;
|
||||
return scrollPercentage >= 0.95;
|
||||
};
|
||||
|
||||
// Helper function to scroll chat to bottom
|
||||
const scrollChatToBottom = () => {
|
||||
if (chatMessagesRef.value) {
|
||||
requestAnimationFrame(() => {
|
||||
if (chatMessagesRef.value) {
|
||||
chatMessagesRef.value.scrollTop = chatMessagesRef.value.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const focusChatInput = () => {
|
||||
Vue.nextTick(() => {
|
||||
if (chatInputRef.value) {
|
||||
chatInputRef.value.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const toggleChatMaximize = () => {
|
||||
if (isChatMaximized.value) {
|
||||
isChatMaximized.value = false;
|
||||
} else {
|
||||
isChatMaximized.value = true;
|
||||
if (!showChat.value) {
|
||||
showChat.value = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sendChatMessage = async () => {
|
||||
if (!chatInput.value.trim() || isChatLoading.value || !selectedRecording.value || selectedRecording.value.status !== 'COMPLETED') {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = chatInput.value.trim();
|
||||
|
||||
if (!Array.isArray(chatMessages.value)) {
|
||||
chatMessages.value = [];
|
||||
}
|
||||
|
||||
chatMessages.value.push({ role: 'user', content: message });
|
||||
chatInput.value = '';
|
||||
isChatLoading.value = true;
|
||||
focusChatInput();
|
||||
|
||||
await Vue.nextTick();
|
||||
scrollChatToBottom();
|
||||
|
||||
let assistantMessage = null;
|
||||
|
||||
try {
|
||||
const messageHistory = chatMessages.value
|
||||
.slice(0, -1)
|
||||
.map(msg => ({ role: msg.role, content: msg.content }));
|
||||
|
||||
// Check if this is an incognito recording
|
||||
const isIncognito = selectedRecording.value.incognito === true;
|
||||
let response;
|
||||
|
||||
if (isIncognito) {
|
||||
// Use incognito chat endpoint - pass transcription directly
|
||||
response = await fetch('/api/recordings/incognito/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
transcription: selectedRecording.value.transcription,
|
||||
participants: selectedRecording.value.participants || '',
|
||||
notes: selectedRecording.value.notes || '',
|
||||
message: message,
|
||||
message_history: messageHistory
|
||||
})
|
||||
});
|
||||
} else {
|
||||
// Use regular chat endpoint
|
||||
response = await fetch('/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recording_id: selectedRecording.value.id,
|
||||
message: message,
|
||||
message_history: messageHistory
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to get chat response');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
const processStream = async () => {
|
||||
let isFirstChunk = true;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop();
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const jsonStr = line.substring(6);
|
||||
// Handle [DONE] marker from incognito endpoint
|
||||
if (jsonStr === '[DONE]') {
|
||||
return;
|
||||
}
|
||||
if (jsonStr) {
|
||||
try {
|
||||
const data = JSON.parse(jsonStr);
|
||||
if (data.thinking) {
|
||||
const shouldScroll = isChatScrolledToBottom();
|
||||
|
||||
if (isFirstChunk) {
|
||||
isChatLoading.value = false;
|
||||
assistantMessage = Vue.reactive({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
html: '',
|
||||
thinking: data.thinking,
|
||||
thinkingExpanded: false
|
||||
});
|
||||
chatMessages.value.push(assistantMessage);
|
||||
isFirstChunk = false;
|
||||
} else if (assistantMessage) {
|
||||
if (assistantMessage.thinking) {
|
||||
assistantMessage.thinking += '\n\n' + data.thinking;
|
||||
} else {
|
||||
assistantMessage.thinking = data.thinking;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldScroll) {
|
||||
await Vue.nextTick();
|
||||
scrollChatToBottom();
|
||||
}
|
||||
}
|
||||
// Handle both 'delta' (regular) and 'content' (incognito) formats
|
||||
const textContent = data.delta || data.content;
|
||||
if (textContent) {
|
||||
const shouldScroll = isChatScrolledToBottom();
|
||||
|
||||
if (isFirstChunk) {
|
||||
isChatLoading.value = false;
|
||||
assistantMessage = Vue.reactive({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
html: '',
|
||||
thinking: '',
|
||||
thinkingExpanded: false
|
||||
});
|
||||
chatMessages.value.push(assistantMessage);
|
||||
isFirstChunk = false;
|
||||
}
|
||||
|
||||
assistantMessage.content += textContent;
|
||||
assistantMessage.html = marked.parse(assistantMessage.content);
|
||||
|
||||
if (shouldScroll) {
|
||||
await Vue.nextTick();
|
||||
scrollChatToBottom();
|
||||
}
|
||||
}
|
||||
if (data.end_of_stream) {
|
||||
return;
|
||||
}
|
||||
if (data.error) {
|
||||
if (data.budget_exceeded) {
|
||||
throw new Error(t('adminDashboard.tokenBudgetExceeded'));
|
||||
}
|
||||
throw new Error(data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing stream data:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await processStream();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Chat Error:', error);
|
||||
if (assistantMessage) {
|
||||
assistantMessage.content = `Error: ${error.message}`;
|
||||
assistantMessage.html = `<span class="text-red-500">Error: ${error.message}</span>`;
|
||||
} else {
|
||||
chatMessages.value.push({
|
||||
role: 'assistant',
|
||||
content: `Error: ${error.message}`,
|
||||
html: `<span class="text-red-500">Error: ${error.message}</span>`
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
isChatLoading.value = false;
|
||||
await Vue.nextTick();
|
||||
if (isChatScrolledToBottom()) {
|
||||
scrollChatToBottom();
|
||||
}
|
||||
focusChatInput();
|
||||
// Refresh token budget after chat completion
|
||||
if (onChatComplete) {
|
||||
onChatComplete();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleChatKeydown = (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
if (event.ctrlKey || event.shiftKey) {
|
||||
return;
|
||||
} else {
|
||||
event.preventDefault();
|
||||
sendChatMessage();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearChat = () => {
|
||||
if (chatMessages.value.length > 0) {
|
||||
chatMessages.value = [];
|
||||
showToast(t('chat.cleared'), 'fa-broom');
|
||||
}
|
||||
};
|
||||
|
||||
const downloadChat = async () => {
|
||||
if (!selectedRecording.value || chatMessages.value.length === 0) {
|
||||
showToast(t('chat.noMessagesToDownload'), 'fa-exclamation-circle');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const csrfTokenValue = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
const response = await fetch(`/recording/${selectedRecording.value.id}/download/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfTokenValue
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages: chatMessages.value
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
showToast(error.error || t('chat.downloadFailed'), '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 = 'chat.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('chat.downloadSuccess'));
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
showToast(t('chat.downloadFailed'), 'fa-exclamation-circle');
|
||||
}
|
||||
};
|
||||
|
||||
const copyMessage = (text, event) => {
|
||||
const button = event.currentTarget;
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text)
|
||||
.then(() => {
|
||||
showToast(t('messages.copiedSuccessfully'));
|
||||
animateCopyButton(button);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Copy failed:', err);
|
||||
showToast(t('messages.copyFailed') + ': ' + err.message, 'fa-exclamation-circle');
|
||||
fallbackCopyTextToClipboard(text, button);
|
||||
});
|
||||
} else {
|
||||
fallbackCopyTextToClipboard(text, button);
|
||||
}
|
||||
};
|
||||
|
||||
const animateCopyButton = (button) => {
|
||||
button.classList.add('copy-success');
|
||||
const originalContent = button.innerHTML;
|
||||
button.innerHTML = '<i class="fas fa-check"></i>';
|
||||
setTimeout(() => {
|
||||
button.classList.remove('copy-success');
|
||||
button.innerHTML = originalContent;
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const fallbackCopyTextToClipboard = (text, button = null) => {
|
||||
try {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.left = "-999999px";
|
||||
textArea.style.top = "-999999px";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
const successful = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
if (successful) {
|
||||
showToast(t('messages.copiedSuccessfully'));
|
||||
if (button) animateCopyButton(button);
|
||||
} else {
|
||||
showToast(t('messages.copyNotSupported'), 'fa-exclamation-circle');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fallback copy failed:', err);
|
||||
showToast(t('messages.copyFailed') + ': ' + err.message, 'fa-exclamation-circle');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isChatScrolledToBottom,
|
||||
scrollChatToBottom,
|
||||
toggleChatMaximize,
|
||||
sendChatMessage,
|
||||
handleChatKeydown,
|
||||
clearChat,
|
||||
downloadChat,
|
||||
copyMessage,
|
||||
animateCopyButton,
|
||||
fallbackCopyTextToClipboard
|
||||
};
|
||||
}
|
||||
173
static/js/modules/composables/folders.js
Normal file
173
static/js/modules/composables/folders.js
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Folders Management Composable
|
||||
* Handles folder operations for recordings
|
||||
*/
|
||||
|
||||
const { computed, ref } = Vue;
|
||||
|
||||
export function useFolders({
|
||||
recordings,
|
||||
availableFolders,
|
||||
selectedRecording,
|
||||
showToast,
|
||||
setGlobalError
|
||||
}) {
|
||||
// Computed / Helpers
|
||||
const getRecordingFolder = (recording) => {
|
||||
if (!recording || !recording.folder_id) return null;
|
||||
// Try to get from recording.folder first, then lookup
|
||||
if (recording.folder) return recording.folder;
|
||||
return availableFolders.value?.find(f => f.id === recording.folder_id) || null;
|
||||
};
|
||||
|
||||
const getFolderById = (folderId) => {
|
||||
if (!folderId || !availableFolders.value) return null;
|
||||
// Use == for loose equality to handle string/number type mismatch (e.g., from localStorage)
|
||||
return availableFolders.value.find(f => f.id == folderId) || null;
|
||||
};
|
||||
|
||||
const getFolderColor = (folderId) => {
|
||||
const folder = getFolderById(folderId);
|
||||
return folder?.color || '#10B981';
|
||||
};
|
||||
|
||||
const getFolderName = (folderId) => {
|
||||
const folder = getFolderById(folderId);
|
||||
return folder?.name || 'Folder';
|
||||
};
|
||||
|
||||
const getAvailableFoldersForRecording = () => {
|
||||
if (!availableFolders.value) return [];
|
||||
return availableFolders.value;
|
||||
};
|
||||
|
||||
// Methods
|
||||
const assignFolderToRecording = async (recordingId, folderId) => {
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
const response = await fetch(`/api/recordings/${recordingId}/folder`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({ folder_id: folderId || null })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to update folder');
|
||||
}
|
||||
|
||||
const updatedRecording = await response.json();
|
||||
|
||||
// Update local recording data
|
||||
const recordingInList = recordings.value.find(r => r.id === recordingId);
|
||||
if (recordingInList) {
|
||||
recordingInList.folder_id = updatedRecording.folder_id;
|
||||
recordingInList.folder = updatedRecording.folder;
|
||||
}
|
||||
|
||||
// Update selectedRecording if it matches
|
||||
if (selectedRecording.value && selectedRecording.value.id === recordingId) {
|
||||
selectedRecording.value.folder_id = updatedRecording.folder_id;
|
||||
selectedRecording.value.folder = updatedRecording.folder;
|
||||
}
|
||||
|
||||
// Update folder recording counts
|
||||
if (availableFolders.value) {
|
||||
availableFolders.value.forEach(f => {
|
||||
const count = recordings.value.filter(r => r.folder_id === f.id).length;
|
||||
f.recording_count = count;
|
||||
});
|
||||
}
|
||||
|
||||
if (folderId) {
|
||||
const folder = availableFolders.value?.find(f => f.id === folderId);
|
||||
showToast(`Moved to folder "${folder?.name || 'Unknown'}"`, 'fa-folder', 2000, 'success');
|
||||
} else {
|
||||
showToast('Removed from folder', 'fa-folder-minus', 2000, 'success');
|
||||
}
|
||||
|
||||
return updatedRecording;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating folder:', error);
|
||||
setGlobalError(`Failed to update folder: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const removeRecordingFromFolder = async (recordingId) => {
|
||||
return assignFolderToRecording(recordingId, null);
|
||||
};
|
||||
|
||||
const bulkAssignFolder = async (recordingIds, folderId) => {
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
const response = await fetch('/api/recordings/bulk/folder', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recording_ids: recordingIds,
|
||||
folder_id: folderId || null
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to update folders');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Update local recording data
|
||||
recordingIds.forEach(id => {
|
||||
const recording = recordings.value.find(r => r.id === id);
|
||||
if (recording) {
|
||||
recording.folder_id = folderId || null;
|
||||
recording.folder = folderId ? availableFolders.value?.find(f => f.id === folderId) : null;
|
||||
}
|
||||
});
|
||||
|
||||
// Update folder recording counts
|
||||
if (availableFolders.value) {
|
||||
availableFolders.value.forEach(f => {
|
||||
const count = recordings.value.filter(r => r.folder_id === f.id).length;
|
||||
f.recording_count = count;
|
||||
});
|
||||
}
|
||||
|
||||
if (folderId) {
|
||||
const folder = availableFolders.value?.find(f => f.id === folderId);
|
||||
showToast(`${result.updated_count} recording(s) moved to "${folder?.name || 'Unknown'}"`, 'fa-folder', 2000, 'success');
|
||||
} else {
|
||||
showToast(`${result.updated_count} recording(s) removed from folder`, 'fa-folder-minus', 2000, 'success');
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error bulk updating folders:', error);
|
||||
setGlobalError(`Failed to update folders: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// Methods
|
||||
getRecordingFolder,
|
||||
getFolderById,
|
||||
getFolderColor,
|
||||
getFolderName,
|
||||
getAvailableFoldersForRecording,
|
||||
assignFolderToRecording,
|
||||
removeRecordingFromFolder,
|
||||
bulkAssignFolder
|
||||
};
|
||||
}
|
||||
33
static/js/modules/composables/index.js
Normal file
33
static/js/modules/composables/index.js
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Composables module exports
|
||||
*
|
||||
* Each composable encapsulates related functionality:
|
||||
* - recordings: Loading, selecting, filtering recordings
|
||||
* - upload: File upload queue management
|
||||
* - audio: Microphone/system audio recording
|
||||
* - ui: Dark mode, color schemes, sidebar
|
||||
* - transcription: Transcription editing (ASR editor, text editor)
|
||||
* - speakers: Speaker identification and management
|
||||
* - reprocess: Reprocessing transcription/summary
|
||||
* - sharing: Public/internal sharing
|
||||
* - modals: Modal dialog management
|
||||
* - chat: AI chat functionality
|
||||
* - pwa: PWA features (install prompt, notifications, badging, media session)
|
||||
* - tokens: API token management
|
||||
*/
|
||||
|
||||
export { useRecordings } from './recordings.js';
|
||||
export { useUpload } from './upload.js';
|
||||
export { useAudio } from './audio.js';
|
||||
export { useUI } from './ui.js';
|
||||
export { useModals } from './modals.js';
|
||||
export { useSharing } from './sharing.js';
|
||||
export { useReprocess } from './reprocess.js';
|
||||
export { useTranscription } from './transcription.js';
|
||||
export { useSpeakers } from './speakers.js';
|
||||
export { useChat } from './chat.js';
|
||||
export { usePWA } from './pwa.js';
|
||||
export { useTokens } from './tokens.js';
|
||||
export { useBulkSelection } from './bulk-selection.js';
|
||||
export { useBulkOperations } from './bulk-operations.js';
|
||||
export { useFolders } from './folders.js';
|
||||
659
static/js/modules/composables/modals.js
Normal file
659
static/js/modules/composables/modals.js
Normal file
@@ -0,0 +1,659 @@
|
||||
/**
|
||||
* Modal management composable
|
||||
* Handles opening, closing, and saving modal dialogs
|
||||
*/
|
||||
|
||||
export function useModals(state, utils) {
|
||||
const {
|
||||
showEditModal, showDeleteModal, showEditTagsModal,
|
||||
showReprocessModal, showResetModal, showShareModal,
|
||||
showSharesListModal, showTextEditorModal, showAsrEditorModal,
|
||||
showEditSpeakersModal, showAddSpeakerModal, showEditTextModal,
|
||||
showShareDeleteModal, showUnifiedShareModal, showColorSchemeModal,
|
||||
showSystemAudioHelpModal, editingRecording, recordingToDelete, recordingToReset,
|
||||
selectedRecording, recordings, selectedNewTagId, tagSearchFilter,
|
||||
availableTags, currentView, totalRecordings, toasts, uploadQueue, allJobs,
|
||||
// DateTime picker state
|
||||
showDateTimePicker, pickerMonth, pickerYear, pickerHour, pickerMinute,
|
||||
pickerAmPm, pickerSelectedDate, dateTimePickerTarget, dateTimePickerCallback
|
||||
} = state;
|
||||
|
||||
const { showToast, setGlobalError } = utils;
|
||||
const { computed } = Vue;
|
||||
|
||||
// =========================================
|
||||
// Edit Recording Modal
|
||||
// =========================================
|
||||
|
||||
const openEditModal = (recording) => {
|
||||
editingRecording.value = { ...recording };
|
||||
showEditModal.value = true;
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
showEditModal.value = false;
|
||||
editingRecording.value = null;
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editingRecording.value) return;
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
const response = await fetch(`/api/recordings/${editingRecording.value.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: editingRecording.value.title,
|
||||
participants: editingRecording.value.participants,
|
||||
meeting_date: editingRecording.value.meeting_date,
|
||||
notes: editingRecording.value.notes
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to save changes');
|
||||
|
||||
// Update local data
|
||||
const index = recordings.value.findIndex(r => r.id === editingRecording.value.id);
|
||||
if (index !== -1) {
|
||||
recordings.value[index] = { ...recordings.value[index], ...editingRecording.value };
|
||||
}
|
||||
if (selectedRecording.value && selectedRecording.value.id === editingRecording.value.id) {
|
||||
selectedRecording.value = { ...selectedRecording.value, ...editingRecording.value };
|
||||
}
|
||||
|
||||
showToast('Recording updated!', 'fa-check-circle');
|
||||
showEditModal.value = false;
|
||||
editingRecording.value = null;
|
||||
} catch (error) {
|
||||
setGlobalError(`Failed to save changes: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// =========================================
|
||||
// Delete Recording Modal
|
||||
// =========================================
|
||||
|
||||
const confirmDelete = (recording) => {
|
||||
recordingToDelete.value = recording;
|
||||
showDeleteModal.value = true;
|
||||
};
|
||||
|
||||
const cancelDelete = () => {
|
||||
showDeleteModal.value = false;
|
||||
recordingToDelete.value = null;
|
||||
};
|
||||
|
||||
const deleteRecording = async () => {
|
||||
if (!recordingToDelete.value) return;
|
||||
const deletedId = recordingToDelete.value.id;
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
const response = await fetch(`/recording/${deletedId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRFToken': csrfToken }
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to delete recording');
|
||||
|
||||
// Remove from recordings list
|
||||
recordings.value = recordings.value.filter(r => r.id !== deletedId);
|
||||
totalRecordings.value--;
|
||||
|
||||
// Remove from upload queue if present (frontend tracking)
|
||||
if (uploadQueue?.value) {
|
||||
uploadQueue.value = uploadQueue.value.filter(item => item.recordingId !== deletedId);
|
||||
}
|
||||
|
||||
// Remove from backend job queue if present (backend processing tracking)
|
||||
// This is critical - without this, deleted recordings remain in processing queue
|
||||
if (allJobs?.value) {
|
||||
allJobs.value = allJobs.value.filter(job => job.recording_id !== deletedId);
|
||||
}
|
||||
|
||||
// Clear selected recording if it's the one being deleted
|
||||
if (selectedRecording.value?.id === deletedId) {
|
||||
selectedRecording.value = null;
|
||||
currentView.value = 'upload';
|
||||
}
|
||||
|
||||
showToast('Recording deleted.', 'fa-trash');
|
||||
showDeleteModal.value = false;
|
||||
recordingToDelete.value = null;
|
||||
} catch (error) {
|
||||
setGlobalError(`Failed to delete recording: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// =========================================
|
||||
// Archive Recording
|
||||
// =========================================
|
||||
|
||||
const archiveRecording = async (recording) => {
|
||||
if (!recording) return;
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
const response = await fetch(`/api/recordings/${recording.id}/archive`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': csrfToken }
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to archive recording');
|
||||
|
||||
recording.is_archived = true;
|
||||
recording.audio_deleted_at = data.audio_deleted_at;
|
||||
|
||||
// Update in recordings list
|
||||
const index = recordings.value.findIndex(r => r.id === recording.id);
|
||||
if (index !== -1) {
|
||||
recordings.value[index].is_archived = true;
|
||||
recordings.value[index].audio_deleted_at = data.audio_deleted_at;
|
||||
}
|
||||
|
||||
showToast('Recording archived (audio deleted)', 'fa-archive');
|
||||
} catch (error) {
|
||||
setGlobalError(`Failed to archive recording: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// =========================================
|
||||
// Edit Tags Modal
|
||||
// =========================================
|
||||
|
||||
const openEditTagsModal = () => {
|
||||
selectedNewTagId.value = '';
|
||||
tagSearchFilter.value = '';
|
||||
showEditTagsModal.value = true;
|
||||
};
|
||||
|
||||
const closeEditTagsModal = () => {
|
||||
showEditTagsModal.value = false;
|
||||
};
|
||||
|
||||
const addTagToRecording = async (tagId) => {
|
||||
if (!selectedRecording.value || !tagId) return;
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
const response = await fetch(`/api/recordings/${selectedRecording.value.id}/tags`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({ tag_id: tagId })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to add tag');
|
||||
|
||||
// Find the tag object
|
||||
const tag = availableTags.value.find(t => t.id === tagId);
|
||||
if (tag) {
|
||||
if (!selectedRecording.value.tags) {
|
||||
selectedRecording.value.tags = [];
|
||||
}
|
||||
selectedRecording.value.tags.push(tag);
|
||||
}
|
||||
|
||||
// Update in recordings list
|
||||
const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id);
|
||||
if (index !== -1 && tag) {
|
||||
if (!recordings.value[index].tags) {
|
||||
recordings.value[index].tags = [];
|
||||
}
|
||||
recordings.value[index].tags.push(tag);
|
||||
}
|
||||
|
||||
selectedNewTagId.value = '';
|
||||
showToast('Tag added!', 'fa-tag');
|
||||
} catch (error) {
|
||||
setGlobalError(`Failed to add tag: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTagFromRecording = async (tagId) => {
|
||||
if (!selectedRecording.value || !tagId) return;
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
const response = await fetch(`/api/recordings/${selectedRecording.value.id}/tags/${tagId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRFToken': csrfToken }
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to remove tag');
|
||||
|
||||
// Remove from selected recording
|
||||
if (selectedRecording.value.tags) {
|
||||
selectedRecording.value.tags = selectedRecording.value.tags.filter(t => t.id !== tagId);
|
||||
}
|
||||
|
||||
// Update in recordings list
|
||||
const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id);
|
||||
if (index !== -1 && recordings.value[index].tags) {
|
||||
recordings.value[index].tags = recordings.value[index].tags.filter(t => t.id !== tagId);
|
||||
}
|
||||
|
||||
showToast('Tag removed!', 'fa-tag');
|
||||
} catch (error) {
|
||||
setGlobalError(`Failed to remove tag: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// =========================================
|
||||
// Reset Modal
|
||||
// =========================================
|
||||
|
||||
const openResetModal = (recording) => {
|
||||
recordingToReset.value = recording;
|
||||
showResetModal.value = true;
|
||||
};
|
||||
|
||||
const cancelReset = () => {
|
||||
showResetModal.value = false;
|
||||
recordingToReset.value = null;
|
||||
};
|
||||
|
||||
const resetRecording = async () => {
|
||||
if (!recordingToReset.value) return;
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
const response = await fetch(`/recording/${recordingToReset.value.id}/reset_status`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': csrfToken }
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to reset recording');
|
||||
|
||||
// Update recording status
|
||||
const index = recordings.value.findIndex(r => r.id === recordingToReset.value.id);
|
||||
if (index !== -1) {
|
||||
recordings.value[index].status = 'PENDING';
|
||||
recordings.value[index].transcription = '';
|
||||
recordings.value[index].summary = '';
|
||||
}
|
||||
|
||||
if (selectedRecording.value?.id === recordingToReset.value.id) {
|
||||
selectedRecording.value.status = 'PENDING';
|
||||
selectedRecording.value.transcription = '';
|
||||
selectedRecording.value.summary = '';
|
||||
}
|
||||
|
||||
showToast('Recording reset for reprocessing.', 'fa-redo');
|
||||
showResetModal.value = false;
|
||||
recordingToReset.value = null;
|
||||
} catch (error) {
|
||||
setGlobalError(`Failed to reset recording: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// =========================================
|
||||
// System Audio Help Modal
|
||||
// =========================================
|
||||
|
||||
const openSystemAudioHelpModal = () => {
|
||||
showSystemAudioHelpModal.value = true;
|
||||
};
|
||||
|
||||
const closeSystemAudioHelpModal = () => {
|
||||
showSystemAudioHelpModal.value = false;
|
||||
};
|
||||
|
||||
// =========================================
|
||||
// Toast Management
|
||||
// =========================================
|
||||
|
||||
const dismissToast = (id) => {
|
||||
toasts.value = toasts.value.filter(t => t.id !== id);
|
||||
};
|
||||
|
||||
// Aliases for template compatibility
|
||||
const editRecording = openEditModal;
|
||||
const editRecordingTags = openEditTagsModal;
|
||||
|
||||
// =========================================
|
||||
// DateTime Picker
|
||||
// =========================================
|
||||
|
||||
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
const dayNames = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
||||
|
||||
// Generate available years (10 years before and after current year)
|
||||
const availableYears = computed(() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const years = [];
|
||||
for (let y = currentYear - 10; y <= currentYear + 10; y++) {
|
||||
years.push(y);
|
||||
}
|
||||
return years;
|
||||
});
|
||||
|
||||
// Generate hours for 12-hour format
|
||||
const hours12 = computed(() => {
|
||||
const hours = [];
|
||||
for (let h = 1; h <= 12; h++) {
|
||||
hours.push({ value: h, label: h.toString() });
|
||||
}
|
||||
return hours;
|
||||
});
|
||||
|
||||
// Generate minutes
|
||||
const minutes = computed(() => {
|
||||
const mins = [];
|
||||
for (let m = 0; m < 60; m++) {
|
||||
mins.push(m);
|
||||
}
|
||||
return mins;
|
||||
});
|
||||
|
||||
// Generate calendar days for current month view
|
||||
const calendarDays = computed(() => {
|
||||
const days = [];
|
||||
const year = pickerYear.value;
|
||||
const month = pickerMonth.value;
|
||||
|
||||
// First day of the month
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const startingDay = firstDay.getDay();
|
||||
|
||||
// Last day of the month
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const totalDays = lastDay.getDate();
|
||||
|
||||
// Previous month days to fill the grid
|
||||
const prevMonthLastDay = new Date(year, month, 0).getDate();
|
||||
for (let i = startingDay - 1; i >= 0; i--) {
|
||||
days.push({
|
||||
day: prevMonthLastDay - i,
|
||||
date: new Date(year, month - 1, prevMonthLastDay - i),
|
||||
inMonth: false,
|
||||
isToday: false,
|
||||
isSelected: false
|
||||
});
|
||||
}
|
||||
|
||||
// Current month days
|
||||
const today = new Date();
|
||||
for (let d = 1; d <= totalDays; d++) {
|
||||
const date = new Date(year, month, d);
|
||||
const isToday = date.toDateString() === today.toDateString();
|
||||
const isSelected = pickerSelectedDate.value &&
|
||||
date.toDateString() === pickerSelectedDate.value.toDateString();
|
||||
days.push({
|
||||
day: d,
|
||||
date: date,
|
||||
inMonth: true,
|
||||
isToday: isToday,
|
||||
isSelected: isSelected
|
||||
});
|
||||
}
|
||||
|
||||
// Next month days to fill the grid (6 rows * 7 days = 42 total)
|
||||
const remainingDays = 42 - days.length;
|
||||
for (let d = 1; d <= remainingDays; d++) {
|
||||
days.push({
|
||||
day: d,
|
||||
date: new Date(year, month + 1, d),
|
||||
inMonth: false,
|
||||
isToday: false,
|
||||
isSelected: false
|
||||
});
|
||||
}
|
||||
|
||||
return days;
|
||||
});
|
||||
|
||||
const openDateTimePicker = (target, currentValue, callback) => {
|
||||
dateTimePickerTarget.value = target;
|
||||
dateTimePickerCallback.value = callback;
|
||||
|
||||
// Parse current value if exists
|
||||
if (currentValue) {
|
||||
const date = new Date(currentValue);
|
||||
if (!isNaN(date.getTime())) {
|
||||
pickerSelectedDate.value = date;
|
||||
pickerMonth.value = date.getMonth();
|
||||
pickerYear.value = date.getFullYear();
|
||||
|
||||
let hours = date.getHours();
|
||||
const ampm = hours >= 12 ? 'PM' : 'AM';
|
||||
hours = hours % 12;
|
||||
hours = hours === 0 ? 12 : hours;
|
||||
|
||||
pickerHour.value = hours;
|
||||
pickerMinute.value = date.getMinutes();
|
||||
pickerAmPm.value = ampm;
|
||||
} else {
|
||||
setPickerToNow();
|
||||
}
|
||||
} else {
|
||||
setPickerToNow();
|
||||
}
|
||||
|
||||
showDateTimePicker.value = true;
|
||||
};
|
||||
|
||||
const setPickerToNow = () => {
|
||||
const now = new Date();
|
||||
pickerSelectedDate.value = now;
|
||||
pickerMonth.value = now.getMonth();
|
||||
pickerYear.value = now.getFullYear();
|
||||
|
||||
let hours = now.getHours();
|
||||
const ampm = hours >= 12 ? 'PM' : 'AM';
|
||||
hours = hours % 12;
|
||||
hours = hours === 0 ? 12 : hours;
|
||||
|
||||
pickerHour.value = hours;
|
||||
pickerMinute.value = now.getMinutes();
|
||||
pickerAmPm.value = ampm;
|
||||
};
|
||||
|
||||
const closeDateTimePicker = () => {
|
||||
showDateTimePicker.value = false;
|
||||
dateTimePickerTarget.value = null;
|
||||
dateTimePickerCallback.value = null;
|
||||
};
|
||||
|
||||
const prevMonth = () => {
|
||||
if (pickerMonth.value === 0) {
|
||||
pickerMonth.value = 11;
|
||||
pickerYear.value--;
|
||||
} else {
|
||||
pickerMonth.value--;
|
||||
}
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
if (pickerMonth.value === 11) {
|
||||
pickerMonth.value = 0;
|
||||
pickerYear.value++;
|
||||
} else {
|
||||
pickerMonth.value++;
|
||||
}
|
||||
};
|
||||
|
||||
const updatePickerView = () => {
|
||||
// Called when month/year dropdowns change
|
||||
// The computed calendarDays will automatically update
|
||||
};
|
||||
|
||||
const selectDate = (date) => {
|
||||
pickerSelectedDate.value = date;
|
||||
};
|
||||
|
||||
const setToNow = () => {
|
||||
setPickerToNow();
|
||||
};
|
||||
|
||||
const setToToday = () => {
|
||||
const today = new Date();
|
||||
pickerSelectedDate.value = today;
|
||||
pickerMonth.value = today.getMonth();
|
||||
pickerYear.value = today.getFullYear();
|
||||
// Keep the current time
|
||||
};
|
||||
|
||||
const clearDateTime = () => {
|
||||
pickerSelectedDate.value = null;
|
||||
const now = new Date();
|
||||
pickerMonth.value = now.getMonth();
|
||||
pickerYear.value = now.getFullYear();
|
||||
pickerHour.value = 12;
|
||||
pickerMinute.value = 0;
|
||||
pickerAmPm.value = 'PM';
|
||||
};
|
||||
|
||||
const formatPickerPreview = () => {
|
||||
if (!pickerSelectedDate.value) return '';
|
||||
|
||||
const date = pickerSelectedDate.value;
|
||||
const monthName = monthNames[date.getMonth()];
|
||||
const day = date.getDate();
|
||||
const year = date.getFullYear();
|
||||
|
||||
const hour = pickerHour.value;
|
||||
const minute = pickerMinute.value.toString().padStart(2, '0');
|
||||
const ampm = pickerAmPm.value;
|
||||
|
||||
return `${monthName} ${day}, ${year} at ${hour}:${minute} ${ampm}`;
|
||||
};
|
||||
|
||||
const applyDateTime = () => {
|
||||
if (!pickerSelectedDate.value) {
|
||||
// If no date selected, just close
|
||||
closeDateTimePicker();
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the full datetime
|
||||
const date = new Date(pickerSelectedDate.value);
|
||||
let hours = pickerHour.value;
|
||||
|
||||
// Convert 12-hour to 24-hour
|
||||
if (pickerAmPm.value === 'AM') {
|
||||
hours = hours === 12 ? 0 : hours;
|
||||
} else {
|
||||
hours = hours === 12 ? 12 : hours + 12;
|
||||
}
|
||||
|
||||
date.setHours(hours);
|
||||
date.setMinutes(pickerMinute.value);
|
||||
date.setSeconds(0);
|
||||
date.setMilliseconds(0);
|
||||
|
||||
// Format as ISO string for storage (YYYY-MM-DDTHH:mm:ss)
|
||||
const isoString = date.toISOString().slice(0, 19);
|
||||
|
||||
// Call the callback with the result
|
||||
if (dateTimePickerCallback.value) {
|
||||
dateTimePickerCallback.value(isoString, date);
|
||||
}
|
||||
|
||||
closeDateTimePicker();
|
||||
};
|
||||
|
||||
// Helper to open datetime picker for meeting date
|
||||
const openMeetingDatePicker = () => {
|
||||
if (!selectedRecording.value) return;
|
||||
|
||||
openDateTimePicker(
|
||||
'meeting_date',
|
||||
selectedRecording.value.meeting_date,
|
||||
(isoString) => {
|
||||
selectedRecording.value.meeting_date = isoString;
|
||||
// Auto-save the change
|
||||
saveInlineMeetingDate();
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Save meeting date inline (similar to other inline edits)
|
||||
const saveInlineMeetingDate = async () => {
|
||||
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 meeting date');
|
||||
|
||||
showToast('Meeting date updated!', 'fa-calendar-check');
|
||||
} catch (error) {
|
||||
showToast(`Failed to save: ${error.message}`, 'fa-exclamation-circle', 3000, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// Edit modal
|
||||
openEditModal,
|
||||
editRecording,
|
||||
cancelEdit,
|
||||
saveEdit,
|
||||
|
||||
// Delete modal
|
||||
confirmDelete,
|
||||
cancelDelete,
|
||||
deleteRecording,
|
||||
|
||||
// Archive
|
||||
archiveRecording,
|
||||
|
||||
// Tags modal
|
||||
openEditTagsModal,
|
||||
editRecordingTags,
|
||||
closeEditTagsModal,
|
||||
addTagToRecording,
|
||||
removeTagFromRecording,
|
||||
|
||||
// Reset modal
|
||||
openResetModal,
|
||||
cancelReset,
|
||||
resetRecording,
|
||||
|
||||
// System audio help
|
||||
openSystemAudioHelpModal,
|
||||
closeSystemAudioHelpModal,
|
||||
|
||||
// Toast
|
||||
dismissToast,
|
||||
|
||||
// DateTime picker
|
||||
monthNames,
|
||||
dayNames,
|
||||
availableYears,
|
||||
hours12,
|
||||
minutes,
|
||||
calendarDays,
|
||||
openDateTimePicker,
|
||||
closeDateTimePicker,
|
||||
prevMonth,
|
||||
nextMonth,
|
||||
updatePickerView,
|
||||
selectDate,
|
||||
setToNow,
|
||||
setToToday,
|
||||
clearDateTime,
|
||||
formatPickerPreview,
|
||||
applyDateTime,
|
||||
openMeetingDatePicker
|
||||
};
|
||||
}
|
||||
518
static/js/modules/composables/pwa.js
Normal file
518
static/js/modules/composables/pwa.js
Normal file
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* PWA Features Composable
|
||||
* Handles install prompt, push notifications, badging, and other PWA APIs
|
||||
*/
|
||||
|
||||
import { isPushEnabled, getPublicKey, urlBase64ToUint8Array } from '../../config/push-config.js';
|
||||
|
||||
export function usePWA(state, utils) {
|
||||
const {
|
||||
deferredInstallPrompt,
|
||||
showInstallButton,
|
||||
isPWAInstalled,
|
||||
notificationPermission,
|
||||
pushSubscription,
|
||||
appBadgeCount
|
||||
} = state;
|
||||
|
||||
const { showToast } = utils;
|
||||
|
||||
// --- Install Prompt ---
|
||||
|
||||
/**
|
||||
* Handle beforeinstallprompt event
|
||||
* This event is fired when the browser detects the app can be installed
|
||||
*/
|
||||
const handleBeforeInstallPrompt = (e) => {
|
||||
console.log('[PWA] beforeinstallprompt event fired');
|
||||
// Prevent the mini-infobar from appearing on mobile
|
||||
e.preventDefault();
|
||||
// Stash the event so it can be triggered later
|
||||
deferredInstallPrompt.value = e;
|
||||
// Show our custom install button
|
||||
showInstallButton.value = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prompt user to install the PWA
|
||||
*/
|
||||
const promptInstall = async () => {
|
||||
if (!deferredInstallPrompt.value) {
|
||||
console.log('[PWA] No deferred install prompt available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the install prompt
|
||||
deferredInstallPrompt.value.prompt();
|
||||
|
||||
// Wait for the user's response
|
||||
const { outcome } = await deferredInstallPrompt.value.userChoice;
|
||||
console.log(`[PWA] User response to install prompt: ${outcome}`);
|
||||
|
||||
if (outcome === 'accepted') {
|
||||
showToast('Installing Speakr...', 'success');
|
||||
}
|
||||
|
||||
// Clear the deferred prompt since it can only be used once
|
||||
deferredInstallPrompt.value = null;
|
||||
showInstallButton.value = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if app is already installed
|
||||
*/
|
||||
const checkIfInstalled = () => {
|
||||
// Check if running in standalone mode (installed PWA)
|
||||
if (window.matchMedia('(display-mode: standalone)').matches ||
|
||||
window.navigator.standalone === true) {
|
||||
isPWAInstalled.value = true;
|
||||
showInstallButton.value = false;
|
||||
console.log('[PWA] App is installed and running in standalone mode');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle appinstalled event
|
||||
*/
|
||||
const handleAppInstalled = () => {
|
||||
console.log('[PWA] App was installed');
|
||||
isPWAInstalled.value = true;
|
||||
showInstallButton.value = false;
|
||||
showToast('Speakr installed successfully!', 'success');
|
||||
};
|
||||
|
||||
// --- Push Notifications ---
|
||||
|
||||
/**
|
||||
* Request notification permission
|
||||
*/
|
||||
const requestNotificationPermission = async () => {
|
||||
if (!('Notification' in window)) {
|
||||
console.warn('[PWA] This browser does not support notifications');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
notificationPermission.value = permission;
|
||||
console.log(`[PWA] Notification permission: ${permission}`);
|
||||
|
||||
if (permission === 'granted') {
|
||||
showToast('Notifications enabled', 'success');
|
||||
return true;
|
||||
} else if (permission === 'denied') {
|
||||
showToast('Notification permission denied', 'error');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PWA] Error requesting notification permission:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to push notifications
|
||||
*/
|
||||
const subscribeToPushNotifications = async () => {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
console.warn('[PWA] Push notifications not supported');
|
||||
showToast('Push notifications not supported in this browser', 'warning');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if push is enabled on server
|
||||
const enabled = await isPushEnabled();
|
||||
if (!enabled) {
|
||||
console.warn('[PWA] Push notifications not configured on server');
|
||||
showToast('Push notifications not available. Install pywebpush on server.', 'warning');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get public key from server
|
||||
const publicKey = await getPublicKey();
|
||||
if (!publicKey) {
|
||||
console.error('[PWA] Failed to get VAPID public key from server');
|
||||
showToast('Failed to configure push notifications', 'error');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
|
||||
// Check if already subscribed
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (!subscription) {
|
||||
// Subscribe to push notifications
|
||||
console.log('[PWA] Creating new push subscription...');
|
||||
|
||||
const applicationServerKey = urlBase64ToUint8Array(publicKey);
|
||||
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: applicationServerKey
|
||||
});
|
||||
|
||||
// Send subscription to server
|
||||
const success = await sendSubscriptionToServer(subscription);
|
||||
|
||||
if (success) {
|
||||
pushSubscription.value = subscription;
|
||||
showToast('Push notifications enabled', 'success');
|
||||
console.log('[PWA] Push subscription successful:', subscription);
|
||||
} else {
|
||||
console.warn('[PWA] Failed to save subscription on server');
|
||||
showToast('Failed to enable push notifications', 'error');
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
pushSubscription.value = subscription;
|
||||
console.log('[PWA] Already subscribed to push notifications');
|
||||
}
|
||||
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
console.error('[PWA] Failed to subscribe to push notifications:', error);
|
||||
|
||||
if (error.name === 'NotAllowedError') {
|
||||
showToast('Push notification permission denied', 'error');
|
||||
} else {
|
||||
showToast('Failed to enable push notifications', 'error');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Send subscription to server for storage
|
||||
*/
|
||||
const sendSubscriptionToServer = async (subscription) => {
|
||||
try {
|
||||
const response = await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(subscription),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[PWA] Server rejected push subscription:', response.status);
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[PWA] Subscription saved on server:', data);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[PWA] Failed to send subscription to server:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Unsubscribe from push notifications
|
||||
*/
|
||||
const unsubscribeFromPushNotifications = async () => {
|
||||
if (!pushSubscription.value) {
|
||||
console.log('[PWA] No active push subscription to unsubscribe');
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Unsubscribe on client
|
||||
await pushSubscription.value.unsubscribe();
|
||||
|
||||
// Remove from server
|
||||
await fetch('/api/push/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(pushSubscription.value),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
pushSubscription.value = null;
|
||||
showToast('Push notifications disabled', 'info');
|
||||
console.log('[PWA] Unsubscribed from push notifications');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[PWA] Failed to unsubscribe from push notifications:', error);
|
||||
showToast('Failed to disable push notifications', 'error');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Show a local notification
|
||||
*/
|
||||
const showNotification = async (title, options = {}) => {
|
||||
if (!('Notification' in window)) {
|
||||
console.warn('[PWA] Notifications not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission !== 'granted') {
|
||||
const granted = await requestNotificationPermission();
|
||||
if (!granted) return;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
|
||||
const defaultOptions = {
|
||||
icon: '/static/img/icon-192x192.png',
|
||||
badge: '/static/img/icon-192x192.png',
|
||||
vibrate: [200, 100, 200],
|
||||
tag: 'speakr-notification',
|
||||
renotify: true,
|
||||
...options
|
||||
};
|
||||
|
||||
await registration.showNotification(title, defaultOptions);
|
||||
} catch (error) {
|
||||
console.error('[PWA] Error showing notification:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Badging API ---
|
||||
|
||||
/**
|
||||
* Set app badge count
|
||||
*/
|
||||
const setAppBadge = async (count) => {
|
||||
if (!('setAppBadge' in navigator)) {
|
||||
console.log('[PWA] Badging API not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (count > 0) {
|
||||
await navigator.setAppBadge(count);
|
||||
appBadgeCount.value = count;
|
||||
console.log(`[PWA] App badge set to ${count}`);
|
||||
} else {
|
||||
await navigator.clearAppBadge();
|
||||
appBadgeCount.value = 0;
|
||||
console.log('[PWA] App badge cleared');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PWA] Error setting app badge:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear app badge
|
||||
*/
|
||||
const clearAppBadge = async () => {
|
||||
await setAppBadge(0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update badge with unread count
|
||||
*/
|
||||
const updateBadgeCount = async (audioFiles) => {
|
||||
if (!audioFiles || !Array.isArray(audioFiles)) return;
|
||||
|
||||
// Count unread recordings (those still in inbox)
|
||||
const unreadCount = audioFiles.filter(file => file.in_inbox).length;
|
||||
await setAppBadge(unreadCount);
|
||||
};
|
||||
|
||||
// --- Media Session API ---
|
||||
|
||||
/**
|
||||
* Set up Media Session for audio playback control
|
||||
* @param {Object} metadata - Track metadata { title, artist, album, artwork }
|
||||
* @param {Object} handlers - Action handlers { play, pause, seekbackward, seekforward, previoustrack, nexttrack }
|
||||
*/
|
||||
const setupMediaSession = (metadata, handlers = {}) => {
|
||||
if (!('mediaSession' in navigator)) {
|
||||
console.log('[PWA] Media Session API not supported');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Set metadata
|
||||
if (metadata) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: metadata.title || 'Untitled Recording',
|
||||
artist: metadata.artist || 'Speakr',
|
||||
album: metadata.album || 'Recordings',
|
||||
artwork: metadata.artwork || [
|
||||
{ src: '/static/img/icon-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/static/img/icon-512x512.png', sizes: '512x512', type: 'image/png' }
|
||||
]
|
||||
});
|
||||
currentMediaMetadata.value = metadata;
|
||||
}
|
||||
|
||||
// Set action handlers
|
||||
const actions = ['play', 'pause', 'seekbackward', 'seekforward', 'previoustrack', 'nexttrack', 'stop'];
|
||||
|
||||
actions.forEach(action => {
|
||||
if (handlers[action]) {
|
||||
try {
|
||||
navigator.mediaSession.setActionHandler(action, handlers[action]);
|
||||
} catch (error) {
|
||||
console.warn(`[PWA] The ${action} action is not supported`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set position state if provided
|
||||
if (handlers.setPositionState) {
|
||||
try {
|
||||
navigator.mediaSession.setPositionState(handlers.setPositionState);
|
||||
} catch (error) {
|
||||
console.warn('[PWA] setPositionState not supported:', error);
|
||||
}
|
||||
}
|
||||
|
||||
isMediaSessionActive.value = true;
|
||||
console.log('[PWA] Media Session configured successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[PWA] Error setting up Media Session:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update Media Session position state
|
||||
* @param {Object} state - { duration, playbackRate, position }
|
||||
*/
|
||||
const updateMediaSessionPosition = (state) => {
|
||||
if (!('mediaSession' in navigator) || !isMediaSessionActive.value) return;
|
||||
|
||||
try {
|
||||
navigator.mediaSession.setPositionState({
|
||||
duration: state.duration || 0,
|
||||
playbackRate: state.playbackRate || 1.0,
|
||||
position: state.position || 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[PWA] Error updating position state:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update Media Session playback state
|
||||
* @param {string} state - 'playing' | 'paused' | 'none'
|
||||
*/
|
||||
const updateMediaSessionPlaybackState = (state) => {
|
||||
if (!('mediaSession' in navigator) || !isMediaSessionActive.value) return;
|
||||
|
||||
try {
|
||||
navigator.mediaSession.playbackState = state;
|
||||
} catch (error) {
|
||||
console.warn('[PWA] Error updating playback state:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear Media Session
|
||||
*/
|
||||
const clearMediaSession = () => {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
|
||||
try {
|
||||
navigator.mediaSession.metadata = null;
|
||||
const actions = ['play', 'pause', 'seekbackward', 'seekforward', 'previoustrack', 'nexttrack', 'stop'];
|
||||
actions.forEach(action => {
|
||||
try {
|
||||
navigator.mediaSession.setActionHandler(action, null);
|
||||
} catch (e) { /* ignore */ }
|
||||
});
|
||||
isMediaSessionActive.value = false;
|
||||
currentMediaMetadata.value = null;
|
||||
console.log('[PWA] Media Session cleared');
|
||||
} catch (error) {
|
||||
console.error('[PWA] Error clearing Media Session:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Background Sync ---
|
||||
|
||||
/**
|
||||
* Register background sync for upload retry
|
||||
*/
|
||||
const registerBackgroundSync = async (tag = 'sync-uploads') => {
|
||||
if (!('serviceWorker' in navigator) || !('sync' in ServiceWorkerRegistration.prototype)) {
|
||||
console.log('[PWA] Background sync not supported');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
await registration.sync.register(tag);
|
||||
console.log(`[PWA] Background sync registered: ${tag}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[PWA] Failed to register background sync:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize PWA features
|
||||
*/
|
||||
const initPWA = () => {
|
||||
// Check if already installed
|
||||
checkIfInstalled();
|
||||
|
||||
// Listen for beforeinstallprompt event
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||
|
||||
// Listen for appinstalled event
|
||||
window.addEventListener('appinstalled', handleAppInstalled);
|
||||
|
||||
// Check notification permission status
|
||||
if ('Notification' in window) {
|
||||
notificationPermission.value = Notification.permission;
|
||||
}
|
||||
|
||||
console.log('[PWA] PWA features initialized');
|
||||
};
|
||||
|
||||
/**
|
||||
* Cleanup PWA event listeners
|
||||
*/
|
||||
const cleanupPWA = () => {
|
||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||
window.removeEventListener('appinstalled', handleAppInstalled);
|
||||
};
|
||||
|
||||
return {
|
||||
// Install prompt
|
||||
promptInstall,
|
||||
checkIfInstalled,
|
||||
|
||||
// Notifications
|
||||
requestNotificationPermission,
|
||||
subscribeToPushNotifications,
|
||||
unsubscribeFromPushNotifications,
|
||||
showNotification,
|
||||
|
||||
// Badging
|
||||
setAppBadge,
|
||||
clearAppBadge,
|
||||
updateBadgeCount,
|
||||
|
||||
// Media Session
|
||||
setupMediaSession,
|
||||
updateMediaSessionPosition,
|
||||
updateMediaSessionPlaybackState,
|
||||
clearMediaSession,
|
||||
|
||||
// Background sync
|
||||
registerBackgroundSync,
|
||||
|
||||
// Initialization
|
||||
initPWA,
|
||||
cleanupPWA
|
||||
};
|
||||
}
|
||||
482
static/js/modules/composables/recordings.js
Normal file
482
static/js/modules/composables/recordings.js
Normal file
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* Recording management composable
|
||||
* Handles loading, selecting, filtering, and managing recordings
|
||||
*/
|
||||
|
||||
import * as IncognitoStorage from '../db/incognito-storage.js';
|
||||
|
||||
export function useRecordings(state, utils, reprocessComposable) {
|
||||
const {
|
||||
recordings, selectedRecording, isLoadingRecordings, isLoadingMore,
|
||||
currentPage, perPage, totalRecordings, totalPages, hasNextPage, hasPrevPage,
|
||||
showSharedWithMe, showArchivedRecordings, searchQuery, searchDebounceTimer,
|
||||
filterTags, filterSpeakers, filterDatePreset, filterDateRange, filterTextQuery,
|
||||
filterStarred, filterInbox, filterFolder, sortBy,
|
||||
availableTags, availableSpeakers, availableFolders, selectedTagIds, uploadLanguage, uploadMinSpeakers, uploadMaxSpeakers, uploadHotwords, uploadInitialPrompt,
|
||||
useAsrEndpoint, connectorSupportsDiarization, globalError, uploadQueue, isProcessingActive, currentView,
|
||||
isMobileScreen, isSidebarCollapsed, isRecording, audioBlobURL,
|
||||
speakerColorMap,
|
||||
// Incognito mode
|
||||
incognitoRecording
|
||||
} = state;
|
||||
|
||||
const { setGlobalError, showToast } = utils;
|
||||
|
||||
// Load recordings from API
|
||||
const loadRecordings = async (page = 1, append = false, searchQueryParam = '') => {
|
||||
globalError.value = null;
|
||||
if (!append) {
|
||||
isLoadingRecordings.value = true;
|
||||
} else {
|
||||
isLoadingMore.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint = '/api/recordings';
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
per_page: perPage.value.toString()
|
||||
});
|
||||
|
||||
if (searchQueryParam.trim()) {
|
||||
params.set('q', searchQueryParam.trim());
|
||||
}
|
||||
|
||||
// Add sort parameter
|
||||
if (sortBy.value) {
|
||||
params.set('sort_by', sortBy.value);
|
||||
}
|
||||
|
||||
// Add archived/shared/starred/inbox filters as query params (ANDed with other filters)
|
||||
if (showArchivedRecordings.value) {
|
||||
params.set('archived', 'true');
|
||||
}
|
||||
if (showSharedWithMe.value) {
|
||||
params.set('shared', 'true');
|
||||
}
|
||||
if (filterStarred.value) {
|
||||
params.set('starred', 'true');
|
||||
}
|
||||
if (filterInbox.value) {
|
||||
params.set('inbox', 'true');
|
||||
}
|
||||
|
||||
// Add folder filter
|
||||
if (filterFolder && filterFolder.value) {
|
||||
params.set('folder', filterFolder.value);
|
||||
}
|
||||
|
||||
const response = await fetch(`${endpoint}?${params}`);
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to load recordings');
|
||||
|
||||
const recordingsList = data.recordings;
|
||||
const pagination = data.pagination;
|
||||
|
||||
if (!Array.isArray(recordingsList)) {
|
||||
console.error('Unexpected response format:', data);
|
||||
throw new Error('Invalid response format from server');
|
||||
}
|
||||
|
||||
if (pagination) {
|
||||
currentPage.value = pagination.page;
|
||||
totalRecordings.value = pagination.total;
|
||||
totalPages.value = pagination.total_pages;
|
||||
hasNextPage.value = pagination.has_next;
|
||||
hasPrevPage.value = pagination.has_prev;
|
||||
} else {
|
||||
currentPage.value = 1;
|
||||
totalRecordings.value = recordingsList.length;
|
||||
totalPages.value = 1;
|
||||
hasNextPage.value = false;
|
||||
hasPrevPage.value = false;
|
||||
}
|
||||
|
||||
if (append) {
|
||||
recordings.value = [...recordings.value, ...recordingsList];
|
||||
} else {
|
||||
recordings.value = recordingsList;
|
||||
const lastRecordingId = localStorage.getItem('lastSelectedRecordingId');
|
||||
if (lastRecordingId && recordingsList.length > 0) {
|
||||
const recordingToSelect = recordingsList.find(r => r.id == lastRecordingId);
|
||||
if (recordingToSelect) {
|
||||
selectRecording(recordingToSelect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: Removed auto-queueing of incomplete recordings.
|
||||
// Backend processing recordings are now shown via backendProcessingRecordings
|
||||
// computed property, which filters recordings by status (PENDING, PROCESSING, etc.)
|
||||
// The job queue system (ProcessingJob) handles background processing.
|
||||
|
||||
} catch (error) {
|
||||
console.error('Load Recordings Error:', error);
|
||||
setGlobalError(`Failed to load recordings: ${error.message}`);
|
||||
if (!append) {
|
||||
recordings.value = [];
|
||||
}
|
||||
} finally {
|
||||
isLoadingRecordings.value = false;
|
||||
isLoadingMore.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreRecordings = async () => {
|
||||
if (!hasNextPage.value || isLoadingMore.value) return;
|
||||
await loadRecordings(currentPage.value + 1, true, searchQuery.value);
|
||||
};
|
||||
|
||||
const performSearch = async (query = '') => {
|
||||
currentPage.value = 1;
|
||||
await loadRecordings(1, false, query);
|
||||
};
|
||||
|
||||
const debouncedSearch = (query) => {
|
||||
if (searchDebounceTimer.value) {
|
||||
clearTimeout(searchDebounceTimer.value);
|
||||
}
|
||||
searchDebounceTimer.value = setTimeout(() => {
|
||||
performSearch(query);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const loadTags = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/tags');
|
||||
if (response.ok) {
|
||||
availableTags.value = await response.json();
|
||||
} else {
|
||||
availableTags.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error loading tags:', error);
|
||||
availableTags.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const loadFolders = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/folders');
|
||||
if (response.ok) {
|
||||
availableFolders.value = await response.json();
|
||||
} else {
|
||||
availableFolders.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error loading folders:', error);
|
||||
availableFolders.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const loadSpeakers = async () => {
|
||||
try {
|
||||
const response = await fetch('/speakers');
|
||||
if (response.ok) {
|
||||
availableSpeakers.value = await response.json();
|
||||
} else {
|
||||
availableSpeakers.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error loading speakers:', error);
|
||||
availableSpeakers.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const selectRecording = async (recording) => {
|
||||
if (hasUnsavedRecording()) {
|
||||
if (!confirm('You have an unsaved recording. Are you sure you want to leave?')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if switching away from incognito recording to a regular recording
|
||||
if (incognitoRecording && incognitoRecording.value &&
|
||||
selectedRecording.value?.id === 'incognito' &&
|
||||
recording?.id !== 'incognito') {
|
||||
if (!confirm('Switching to another recording will discard your incognito recording. Continue?')) {
|
||||
return;
|
||||
}
|
||||
// Clear incognito recording immediately - this is the "incognito" promise
|
||||
IncognitoStorage.clearIncognitoRecording();
|
||||
incognitoRecording.value = null;
|
||||
}
|
||||
|
||||
// Also clear any orphaned incognito data when selecting a non-incognito recording
|
||||
// This handles edge cases like page refresh where the above check doesn't trigger
|
||||
if (recording?.id !== 'incognito' && IncognitoStorage.hasIncognitoRecording()) {
|
||||
console.log('[Incognito] Clearing orphaned incognito data');
|
||||
IncognitoStorage.clearIncognitoRecording();
|
||||
if (incognitoRecording) {
|
||||
incognitoRecording.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset modal audio state when switching recordings
|
||||
if (utils.resetModalAudioState) {
|
||||
utils.resetModalAudioState();
|
||||
}
|
||||
|
||||
// Clear speaker color map when switching recordings - new colors will be assigned on first render
|
||||
if (speakerColorMap) {
|
||||
speakerColorMap.value = {};
|
||||
}
|
||||
|
||||
selectedRecording.value = recording;
|
||||
|
||||
if (recording && recording.id) {
|
||||
localStorage.setItem('lastSelectedRecordingId', recording.id);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/recordings/${recording.id}`);
|
||||
if (response.ok) {
|
||||
const fullRecording = await response.json();
|
||||
selectedRecording.value = fullRecording;
|
||||
|
||||
const index = recordings.value.findIndex(r => r.id === recording.id);
|
||||
if (index !== -1) {
|
||||
recordings.value[index] = fullRecording;
|
||||
}
|
||||
|
||||
// Auto-start polling if recording is still processing or summarizing
|
||||
if (['PROCESSING', 'SUMMARIZING'].includes(fullRecording.status)) {
|
||||
console.log(`[AUTO-POLL] Recording ${fullRecording.id} is in ${fullRecording.status} state, starting auto-polling`);
|
||||
if (reprocessComposable && reprocessComposable.startReprocessingPoll) {
|
||||
reprocessComposable.startReprocessingPoll(fullRecording.id);
|
||||
} else {
|
||||
console.warn('[AUTO-POLL] reprocessComposable.startReprocessingPoll not available');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading full recording:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (isMobileScreen.value) {
|
||||
isSidebarCollapsed.value = true;
|
||||
}
|
||||
|
||||
currentView.value = 'detail';
|
||||
|
||||
if (isRecording.value) {
|
||||
// Don't interrupt recording
|
||||
}
|
||||
if (audioBlobURL.value) {
|
||||
// Don't discard recorded audio
|
||||
}
|
||||
};
|
||||
|
||||
const hasUnsavedRecording = () => {
|
||||
return isRecording.value || audioBlobURL.value;
|
||||
};
|
||||
|
||||
const toggleInbox = async (recording) => {
|
||||
if (!recording || !recording.id) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/recording/${recording.id}/toggle_inbox`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to toggle inbox status');
|
||||
|
||||
// Update the recording in the UI
|
||||
recording.is_inbox = data.is_inbox;
|
||||
|
||||
// Update in the recordings list
|
||||
const index = recordings.value.findIndex(r => r.id === recording.id);
|
||||
if (index !== -1) {
|
||||
recordings.value[index].is_inbox = data.is_inbox;
|
||||
}
|
||||
|
||||
showToast(`Recording ${data.is_inbox ? 'moved to inbox' : 'marked as read'}`);
|
||||
} catch (error) {
|
||||
console.error('Toggle Inbox Error:', error);
|
||||
setGlobalError(`Failed to toggle inbox status: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleHighlight = async (recording) => {
|
||||
if (!recording || !recording.id) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/recording/${recording.id}/toggle_highlight`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to toggle highlighted status');
|
||||
|
||||
// Update the recording in the UI
|
||||
recording.is_highlighted = data.is_highlighted;
|
||||
|
||||
// Update in the recordings list
|
||||
const index = recordings.value.findIndex(r => r.id === recording.id);
|
||||
if (index !== -1) {
|
||||
recordings.value[index].is_highlighted = data.is_highlighted;
|
||||
}
|
||||
|
||||
showToast(`Recording ${data.is_highlighted ? 'highlighted' : 'unhighlighted'}`);
|
||||
} catch (error) {
|
||||
console.error('Toggle Highlight Error:', error);
|
||||
setGlobalError(`Failed to toggle highlighted status: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getRecordingTags = (recording) => {
|
||||
if (!recording || !recording.tags) return [];
|
||||
return recording.tags || [];
|
||||
};
|
||||
|
||||
const getAvailableTagsForRecording = (recording) => {
|
||||
if (!recording || !availableTags.value) return [];
|
||||
const recordingTagIds = getRecordingTags(recording).map(tag => tag.id);
|
||||
return availableTags.value.filter(tag => !recordingTagIds.includes(tag.id));
|
||||
};
|
||||
|
||||
const filterByTag = (tag) => {
|
||||
filterTags.value = [tag.id];
|
||||
applyAdvancedFilters();
|
||||
};
|
||||
|
||||
const buildSearchQuery = () => {
|
||||
let query = [];
|
||||
|
||||
if (filterTextQuery.value.trim()) {
|
||||
query.push(filterTextQuery.value.trim());
|
||||
}
|
||||
|
||||
if (filterTags.value.length > 0) {
|
||||
const tagNames = filterTags.value.map(tagId => {
|
||||
const tag = availableTags.value.find(t => t.id === tagId);
|
||||
return tag ? `tag:${tag.name.replace(/\s+/g, '_')}` : '';
|
||||
}).filter(Boolean);
|
||||
query.push(...tagNames);
|
||||
}
|
||||
|
||||
if (filterSpeakers.value.length > 0) {
|
||||
const speakerNames = filterSpeakers.value.map(name =>
|
||||
`speaker:${name.replace(/\s+/g, '_')}`
|
||||
);
|
||||
query.push(...speakerNames);
|
||||
}
|
||||
|
||||
if (filterDatePreset.value) {
|
||||
query.push(`date:${filterDatePreset.value}`);
|
||||
} else if (filterDateRange.value.start || filterDateRange.value.end) {
|
||||
if (filterDateRange.value.start) {
|
||||
query.push(`date_from:${filterDateRange.value.start}`);
|
||||
}
|
||||
if (filterDateRange.value.end) {
|
||||
query.push(`date_to:${filterDateRange.value.end}`);
|
||||
}
|
||||
}
|
||||
|
||||
return query.join(' ');
|
||||
};
|
||||
|
||||
const applyAdvancedFilters = () => {
|
||||
searchQuery.value = buildSearchQuery();
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
filterTags.value = [];
|
||||
filterSpeakers.value = [];
|
||||
filterDateRange.value = { start: '', end: '' };
|
||||
filterDatePreset.value = '';
|
||||
filterTextQuery.value = '';
|
||||
filterStarred.value = false;
|
||||
filterInbox.value = false;
|
||||
// Note: filterFolder is NOT cleared here - it's a navigation element, not a filter
|
||||
searchQuery.value = '';
|
||||
};
|
||||
|
||||
const clearTagFilter = () => {
|
||||
searchQuery.value = '';
|
||||
clearAllFilters();
|
||||
};
|
||||
|
||||
const addTagToSelection = (tagId) => {
|
||||
if (!selectedTagIds.value.includes(tagId)) {
|
||||
selectedTagIds.value.push(tagId);
|
||||
applyTagDefaults();
|
||||
}
|
||||
};
|
||||
|
||||
const removeTagFromSelection = (tagId) => {
|
||||
const index = selectedTagIds.value.indexOf(tagId);
|
||||
if (index > -1) {
|
||||
selectedTagIds.value.splice(index, 1);
|
||||
applyTagDefaults();
|
||||
}
|
||||
};
|
||||
|
||||
const applyTagDefaults = () => {
|
||||
const selectedTags = selectedTagIds.value.map(tagId =>
|
||||
availableTags.value.find(tag => tag.id == tagId)
|
||||
).filter(Boolean);
|
||||
|
||||
const firstTag = selectedTags[0];
|
||||
if (firstTag && connectorSupportsDiarization.value) {
|
||||
if (firstTag.default_language) {
|
||||
uploadLanguage.value = firstTag.default_language;
|
||||
}
|
||||
if (firstTag.default_min_speakers) {
|
||||
uploadMinSpeakers.value = firstTag.default_min_speakers;
|
||||
}
|
||||
if (firstTag.default_max_speakers) {
|
||||
uploadMaxSpeakers.value = firstTag.default_max_speakers;
|
||||
}
|
||||
}
|
||||
if (firstTag) {
|
||||
if (firstTag.default_hotwords) {
|
||||
uploadHotwords.value = firstTag.default_hotwords;
|
||||
}
|
||||
if (firstTag.default_initial_prompt) {
|
||||
uploadInitialPrompt.value = firstTag.default_initial_prompt;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pollInboxRecordings = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/recordings/inbox-count');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Update inbox count in UI if needed
|
||||
}
|
||||
} catch (error) {
|
||||
// Silent fail for polling
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
loadRecordings,
|
||||
loadMoreRecordings,
|
||||
performSearch,
|
||||
debouncedSearch,
|
||||
loadTags,
|
||||
loadFolders,
|
||||
loadSpeakers,
|
||||
selectRecording,
|
||||
hasUnsavedRecording,
|
||||
toggleInbox,
|
||||
toggleHighlight,
|
||||
getRecordingTags,
|
||||
getAvailableTagsForRecording,
|
||||
filterByTag,
|
||||
buildSearchQuery,
|
||||
applyAdvancedFilters,
|
||||
clearAllFilters,
|
||||
clearTagFilter,
|
||||
addTagToSelection,
|
||||
removeTagFromSelection,
|
||||
applyTagDefaults,
|
||||
pollInboxRecordings
|
||||
};
|
||||
}
|
||||
450
static/js/modules/composables/reprocess.js
Normal file
450
static/js/modules/composables/reprocess.js
Normal file
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* Reprocessing composable
|
||||
* Handles reprocessing transcription and summary
|
||||
*/
|
||||
|
||||
import * as IncognitoStorage from '../db/incognito-storage.js';
|
||||
|
||||
export function useReprocess(state, utils) {
|
||||
const { nextTick } = Vue;
|
||||
|
||||
const {
|
||||
showReprocessModal, showResetModal, reprocessType,
|
||||
reprocessRecording, recordingToReset, selectedRecording,
|
||||
recordings, asrReprocessOptions, summaryReprocessPromptSource,
|
||||
summaryReprocessSelectedTagId, summaryReprocessCustomPrompt,
|
||||
availableTags, processingProgress, processingMessage,
|
||||
currentlyProcessingFile, uploadQueue
|
||||
} = state;
|
||||
|
||||
const { showToast, setGlobalError, onChatComplete } = utils;
|
||||
|
||||
// Store for active polling intervals
|
||||
const reprocessingPolls = new Map();
|
||||
|
||||
// =========================================
|
||||
// Reprocess Modal
|
||||
// =========================================
|
||||
|
||||
const openReprocessModal = (type, recording = null) => {
|
||||
reprocessType.value = type;
|
||||
reprocessRecording.value = recording || selectedRecording.value;
|
||||
showReprocessModal.value = true;
|
||||
|
||||
// Reset options
|
||||
if (type === 'transcription') {
|
||||
asrReprocessOptions.language = '';
|
||||
asrReprocessOptions.min_speakers = '';
|
||||
asrReprocessOptions.max_speakers = '';
|
||||
} else {
|
||||
summaryReprocessPromptSource.value = 'default';
|
||||
summaryReprocessSelectedTagId.value = '';
|
||||
summaryReprocessCustomPrompt.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const closeReprocessModal = () => {
|
||||
showReprocessModal.value = false;
|
||||
reprocessRecording.value = null;
|
||||
reprocessType.value = null;
|
||||
};
|
||||
|
||||
const confirmReprocess = openReprocessModal;
|
||||
const cancelReprocess = closeReprocessModal;
|
||||
|
||||
// =========================================
|
||||
// Reset Status
|
||||
// =========================================
|
||||
|
||||
const confirmReset = (recording) => {
|
||||
recordingToReset.value = recording;
|
||||
showResetModal.value = true;
|
||||
};
|
||||
|
||||
const cancelReset = () => {
|
||||
showResetModal.value = false;
|
||||
recordingToReset.value = null;
|
||||
};
|
||||
|
||||
const executeReset = async () => {
|
||||
if (!recordingToReset.value) return;
|
||||
|
||||
const recordingId = recordingToReset.value.id;
|
||||
|
||||
// Close the modal first
|
||||
cancelReset();
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
const response = await fetch(`/recording/${recordingId}/reset_status`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to reset recording status');
|
||||
|
||||
// Update recording status in list
|
||||
const index = recordings.value.findIndex(r => r.id === recordingId);
|
||||
if (index !== -1) {
|
||||
recordings.value[index].status = 'FAILED';
|
||||
}
|
||||
|
||||
if (selectedRecording.value?.id === recordingId) {
|
||||
selectedRecording.value.status = 'FAILED';
|
||||
}
|
||||
|
||||
showToast('Recording status reset to FAILED', 'fa-undo');
|
||||
} catch (error) {
|
||||
setGlobalError(`Failed to reset status: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const executeReprocess = async () => {
|
||||
if (!reprocessRecording.value || !reprocessType.value) return;
|
||||
|
||||
const recordingId = reprocessRecording.value.id;
|
||||
const type = reprocessType.value;
|
||||
|
||||
closeReprocessModal();
|
||||
|
||||
if (type === 'transcription') {
|
||||
await reprocessTranscription(
|
||||
recordingId,
|
||||
asrReprocessOptions.language,
|
||||
asrReprocessOptions.min_speakers,
|
||||
asrReprocessOptions.max_speakers
|
||||
);
|
||||
} else {
|
||||
await reprocessSummary(
|
||||
recordingId,
|
||||
summaryReprocessPromptSource.value,
|
||||
summaryReprocessSelectedTagId.value,
|
||||
summaryReprocessCustomPrompt.value
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// =========================================
|
||||
// Transcription Reprocessing
|
||||
// =========================================
|
||||
|
||||
const reprocessTranscription = async (recordingId, language, minSpeakers, maxSpeakers) => {
|
||||
if (!recordingId) {
|
||||
setGlobalError('No recording ID provided for reprocessing.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
const requestBody = {
|
||||
language: language || '' // Always send language - empty string means auto-detect
|
||||
};
|
||||
if (minSpeakers && minSpeakers !== '') requestBody.min_speakers = parseInt(minSpeakers);
|
||||
if (maxSpeakers && maxSpeakers !== '') requestBody.max_speakers = parseInt(maxSpeakers);
|
||||
|
||||
const response = await fetch(`/recording/${recordingId}/reprocess_transcription`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to start transcription reprocessing');
|
||||
|
||||
// Update recording status in list
|
||||
const index = recordings.value.findIndex(r => r.id === recordingId);
|
||||
if (index !== -1) {
|
||||
recordings.value[index].status = 'PROCESSING';
|
||||
}
|
||||
|
||||
if (selectedRecording.value?.id === recordingId) {
|
||||
selectedRecording.value.status = 'PROCESSING';
|
||||
}
|
||||
|
||||
showToast('Transcription reprocessing started', 'fa-sync-alt');
|
||||
|
||||
// Start polling for progress
|
||||
startReprocessingPoll(recordingId);
|
||||
} catch (error) {
|
||||
setGlobalError(`Failed to start transcription reprocessing: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// =========================================
|
||||
// Summary Reprocessing
|
||||
// =========================================
|
||||
|
||||
const reprocessSummary = async (recordingId, promptSource, selectedTagId, customPrompt) => {
|
||||
if (!recordingId) {
|
||||
setGlobalError('No recording ID provided for reprocessing.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
const requestBody = { reprocess_summary: true };
|
||||
|
||||
if (promptSource === 'tag' && selectedTagId) {
|
||||
const selectedTag = availableTags.value.find(t => t.id == selectedTagId);
|
||||
if (selectedTag && selectedTag.custom_prompt) {
|
||||
requestBody.custom_prompt = selectedTag.custom_prompt;
|
||||
}
|
||||
} else if (promptSource === 'custom' && customPrompt) {
|
||||
requestBody.custom_prompt = customPrompt;
|
||||
}
|
||||
|
||||
const response = await fetch(`/recording/${recordingId}/reprocess_summary`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to start summary reprocessing');
|
||||
|
||||
// Update recording status in list
|
||||
const index = recordings.value.findIndex(r => r.id === recordingId);
|
||||
if (index !== -1) {
|
||||
recordings.value[index].status = 'SUMMARIZING';
|
||||
}
|
||||
|
||||
if (selectedRecording.value?.id === recordingId) {
|
||||
selectedRecording.value.status = 'SUMMARIZING';
|
||||
}
|
||||
|
||||
showToast('Summary reprocessing started', 'fa-sync-alt');
|
||||
|
||||
// Start polling for progress
|
||||
startReprocessingPoll(recordingId);
|
||||
} catch (error) {
|
||||
setGlobalError(`Failed to start summary reprocessing: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// =========================================
|
||||
// Generate Summary
|
||||
// =========================================
|
||||
|
||||
const generateSummary = async () => {
|
||||
if (!selectedRecording.value) return;
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
// Check if this is an incognito recording
|
||||
if (selectedRecording.value.incognito === true) {
|
||||
// Use incognito summary endpoint - generate synchronously
|
||||
const response = await fetch('/api/recordings/incognito/summary', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
transcription: selectedRecording.value.transcription
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to generate summary');
|
||||
|
||||
// Update the incognito recording with the new summary
|
||||
selectedRecording.value.summary = data.summary;
|
||||
selectedRecording.value.summary_html = data.summary_html;
|
||||
|
||||
// Update sessionStorage
|
||||
IncognitoStorage.updateIncognitoRecording({
|
||||
summary: data.summary,
|
||||
summary_html: data.summary_html
|
||||
});
|
||||
|
||||
showToast('Summary generated', 'fa-file-alt');
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular recording - use existing flow
|
||||
const response = await fetch(`/recording/${selectedRecording.value.id}/generate_summary`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to start summary generation');
|
||||
|
||||
selectedRecording.value.status = 'SUMMARIZING';
|
||||
|
||||
const recordingInList = recordings.value.find(r => r.id === selectedRecording.value.id);
|
||||
if (recordingInList) {
|
||||
recordingInList.status = 'SUMMARIZING';
|
||||
}
|
||||
|
||||
showToast('Summary generation started', 'fa-file-alt');
|
||||
|
||||
// Start polling for progress
|
||||
startReprocessingPoll(selectedRecording.value.id);
|
||||
} catch (error) {
|
||||
setGlobalError(`Failed to generate summary: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// =========================================
|
||||
// Progress Polling
|
||||
// =========================================
|
||||
|
||||
const startReprocessingPoll = (recordingId) => {
|
||||
// Stop existing poll if any
|
||||
stopReprocessingPoll(recordingId);
|
||||
|
||||
// Track if we've already fetched full data for SUMMARIZING status
|
||||
let hasFetchedForSummarizing = false;
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
// Use lightweight status-only endpoint
|
||||
const response = await fetch(`/recording/${recordingId}/status`);
|
||||
if (!response.ok) throw new Error('Status check failed');
|
||||
|
||||
const statusData = await response.json();
|
||||
|
||||
// Update status in recordings list
|
||||
const index = recordings.value.findIndex(r => r.id === recordingId);
|
||||
|
||||
if (index !== -1) {
|
||||
// Create new object to ensure Vue reactivity
|
||||
recordings.value[index] = {
|
||||
...recordings.value[index],
|
||||
status: statusData.status
|
||||
};
|
||||
}
|
||||
|
||||
// Update selectedRecording with new object reference for reactivity
|
||||
if (selectedRecording.value?.id === recordingId) {
|
||||
selectedRecording.value = {
|
||||
...selectedRecording.value,
|
||||
status: statusData.status
|
||||
};
|
||||
}
|
||||
|
||||
// Check if summarization has started (fetch transcript) or processing is complete
|
||||
if (statusData.status === 'SUMMARIZING' || statusData.status === 'COMPLETED') {
|
||||
// Only fetch once when status first becomes SUMMARIZING
|
||||
const shouldFetch = (statusData.status === 'SUMMARIZING' && !hasFetchedForSummarizing) ||
|
||||
statusData.status === 'COMPLETED';
|
||||
|
||||
if (shouldFetch) {
|
||||
// Mark that we've fetched for SUMMARIZING
|
||||
if (statusData.status === 'SUMMARIZING') {
|
||||
hasFetchedForSummarizing = true;
|
||||
}
|
||||
|
||||
// Only stop polling if COMPLETED, keep polling during SUMMARIZING
|
||||
if (statusData.status === 'COMPLETED') {
|
||||
stopReprocessingPoll(recordingId);
|
||||
}
|
||||
|
||||
// Fetch the full recording with updated data
|
||||
const fullResponse = await fetch(`/api/recordings/${recordingId}`);
|
||||
|
||||
if (fullResponse.ok) {
|
||||
const fullData = await fullResponse.json();
|
||||
|
||||
// Update in recordings list first
|
||||
const currentIndex = recordings.value.findIndex(r => r.id === recordingId);
|
||||
if (currentIndex !== -1) {
|
||||
recordings.value[currentIndex] = fullData;
|
||||
}
|
||||
|
||||
// Always update selectedRecording if it's the current recording
|
||||
if (selectedRecording.value?.id === recordingId) {
|
||||
selectedRecording.value = fullData;
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
if (statusData.status === 'COMPLETED') {
|
||||
showToast('Processing completed!', 'fa-check-circle');
|
||||
// Refresh token budget after LLM operations complete
|
||||
if (onChatComplete) onChatComplete();
|
||||
}
|
||||
}
|
||||
} else if (statusData.status === 'FAILED') {
|
||||
stopReprocessingPoll(recordingId);
|
||||
|
||||
// Fetch full recording data to get error details for display
|
||||
try {
|
||||
const failedResponse = await fetch(`/api/recordings/${recordingId}`);
|
||||
if (failedResponse.ok) {
|
||||
const failedData = await failedResponse.json();
|
||||
|
||||
// Update in recordings list
|
||||
const currentIndex = recordings.value.findIndex(r => r.id === recordingId);
|
||||
if (currentIndex !== -1) {
|
||||
recordings.value[currentIndex] = failedData;
|
||||
}
|
||||
|
||||
// Update selectedRecording to show error in transcription panel
|
||||
if (selectedRecording.value?.id === recordingId) {
|
||||
selectedRecording.value = failedData;
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch error details:', err);
|
||||
}
|
||||
|
||||
showToast('Processing failed', 'fa-exclamation-circle');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Polling error:', error);
|
||||
stopReprocessingPoll(recordingId);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
reprocessingPolls.set(recordingId, pollInterval);
|
||||
};
|
||||
|
||||
const stopReprocessingPoll = (recordingId) => {
|
||||
if (reprocessingPolls.has(recordingId)) {
|
||||
clearInterval(reprocessingPolls.get(recordingId));
|
||||
reprocessingPolls.delete(recordingId);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// Reprocess modal
|
||||
openReprocessModal,
|
||||
closeReprocessModal,
|
||||
confirmReprocess,
|
||||
cancelReprocess,
|
||||
executeReprocess,
|
||||
|
||||
// Reset status
|
||||
confirmReset,
|
||||
cancelReset,
|
||||
executeReset,
|
||||
|
||||
// Transcription
|
||||
reprocessTranscription,
|
||||
|
||||
// Summary
|
||||
reprocessSummary,
|
||||
generateSummary,
|
||||
|
||||
// Polling
|
||||
startReprocessingPoll,
|
||||
stopReprocessingPoll
|
||||
};
|
||||
}
|
||||
659
static/js/modules/composables/sharing.js
Normal file
659
static/js/modules/composables/sharing.js
Normal file
@@ -0,0 +1,659 @@
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
}
|
||||
1251
static/js/modules/composables/speakers.js
Normal file
1251
static/js/modules/composables/speakers.js
Normal file
File diff suppressed because it is too large
Load Diff
297
static/js/modules/composables/tags.js
Normal file
297
static/js/modules/composables/tags.js
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Tags Management Composable
|
||||
* Handles tag operations for recordings
|
||||
*/
|
||||
|
||||
const { computed, ref } = Vue;
|
||||
|
||||
export function useTags({
|
||||
recordings,
|
||||
availableTags,
|
||||
selectedRecording,
|
||||
showEditTagsModal,
|
||||
editingRecording,
|
||||
tagSearchFilter,
|
||||
showToast,
|
||||
setGlobalError
|
||||
}) {
|
||||
// State (using passed refs from parent)
|
||||
|
||||
// --- Tag Drag-and-Drop State for Edit Modal ---
|
||||
const modalDraggedTagIndex = ref(null);
|
||||
const modalDragOverTagIndex = ref(null);
|
||||
|
||||
// Computed
|
||||
const getRecordingTags = (recording) => {
|
||||
if (!recording || !recording.tags) return [];
|
||||
return recording.tags;
|
||||
};
|
||||
|
||||
const getAvailableTagsForRecording = (recording) => {
|
||||
if (!recording || !availableTags.value) return [];
|
||||
const recordingTagIds = getRecordingTags(recording).map(tag => tag.id);
|
||||
return availableTags.value.filter(tag => !recordingTagIds.includes(tag.id));
|
||||
};
|
||||
|
||||
const filteredAvailableTagsForModal = computed(() => {
|
||||
if (!editingRecording.value) return [];
|
||||
const availableTagsForRec = getAvailableTagsForRecording(editingRecording.value);
|
||||
if (!tagSearchFilter.value) return availableTagsForRec;
|
||||
|
||||
const filter = tagSearchFilter.value.toLowerCase();
|
||||
return availableTagsForRec.filter(tag =>
|
||||
tag.name.toLowerCase().includes(filter)
|
||||
);
|
||||
});
|
||||
|
||||
// Methods
|
||||
const editRecordingTags = (recording) => {
|
||||
editingRecording.value = recording;
|
||||
tagSearchFilter.value = '';
|
||||
showEditTagsModal.value = true;
|
||||
};
|
||||
|
||||
const closeEditTagsModal = () => {
|
||||
showEditTagsModal.value = false;
|
||||
editingRecording.value = null;
|
||||
tagSearchFilter.value = '';
|
||||
};
|
||||
|
||||
const addTagToRecording = async (tagId) => {
|
||||
if (!tagId || !editingRecording.value) return;
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
const response = await fetch(`/api/recordings/${editingRecording.value.id}/tags`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({ tag_id: tagId })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to add tag');
|
||||
}
|
||||
|
||||
// Update local recording data
|
||||
const tagToAdd = availableTags.value.find(tag => tag.id == tagId);
|
||||
if (tagToAdd) {
|
||||
// Check if tag already exists to prevent duplicates
|
||||
const tagExists = editingRecording.value.tags?.some(t => t.id === tagToAdd.id);
|
||||
if (!tagExists) {
|
||||
if (!editingRecording.value.tags) {
|
||||
editingRecording.value.tags = [];
|
||||
}
|
||||
editingRecording.value.tags.push(tagToAdd);
|
||||
}
|
||||
|
||||
// Also update in recordings list (only if different object)
|
||||
const recordingInList = recordings.value.find(r => r.id === editingRecording.value.id);
|
||||
if (recordingInList && recordingInList !== editingRecording.value) {
|
||||
const tagExistsInList = recordingInList.tags?.some(t => t.id === tagToAdd.id);
|
||||
if (!tagExistsInList) {
|
||||
if (!recordingInList.tags) {
|
||||
recordingInList.tags = [];
|
||||
}
|
||||
recordingInList.tags.push(tagToAdd);
|
||||
}
|
||||
}
|
||||
|
||||
// Update selectedRecording if it matches (only if different object)
|
||||
if (selectedRecording.value &&
|
||||
selectedRecording.value.id === editingRecording.value.id &&
|
||||
selectedRecording.value !== editingRecording.value) {
|
||||
const tagExistsInSelected = selectedRecording.value.tags?.some(t => t.id === tagToAdd.id);
|
||||
if (!tagExistsInSelected) {
|
||||
if (!selectedRecording.value.tags) {
|
||||
selectedRecording.value.tags = [];
|
||||
}
|
||||
selectedRecording.value.tags.push(tagToAdd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showToast('Tag added successfully', 'fa-check-circle', 2000, 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error adding tag to recording:', error);
|
||||
setGlobalError(`Failed to add tag: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTagFromRecording = async (tagId) => {
|
||||
if (!editingRecording.value) return;
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
const response = await fetch(`/api/recordings/${editingRecording.value.id}/tags/${tagId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to remove tag');
|
||||
}
|
||||
|
||||
// Update local recording data
|
||||
editingRecording.value.tags = editingRecording.value.tags.filter(tag => tag.id !== tagId);
|
||||
|
||||
// Also update in recordings list
|
||||
const recordingInList = recordings.value.find(r => r.id === editingRecording.value.id);
|
||||
if (recordingInList && recordingInList !== editingRecording.value && recordingInList.tags) {
|
||||
recordingInList.tags = recordingInList.tags.filter(tag => tag.id !== tagId);
|
||||
}
|
||||
|
||||
// Update selectedRecording if it matches
|
||||
if (selectedRecording.value && selectedRecording.value.id === editingRecording.value.id && selectedRecording.value.tags) {
|
||||
selectedRecording.value.tags = selectedRecording.value.tags.filter(tag => tag.id !== tagId);
|
||||
}
|
||||
|
||||
showToast('Tag removed successfully', 'fa-check-circle', 2000, 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error removing tag from recording:', error);
|
||||
setGlobalError(`Failed to remove tag: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Modal Tag Reordering ---
|
||||
|
||||
const reorderModalTags = async (fromIndex, toIndex) => {
|
||||
if (!editingRecording.value || !editingRecording.value.tags) return;
|
||||
|
||||
// Reorder locally first for immediate visual feedback
|
||||
const tags = [...editingRecording.value.tags];
|
||||
const [removed] = tags.splice(fromIndex, 1);
|
||||
tags.splice(toIndex, 0, removed);
|
||||
editingRecording.value.tags = tags;
|
||||
|
||||
// Update in recordings list
|
||||
const recordingInList = recordings.value.find(r => r.id === editingRecording.value.id);
|
||||
if (recordingInList && recordingInList !== editingRecording.value) {
|
||||
recordingInList.tags = [...tags];
|
||||
}
|
||||
|
||||
// Update selectedRecording if it matches
|
||||
if (selectedRecording.value && selectedRecording.value.id === editingRecording.value.id) {
|
||||
selectedRecording.value.tags = [...tags];
|
||||
}
|
||||
|
||||
// Persist to backend
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
const tagIds = tags.map(t => t.id);
|
||||
|
||||
const response = await fetch(`/api/recordings/${editingRecording.value.id}/tags/reorder`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({ tag_ids: tagIds })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to reorder tags');
|
||||
}
|
||||
|
||||
showToast('Tags reordered', 'fa-arrows-alt', 1500, 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error reordering tags:', error);
|
||||
setGlobalError(`Failed to save tag order: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// === MOUSE DRAG HANDLERS (Modal) ===
|
||||
const handleModalTagDragStart = (index, event) => {
|
||||
modalDraggedTagIndex.value = index;
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', index.toString());
|
||||
};
|
||||
|
||||
const handleModalTagDragOver = (index, event) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
modalDragOverTagIndex.value = index;
|
||||
};
|
||||
|
||||
const handleModalTagDrop = (targetIndex, event) => {
|
||||
event.preventDefault();
|
||||
if (modalDraggedTagIndex.value !== null && modalDraggedTagIndex.value !== targetIndex) {
|
||||
reorderModalTags(modalDraggedTagIndex.value, targetIndex);
|
||||
}
|
||||
modalDraggedTagIndex.value = null;
|
||||
modalDragOverTagIndex.value = null;
|
||||
};
|
||||
|
||||
const handleModalTagDragEnd = () => {
|
||||
modalDraggedTagIndex.value = null;
|
||||
modalDragOverTagIndex.value = null;
|
||||
};
|
||||
|
||||
// === TOUCH HANDLERS (Modal - Mobile) ===
|
||||
let modalTouchStartIndex = null;
|
||||
|
||||
const handleModalTagTouchStart = (index, event) => {
|
||||
modalTouchStartIndex = index;
|
||||
modalDraggedTagIndex.value = index;
|
||||
};
|
||||
|
||||
const handleModalTagTouchMove = (event) => {
|
||||
if (modalTouchStartIndex === null) return;
|
||||
event.preventDefault();
|
||||
|
||||
const touch = event.touches[0];
|
||||
const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY);
|
||||
const tagElement = elementBelow?.closest('[data-modal-tag-index]');
|
||||
|
||||
if (tagElement) {
|
||||
const targetIndex = parseInt(tagElement.dataset.modalTagIndex);
|
||||
modalDragOverTagIndex.value = targetIndex;
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalTagTouchEnd = () => {
|
||||
if (modalTouchStartIndex !== null && modalDragOverTagIndex.value !== null &&
|
||||
modalTouchStartIndex !== modalDragOverTagIndex.value) {
|
||||
reorderModalTags(modalTouchStartIndex, modalDragOverTagIndex.value);
|
||||
}
|
||||
modalTouchStartIndex = null;
|
||||
modalDraggedTagIndex.value = null;
|
||||
modalDragOverTagIndex.value = null;
|
||||
};
|
||||
|
||||
return {
|
||||
// Computed
|
||||
filteredAvailableTagsForModal,
|
||||
|
||||
// Methods
|
||||
getRecordingTags,
|
||||
getAvailableTagsForRecording,
|
||||
editRecordingTags,
|
||||
closeEditTagsModal,
|
||||
addTagToRecording,
|
||||
removeTagFromRecording,
|
||||
|
||||
// Modal Tag Drag-and-Drop
|
||||
modalDraggedTagIndex,
|
||||
modalDragOverTagIndex,
|
||||
handleModalTagDragStart,
|
||||
handleModalTagDragOver,
|
||||
handleModalTagDrop,
|
||||
handleModalTagDragEnd,
|
||||
handleModalTagTouchStart,
|
||||
handleModalTagTouchMove,
|
||||
handleModalTagTouchEnd
|
||||
};
|
||||
}
|
||||
286
static/js/modules/composables/tokens.js
Normal file
286
static/js/modules/composables/tokens.js
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* API Tokens Management Composable
|
||||
* Handles API token operations for user authentication
|
||||
*/
|
||||
|
||||
const { ref, computed } = Vue;
|
||||
|
||||
export function useTokens({ showToast, setGlobalError }) {
|
||||
// State
|
||||
const tokens = ref([]);
|
||||
const isLoadingTokens = ref(false);
|
||||
const showCreateTokenModal = ref(false);
|
||||
const showTokenSecretModal = ref(false);
|
||||
const newTokenSecret = ref('');
|
||||
const newTokenData = ref(null);
|
||||
const tokenForm = ref({
|
||||
name: '',
|
||||
expires_in_days: 0 // 0 = no expiration
|
||||
});
|
||||
|
||||
// Computed
|
||||
const hasTokens = computed(() => tokens.value.length > 0);
|
||||
|
||||
const activeTokens = computed(() => {
|
||||
return tokens.value.filter(token => !token.revoked && !isTokenExpired(token));
|
||||
});
|
||||
|
||||
const expiredOrRevokedTokens = computed(() => {
|
||||
return tokens.value.filter(token => token.revoked || isTokenExpired(token));
|
||||
});
|
||||
|
||||
// Helper methods
|
||||
const isTokenExpired = (token) => {
|
||||
if (!token.expires_at) return false;
|
||||
const expiryDate = new Date(token.expires_at);
|
||||
return expiryDate < new Date();
|
||||
};
|
||||
|
||||
const formatTokenDate = (dateString) => {
|
||||
if (!dateString) return 'Never';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
||||
};
|
||||
|
||||
const getTokenStatus = (token) => {
|
||||
if (token.revoked) return 'revoked';
|
||||
if (isTokenExpired(token)) return 'expired';
|
||||
return 'active';
|
||||
};
|
||||
|
||||
const getTokenStatusClass = (token) => {
|
||||
const status = getTokenStatus(token);
|
||||
const baseClasses = 'px-2 py-1 text-xs font-semibold rounded';
|
||||
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return `${baseClasses} bg-green-100 text-green-800`;
|
||||
case 'expired':
|
||||
return `${baseClasses} bg-yellow-100 text-yellow-800`;
|
||||
case 'revoked':
|
||||
return `${baseClasses} bg-red-100 text-red-800`;
|
||||
default:
|
||||
return `${baseClasses} bg-gray-100 text-gray-800`;
|
||||
}
|
||||
};
|
||||
|
||||
// API methods
|
||||
const loadTokens = async () => {
|
||||
isLoadingTokens.value = true;
|
||||
try {
|
||||
const response = await fetch('/api/tokens', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load tokens');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
tokens.value = data.tokens || [];
|
||||
} catch (error) {
|
||||
console.error('Error loading tokens:', error);
|
||||
setGlobalError('Failed to load API tokens: ' + error.message);
|
||||
} finally {
|
||||
isLoadingTokens.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const createToken = async () => {
|
||||
if (!tokenForm.value.name || tokenForm.value.name.trim() === '') {
|
||||
showToast('Please enter a token name', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
const response = await fetch('/api/tokens', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: tokenForm.value.name,
|
||||
expires_in_days: parseInt(tokenForm.value.expires_in_days) || 0
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to create token');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Store the plaintext token to show to user (only shown once)
|
||||
newTokenSecret.value = data.token;
|
||||
newTokenData.value = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
created_at: data.created_at,
|
||||
expires_at: data.expires_at
|
||||
};
|
||||
|
||||
// Add to tokens list (without the plaintext token)
|
||||
tokens.value.unshift({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
created_at: data.created_at,
|
||||
last_used_at: data.last_used_at,
|
||||
expires_at: data.expires_at,
|
||||
revoked: data.revoked
|
||||
});
|
||||
|
||||
// Reset form
|
||||
tokenForm.value = {
|
||||
name: '',
|
||||
expires_in_days: 0
|
||||
};
|
||||
|
||||
// Close create modal and show secret modal
|
||||
showCreateTokenModal.value = false;
|
||||
showTokenSecretModal.value = true;
|
||||
|
||||
showToast('API token created successfully', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error creating token:', error);
|
||||
showToast('Failed to create token: ' + error.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const revokeToken = async (tokenId, tokenName) => {
|
||||
if (!confirm(`Are you sure you want to revoke the token "${tokenName}"? This action cannot be undone and any applications using this token will lose access.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
const response = await fetch(`/api/tokens/${tokenId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to revoke token');
|
||||
}
|
||||
|
||||
// Remove from local list
|
||||
tokens.value = tokens.value.filter(t => t.id !== tokenId);
|
||||
|
||||
showToast('Token revoked successfully', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error revoking token:', error);
|
||||
showToast('Failed to revoke token: ' + error.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const updateTokenName = async (tokenId, newName) => {
|
||||
if (!newName || newName.trim() === '') {
|
||||
showToast('Token name cannot be empty', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
const response = await fetch(`/api/tokens/${tokenId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({ name: newName })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to update token');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Update local token
|
||||
const token = tokens.value.find(t => t.id === tokenId);
|
||||
if (token) {
|
||||
token.name = data.name;
|
||||
}
|
||||
|
||||
showToast('Token name updated', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error updating token:', error);
|
||||
showToast('Failed to update token: ' + error.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const copyTokenToClipboard = async (token) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(token);
|
||||
showToast('Token copied to clipboard', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error copying token:', error);
|
||||
showToast('Failed to copy token to clipboard', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateTokenModal = () => {
|
||||
tokenForm.value = {
|
||||
name: '',
|
||||
expires_in_days: 0
|
||||
};
|
||||
showCreateTokenModal.value = true;
|
||||
};
|
||||
|
||||
const closeCreateTokenModal = () => {
|
||||
showCreateTokenModal.value = false;
|
||||
tokenForm.value = {
|
||||
name: '',
|
||||
expires_in_days: 0
|
||||
};
|
||||
};
|
||||
|
||||
const closeTokenSecretModal = () => {
|
||||
showTokenSecretModal.value = false;
|
||||
newTokenSecret.value = '';
|
||||
newTokenData.value = null;
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
tokens,
|
||||
isLoadingTokens,
|
||||
showCreateTokenModal,
|
||||
showTokenSecretModal,
|
||||
newTokenSecret,
|
||||
newTokenData,
|
||||
tokenForm,
|
||||
|
||||
// Computed
|
||||
hasTokens,
|
||||
activeTokens,
|
||||
expiredOrRevokedTokens,
|
||||
|
||||
// Methods
|
||||
isTokenExpired,
|
||||
formatTokenDate,
|
||||
getTokenStatus,
|
||||
getTokenStatusClass,
|
||||
loadTokens,
|
||||
createToken,
|
||||
revokeToken,
|
||||
updateTokenName,
|
||||
copyTokenToClipboard,
|
||||
openCreateTokenModal,
|
||||
closeCreateTokenModal,
|
||||
closeTokenSecretModal
|
||||
};
|
||||
}
|
||||
484
static/js/modules/composables/transcription.js
Normal file
484
static/js/modules/composables/transcription.js
Normal file
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* Transcription editing composable
|
||||
* Handles ASR editor, text editor, and segment management
|
||||
*/
|
||||
|
||||
export function useTranscription(state, utils) {
|
||||
const {
|
||||
showTextEditorModal, showAsrEditorModal, selectedRecording,
|
||||
editingTranscriptionContent, editingSegments, availableSpeakers,
|
||||
recordings, dropdownPositions, openAsrDropdownIndex
|
||||
} = state;
|
||||
|
||||
const { showToast, setGlobalError, nextTick } = utils;
|
||||
|
||||
// =========================================
|
||||
// Text Editor Modal
|
||||
// =========================================
|
||||
|
||||
const openTranscriptionEditor = () => {
|
||||
if (!selectedRecording.value || !selectedRecording.value.transcription) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if transcription is JSON (ASR format)
|
||||
try {
|
||||
const parsed = JSON.parse(selectedRecording.value.transcription);
|
||||
if (Array.isArray(parsed)) {
|
||||
openAsrEditorModal();
|
||||
} else {
|
||||
openTextEditorModal();
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, use text editor
|
||||
openTextEditorModal();
|
||||
}
|
||||
};
|
||||
|
||||
const openTextEditorModal = () => {
|
||||
if (!selectedRecording.value) return;
|
||||
editingTranscriptionContent.value = selectedRecording.value.transcription || '';
|
||||
showTextEditorModal.value = true;
|
||||
};
|
||||
|
||||
const closeTextEditorModal = () => {
|
||||
showTextEditorModal.value = false;
|
||||
editingTranscriptionContent.value = '';
|
||||
};
|
||||
|
||||
const saveTranscription = async () => {
|
||||
if (!selectedRecording.value) return;
|
||||
await saveTranscriptionContent(editingTranscriptionContent.value);
|
||||
closeTextEditorModal();
|
||||
};
|
||||
|
||||
// =========================================
|
||||
// ASR Editor Modal
|
||||
// =========================================
|
||||
|
||||
// Helper to pause outer audio player when opening modals with their own player
|
||||
const pauseOuterAudioPlayer = () => {
|
||||
const outerAudio = document.querySelector('#rightMainColumn audio') || document.querySelector('#rightMainColumn video') ||
|
||||
document.querySelector('.detail-view audio:not(.fixed audio)') || document.querySelector('.detail-view video:not(.fixed video)');
|
||||
if (outerAudio && !outerAudio.paused) {
|
||||
outerAudio.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const openAsrEditorModal = async () => {
|
||||
if (!selectedRecording.value) return;
|
||||
|
||||
// Pause outer audio player to avoid conflicts with modal's player
|
||||
pauseOuterAudioPlayer();
|
||||
|
||||
try {
|
||||
const segments = JSON.parse(selectedRecording.value.transcription);
|
||||
|
||||
// Populate available speakers from THIS recording only
|
||||
const speakersInTranscript = [...new Set(segments.map(s => s.speaker))].sort();
|
||||
availableSpeakers.value = speakersInTranscript;
|
||||
|
||||
editingSegments.value = segments.map((s, i) => ({
|
||||
...s,
|
||||
id: i,
|
||||
showSuggestions: false,
|
||||
filteredSpeakers: [...speakersInTranscript]
|
||||
}));
|
||||
|
||||
showAsrEditorModal.value = true;
|
||||
|
||||
// Reset virtual scroll state for fresh modal render
|
||||
if (utils.resetAsrEditorScroll) {
|
||||
utils.resetAsrEditorScroll();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Could not parse transcription as JSON for ASR editor:", e);
|
||||
setGlobalError("This transcription is not in the correct format for the ASR editor.");
|
||||
}
|
||||
};
|
||||
|
||||
const closeAsrEditorModal = () => {
|
||||
// Pause any playing modal audio before closing
|
||||
const modalAudio = document.querySelector('.fixed.z-50 audio') || document.querySelector('.fixed.z-50 video');
|
||||
if (modalAudio) {
|
||||
modalAudio.pause();
|
||||
}
|
||||
// Reset modal audio state (keep main player independent)
|
||||
if (utils.resetModalAudioState) {
|
||||
utils.resetModalAudioState();
|
||||
}
|
||||
|
||||
showAsrEditorModal.value = false;
|
||||
editingSegments.value = [];
|
||||
};
|
||||
|
||||
const saveAsrTranscription = async () => {
|
||||
if (!selectedRecording.value) return;
|
||||
|
||||
// Remove extra UI fields and save the rest
|
||||
const contentToSave = JSON.stringify(editingSegments.value.map(({ id, showSuggestions, filteredSpeakers, ...rest }) => rest));
|
||||
|
||||
await saveTranscriptionContent(contentToSave);
|
||||
closeAsrEditorModal();
|
||||
};
|
||||
|
||||
// =========================================
|
||||
// Segment Management
|
||||
// =========================================
|
||||
|
||||
const adjustTime = (index, field, amount) => {
|
||||
if (editingSegments.value[index]) {
|
||||
editingSegments.value[index][field] = Math.max(0,
|
||||
editingSegments.value[index][field] + amount
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const filterSpeakerSuggestions = (index) => {
|
||||
const segment = editingSegments.value[index];
|
||||
if (segment) {
|
||||
const query = segment.speaker?.toLowerCase() || '';
|
||||
if (query === '') {
|
||||
segment.filteredSpeakers = [...availableSpeakers.value];
|
||||
} else {
|
||||
segment.filteredSpeakers = availableSpeakers.value.filter(
|
||||
speaker => speaker.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// O(1) dropdown management using single ref instead of O(n) forEach
|
||||
const openSpeakerSuggestions = (index) => {
|
||||
if (editingSegments.value[index]) {
|
||||
// Simply set the open index - O(1) instead of O(n) forEach
|
||||
openAsrDropdownIndex.value = index;
|
||||
filterSpeakerSuggestions(index);
|
||||
updateDropdownPosition(index);
|
||||
}
|
||||
};
|
||||
|
||||
const closeSpeakerSuggestions = (index) => {
|
||||
// Only close if this index is currently open
|
||||
if (openAsrDropdownIndex.value === index) {
|
||||
openAsrDropdownIndex.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const closeAllSpeakerSuggestions = () => {
|
||||
// O(1) instead of O(n) - just set to null
|
||||
openAsrDropdownIndex.value = null;
|
||||
};
|
||||
|
||||
// Helper to check if a dropdown is open (for template v-if)
|
||||
const isDropdownOpen = (index) => {
|
||||
return openAsrDropdownIndex.value === index;
|
||||
};
|
||||
|
||||
const getDropdownPosition = (index) => {
|
||||
const pos = dropdownPositions.value[index];
|
||||
if (pos) {
|
||||
const style = {
|
||||
left: pos.left + 'px',
|
||||
width: pos.width + 'px'
|
||||
};
|
||||
|
||||
// When opening upward, anchor from bottom so dropdown grows upward
|
||||
if (pos.openUpward) {
|
||||
style.bottom = pos.bottom + 'px';
|
||||
style.top = 'auto';
|
||||
} else {
|
||||
style.top = pos.top + 'px';
|
||||
style.bottom = 'auto';
|
||||
}
|
||||
|
||||
// Apply calculated max height
|
||||
if (pos.maxHeight) {
|
||||
style.maxHeight = pos.maxHeight + 'px';
|
||||
}
|
||||
return style;
|
||||
}
|
||||
return { top: '0px', left: '0px' };
|
||||
};
|
||||
|
||||
const updateDropdownPosition = (index) => {
|
||||
nextTick(() => {
|
||||
// Find row by data attribute to work correctly with virtual scrolling
|
||||
const row = document.querySelector(`.asr-editor-table tbody tr[data-segment-index="${index}"]`);
|
||||
if (row) {
|
||||
const cell = row.querySelector('td:first-child');
|
||||
if (cell) {
|
||||
const rect = cell.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Calculate available space above and below
|
||||
const spaceBelow = viewportHeight - rect.bottom - 10;
|
||||
const spaceAbove = rect.top - 10;
|
||||
|
||||
// Determine max height based on available space (cap at 192px which is max-h-48)
|
||||
const maxDropdownHeight = 192;
|
||||
|
||||
let top, bottom, openUpward, maxHeight;
|
||||
|
||||
if (spaceBelow >= maxDropdownHeight || spaceBelow >= spaceAbove) {
|
||||
// Open downward
|
||||
top = rect.bottom + 2;
|
||||
bottom = null;
|
||||
openUpward = false;
|
||||
maxHeight = Math.min(spaceBelow, maxDropdownHeight);
|
||||
} else {
|
||||
// Open upward - anchor from bottom so dropdown grows upward
|
||||
openUpward = true;
|
||||
maxHeight = Math.min(spaceAbove, maxDropdownHeight);
|
||||
// Bottom is distance from viewport bottom to the top of the cell
|
||||
bottom = viewportHeight - rect.top + 2;
|
||||
top = null;
|
||||
}
|
||||
|
||||
dropdownPositions.value[index] = {
|
||||
top: top,
|
||||
bottom: bottom,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
openUpward: openUpward,
|
||||
maxHeight: maxHeight
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const selectSpeaker = (index, speaker) => {
|
||||
if (editingSegments.value[index]) {
|
||||
editingSegments.value[index].speaker = speaker;
|
||||
closeSpeakerSuggestions(index);
|
||||
}
|
||||
};
|
||||
|
||||
const addSegment = () => {
|
||||
const lastSegment = editingSegments.value[editingSegments.value.length - 1];
|
||||
const newStart = lastSegment ? lastSegment.end_time : 0;
|
||||
|
||||
editingSegments.value.push({
|
||||
speaker: availableSpeakers.value[0] || 'Speaker 1',
|
||||
start_time: newStart,
|
||||
end_time: newStart + 5,
|
||||
sentence: '',
|
||||
id: editingSegments.value.length,
|
||||
showSuggestions: false,
|
||||
filteredSpeakers: [...availableSpeakers.value]
|
||||
});
|
||||
};
|
||||
|
||||
const removeSegment = (index) => {
|
||||
editingSegments.value.splice(index, 1);
|
||||
// Re-index segments
|
||||
editingSegments.value.forEach((seg, i) => {
|
||||
seg.id = i;
|
||||
});
|
||||
};
|
||||
|
||||
const addSegmentBelow = (index) => {
|
||||
const currentSegment = editingSegments.value[index];
|
||||
const nextSegment = editingSegments.value[index + 1];
|
||||
|
||||
const newStart = currentSegment.end_time;
|
||||
const newEnd = nextSegment ? nextSegment.start_time : newStart + 5;
|
||||
|
||||
editingSegments.value.splice(index + 1, 0, {
|
||||
speaker: currentSegment.speaker,
|
||||
start_time: newStart,
|
||||
end_time: newEnd,
|
||||
sentence: '',
|
||||
id: index + 1,
|
||||
showSuggestions: false,
|
||||
filteredSpeakers: [...availableSpeakers.value]
|
||||
});
|
||||
|
||||
// Re-index segments
|
||||
editingSegments.value.forEach((seg, i) => {
|
||||
seg.id = i;
|
||||
});
|
||||
};
|
||||
|
||||
const seekToSegmentTime = (time) => {
|
||||
// Find audio elements and use the one in a visible modal (z-50)
|
||||
const mediaElements = document.querySelectorAll('.fixed.z-50 audio, .fixed.z-50 video');
|
||||
const audioElement = mediaElements.length > 0 ? mediaElements[mediaElements.length - 1] : null;
|
||||
if (audioElement) {
|
||||
audioElement.currentTime = time;
|
||||
audioElement.play();
|
||||
}
|
||||
};
|
||||
|
||||
const autoResizeTextarea = (event) => {
|
||||
const textarea = event.target;
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = textarea.scrollHeight + 'px';
|
||||
};
|
||||
|
||||
// =========================================
|
||||
// Save Transcription Content
|
||||
// =========================================
|
||||
|
||||
const saveTranscriptionContent = async (content) => {
|
||||
if (!selectedRecording.value) return;
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
const response = await fetch(`/recording/${selectedRecording.value.id}/update_transcription`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({ transcription: content })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to update transcription');
|
||||
|
||||
// Update recording
|
||||
selectedRecording.value.transcription = content;
|
||||
|
||||
const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id);
|
||||
if (index !== -1) {
|
||||
recordings.value[index].transcription = content;
|
||||
}
|
||||
|
||||
showToast('Transcription updated successfully!', 'fa-check-circle');
|
||||
} catch (error) {
|
||||
setGlobalError(`Failed to save transcription: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// =========================================
|
||||
// Save Summary
|
||||
// =========================================
|
||||
|
||||
const saveSummary = async (summary) => {
|
||||
if (!selectedRecording.value) return;
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
const payload = {
|
||||
id: selectedRecording.value.id,
|
||||
title: selectedRecording.value.title,
|
||||
participants: selectedRecording.value.participants,
|
||||
notes: selectedRecording.value.notes,
|
||||
summary: summary,
|
||||
meeting_date: selectedRecording.value.meeting_date
|
||||
};
|
||||
const response = await fetch('/save', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to update summary');
|
||||
|
||||
// Update recording
|
||||
selectedRecording.value.summary = summary;
|
||||
|
||||
const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id);
|
||||
if (index !== -1) {
|
||||
recordings.value[index].summary = summary;
|
||||
}
|
||||
|
||||
showToast('Summary saved!', 'fa-check-circle');
|
||||
} catch (error) {
|
||||
setGlobalError(`Failed to save summary: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// =========================================
|
||||
// Save Notes
|
||||
// =========================================
|
||||
|
||||
const saveNotes = async (notes) => {
|
||||
if (!selectedRecording.value) return;
|
||||
|
||||
// Handle incognito recordings - save to sessionStorage only
|
||||
if (selectedRecording.value.incognito) {
|
||||
selectedRecording.value.notes = notes;
|
||||
// Update sessionStorage
|
||||
try {
|
||||
const stored = sessionStorage.getItem('speakr_incognito_recording');
|
||||
if (stored) {
|
||||
const data = JSON.parse(stored);
|
||||
data.notes = notes;
|
||||
sessionStorage.setItem('speakr_incognito_recording', JSON.stringify(data));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Incognito] Failed to save notes to sessionStorage:', e);
|
||||
}
|
||||
showToast('Notes saved (in browser only)', 'fa-check-circle');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
const response = await fetch(`/api/recordings/${selectedRecording.value.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({ notes })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to update notes');
|
||||
|
||||
// Update recording
|
||||
selectedRecording.value.notes = notes;
|
||||
|
||||
const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id);
|
||||
if (index !== -1) {
|
||||
recordings.value[index].notes = notes;
|
||||
}
|
||||
|
||||
showToast('Notes saved!', 'fa-check-circle');
|
||||
} catch (error) {
|
||||
setGlobalError(`Failed to save notes: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// Text editor
|
||||
openTranscriptionEditor,
|
||||
openTextEditorModal,
|
||||
closeTextEditorModal,
|
||||
saveTranscription,
|
||||
|
||||
// ASR editor
|
||||
openAsrEditorModal,
|
||||
closeAsrEditorModal,
|
||||
saveAsrTranscription,
|
||||
|
||||
// Segment management
|
||||
adjustTime,
|
||||
filterSpeakerSuggestions,
|
||||
openSpeakerSuggestions,
|
||||
closeSpeakerSuggestions,
|
||||
closeAllSpeakerSuggestions,
|
||||
isDropdownOpen,
|
||||
getDropdownPosition,
|
||||
updateDropdownPosition,
|
||||
selectSpeaker,
|
||||
addSegment,
|
||||
removeSegment,
|
||||
addSegmentBelow,
|
||||
seekToSegmentTime,
|
||||
autoResizeTextarea,
|
||||
|
||||
// Save
|
||||
saveTranscriptionContent,
|
||||
saveSummary,
|
||||
saveNotes
|
||||
};
|
||||
}
|
||||
2110
static/js/modules/composables/ui.js
Normal file
2110
static/js/modules/composables/ui.js
Normal file
File diff suppressed because it is too large
Load Diff
824
static/js/modules/composables/upload.js
Normal file
824
static/js/modules/composables/upload.js
Normal file
@@ -0,0 +1,824 @@
|
||||
/**
|
||||
* Upload management composable
|
||||
* Handles file uploads, queue processing, and progress tracking
|
||||
*/
|
||||
|
||||
import * as FailedUploads from '../db/failed-uploads.js';
|
||||
import * as IncognitoStorage from '../db/incognito-storage.js';
|
||||
|
||||
// Parse error message and return friendly error info
|
||||
function getFriendlyError(errorMessage, t) {
|
||||
const _t = t || ((key) => key);
|
||||
if (!errorMessage) return { title: _t('errors.processingError'), message: _t('errors.processingErrorMessage') };
|
||||
const lowerText = errorMessage.toLowerCase();
|
||||
const patterns = [
|
||||
{ patterns: ['maximum content size limit', 'file too large', '413', 'payload too large', 'exceeded'], title: _t('errors.fileTooLargeTitle'), guidance: _t('errors.enableChunkingGuidance') },
|
||||
{ patterns: ['timed out', 'timeout', 'deadline exceeded'], title: _t('errors.processingTimeout'), guidance: _t('errors.splitAudioGuidance') },
|
||||
{ patterns: ['401', 'unauthorized', 'invalid api key', 'authentication failed', 'incorrect api key'], title: _t('errors.authenticationError'), guidance: _t('errors.checkApiKeyGuidance') },
|
||||
{ patterns: ['rate limit', 'too many requests', '429', 'quota exceeded'], title: _t('errors.rateLimitExceeded'), guidance: _t('errors.waitAndRetryGuidance') },
|
||||
{ patterns: ['connection refused', 'connection reset', 'could not connect', 'network unreachable'], title: _t('errors.connectionError'), guidance: _t('errors.checkNetworkGuidance') },
|
||||
{ patterns: ['503', '502', '500', 'service unavailable', 'server error', 'internal server error'], title: _t('errors.serviceUnavailable'), guidance: _t('errors.tryAgainLaterGuidance') },
|
||||
{ patterns: ['invalid file format', 'unsupported format', 'could not decode', 'corrupt'], title: _t('errors.invalidAudioFormat'), guidance: _t('errors.convertFormatGuidance') },
|
||||
{ patterns: ['audio extraction failed', 'ffmpeg failed', 'no audio stream'], title: _t('errors.audioExtractionFailed'), guidance: _t('errors.convertStandardGuidance') },
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
for (const p of pattern.patterns) {
|
||||
if (lowerText.includes(p)) return { title: pattern.title, guidance: pattern.guidance };
|
||||
}
|
||||
}
|
||||
return { title: _t('errors.processingError'), guidance: _t('errors.processingErrorFallbackGuidance') };
|
||||
}
|
||||
|
||||
export function useUpload(state, utils) {
|
||||
const {
|
||||
uploadQueue, currentlyProcessingFile, processingProgress, processingMessage,
|
||||
isProcessingActive, pollInterval, progressPopupMinimized, progressPopupClosed,
|
||||
maxFileSizeMB, chunkingEnabled, chunkingMode, chunkingLimit, maxConcurrentUploads,
|
||||
recordings, selectedRecording, totalRecordings, globalError,
|
||||
selectedTagIds, uploadLanguage, uploadMinSpeakers, uploadMaxSpeakers, uploadHotwords, uploadInitialPrompt,
|
||||
useAsrEndpoint, connectorSupportsDiarization, asrLanguage, asrMinSpeakers, asrMaxSpeakers,
|
||||
dragover, availableTags, uploadTagSearchFilter,
|
||||
// Folder state
|
||||
availableFolders, selectedFolderId,
|
||||
// Incognito mode state
|
||||
incognitoMode, incognitoRecording, incognitoProcessing,
|
||||
// View state
|
||||
currentView,
|
||||
// Upload disclaimer state
|
||||
uploadDisclaimer, showUploadDisclaimerModal
|
||||
} = state;
|
||||
|
||||
const { computed, nextTick, ref } = Vue;
|
||||
|
||||
const { setGlobalError, showToast, formatFileSize, onChatComplete, t } = utils;
|
||||
|
||||
// Compute selected tags from IDs
|
||||
const selectedTags = computed(() => {
|
||||
return selectedTagIds.value.map(id =>
|
||||
availableTags.value.find(t => t.id === id)
|
||||
).filter(Boolean);
|
||||
});
|
||||
|
||||
// --- Tag Drag-and-Drop State ---
|
||||
const draggedTagIndex = ref(null);
|
||||
const dragOverTagIndex = ref(null);
|
||||
|
||||
// Reorder selectedTagIds array
|
||||
const reorderSelectedTags = (fromIndex, toIndex) => {
|
||||
const tagIds = [...selectedTagIds.value];
|
||||
const [removed] = tagIds.splice(fromIndex, 1);
|
||||
tagIds.splice(toIndex, 0, removed);
|
||||
selectedTagIds.value = tagIds;
|
||||
applyTagDefaults(); // Re-apply defaults since first tag may have changed
|
||||
};
|
||||
|
||||
// === MOUSE DRAG HANDLERS ===
|
||||
const handleTagDragStart = (index, event) => {
|
||||
draggedTagIndex.value = index;
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', index.toString());
|
||||
};
|
||||
|
||||
const handleTagDragOver = (index, event) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
dragOverTagIndex.value = index;
|
||||
};
|
||||
|
||||
const handleTagDrop = (targetIndex, event) => {
|
||||
event.preventDefault();
|
||||
if (draggedTagIndex.value !== null && draggedTagIndex.value !== targetIndex) {
|
||||
reorderSelectedTags(draggedTagIndex.value, targetIndex);
|
||||
}
|
||||
draggedTagIndex.value = null;
|
||||
dragOverTagIndex.value = null;
|
||||
};
|
||||
|
||||
const handleTagDragEnd = () => {
|
||||
draggedTagIndex.value = null;
|
||||
dragOverTagIndex.value = null;
|
||||
};
|
||||
|
||||
// === TOUCH HANDLERS (Mobile) ===
|
||||
let touchStartIndex = null;
|
||||
|
||||
const handleTagTouchStart = (index, event) => {
|
||||
touchStartIndex = index;
|
||||
draggedTagIndex.value = index;
|
||||
};
|
||||
|
||||
const handleTagTouchMove = (event) => {
|
||||
if (touchStartIndex === null) return;
|
||||
event.preventDefault();
|
||||
|
||||
const touch = event.touches[0];
|
||||
const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY);
|
||||
const tagElement = elementBelow?.closest('[data-tag-index]');
|
||||
|
||||
if (tagElement) {
|
||||
const targetIndex = parseInt(tagElement.dataset.tagIndex);
|
||||
dragOverTagIndex.value = targetIndex;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTagTouchEnd = () => {
|
||||
if (touchStartIndex !== null && dragOverTagIndex.value !== null &&
|
||||
touchStartIndex !== dragOverTagIndex.value) {
|
||||
reorderSelectedTags(touchStartIndex, dragOverTagIndex.value);
|
||||
}
|
||||
touchStartIndex = null;
|
||||
draggedTagIndex.value = null;
|
||||
dragOverTagIndex.value = null;
|
||||
};
|
||||
|
||||
// Handle drag events
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
dragover.value = true;
|
||||
};
|
||||
|
||||
const handleDragLeave = (e) => {
|
||||
if (e.relatedTarget && e.currentTarget.contains(e.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
dragover.value = false;
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
dragover.value = false;
|
||||
addFilesToQueue(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
addFilesToQueue(e.target.files);
|
||||
e.target.value = null;
|
||||
};
|
||||
|
||||
// Add files to the upload queue
|
||||
const addFilesToQueue = (files) => {
|
||||
let filesAdded = 0;
|
||||
for (const file of files) {
|
||||
const fileObject = file.file ? file.file : file;
|
||||
const notes = file.notes || null;
|
||||
const tags = file.tags || selectedTags.value || [];
|
||||
const asrOptions = file.asrOptions || {
|
||||
language: asrLanguage.value,
|
||||
min_speakers: asrMinSpeakers.value,
|
||||
max_speakers: asrMaxSpeakers.value
|
||||
};
|
||||
|
||||
// Check if it's an audio file or video container with audio
|
||||
const isAudioFile = fileObject && (
|
||||
fileObject.type.startsWith('audio/') ||
|
||||
fileObject.type === 'video/mp4' ||
|
||||
fileObject.type === 'video/quicktime' ||
|
||||
fileObject.type === 'video/x-msvideo' ||
|
||||
fileObject.type === 'video/webm' ||
|
||||
fileObject.name.toLowerCase().endsWith('.amr') ||
|
||||
fileObject.name.toLowerCase().endsWith('.3gp') ||
|
||||
fileObject.name.toLowerCase().endsWith('.3gpp') ||
|
||||
fileObject.name.toLowerCase().endsWith('.mp4') ||
|
||||
fileObject.name.toLowerCase().endsWith('.mov') ||
|
||||
fileObject.name.toLowerCase().endsWith('.avi') ||
|
||||
fileObject.name.toLowerCase().endsWith('.mkv') ||
|
||||
fileObject.name.toLowerCase().endsWith('.webm') ||
|
||||
fileObject.name.toLowerCase().endsWith('.weba')
|
||||
);
|
||||
|
||||
if (isAudioFile) {
|
||||
// Only check general file size limit
|
||||
if (fileObject.size > maxFileSizeMB.value * 1024 * 1024) {
|
||||
setGlobalError(t('upload.fileExceedsMaxSize', { name: fileObject.name, size: maxFileSizeMB.value }));
|
||||
continue;
|
||||
}
|
||||
|
||||
const clientId = `client-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
uploadQueue.value.push({
|
||||
file: fileObject,
|
||||
notes: notes,
|
||||
tags: tags,
|
||||
asrOptions: asrOptions,
|
||||
status: 'queued',
|
||||
recordingId: null,
|
||||
clientId: clientId,
|
||||
error: null,
|
||||
willAutoSummarize: false // Server will tell us via SUMMARIZING status
|
||||
});
|
||||
filesAdded++;
|
||||
} else if (fileObject) {
|
||||
setGlobalError(t('upload.invalidFileType', { name: fileObject.name }));
|
||||
}
|
||||
}
|
||||
if (filesAdded > 0) {
|
||||
console.log(`Added ${filesAdded} file(s) to the queue.`);
|
||||
}
|
||||
};
|
||||
|
||||
// Remove a file from the queue before processing starts
|
||||
const removeFromQueue = (clientId) => {
|
||||
const index = uploadQueue.value.findIndex(item => item.clientId === clientId);
|
||||
if (index !== -1 && (uploadQueue.value[index].status === 'queued' || uploadQueue.value[index].status === 'ready')) {
|
||||
uploadQueue.value.splice(index, 1);
|
||||
console.log(`Removed file from queue: ${clientId}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel a waiting file from the upload progress queue
|
||||
const cancelWaitingFile = (clientId) => {
|
||||
const index = uploadQueue.value.findIndex(item => item.clientId === clientId);
|
||||
if (index !== -1 && uploadQueue.value[index].status === 'ready') {
|
||||
uploadQueue.value.splice(index, 1);
|
||||
console.log(`Cancelled waiting file: ${clientId}`);
|
||||
showToast(t('upload.fileRemovedFromQueue'), 'fa-trash');
|
||||
}
|
||||
};
|
||||
|
||||
// Clear completed uploads from queue
|
||||
const clearCompletedUploads = () => {
|
||||
uploadQueue.value = uploadQueue.value.filter(item => !['completed', 'failed'].includes(item.status));
|
||||
};
|
||||
|
||||
// Start processing all queued files
|
||||
const startUpload = () => {
|
||||
const pendingFiles = uploadQueue.value.filter(item => item.status === 'queued');
|
||||
if (pendingFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
// Show upload disclaimer if configured
|
||||
if (uploadDisclaimer.value && uploadDisclaimer.value.trim() !== '') {
|
||||
showUploadDisclaimerModal.value = true;
|
||||
return;
|
||||
}
|
||||
// Update all queued files with current tags and ASR options
|
||||
// AND change their status to 'ready' so they move to upload progress immediately
|
||||
for (const item of uploadQueue.value) {
|
||||
if (item.status === 'queued') {
|
||||
if (!item.preserveOptions) {
|
||||
// For file uploads: use current UI selection (user may have changed tags after dropping)
|
||||
item.tags = [...selectedTags.value];
|
||||
item.asrOptions = {
|
||||
language: asrLanguage.value,
|
||||
min_speakers: asrMinSpeakers.value,
|
||||
max_speakers: asrMaxSpeakers.value,
|
||||
hotwords: uploadHotwords.value,
|
||||
initial_prompt: uploadInitialPrompt.value,
|
||||
};
|
||||
item.folder_id = selectedFolderId.value;
|
||||
}
|
||||
// Change status to 'ready' to remove from upload view but keep in queue
|
||||
item.status = 'ready';
|
||||
}
|
||||
}
|
||||
progressPopupMinimized.value = false;
|
||||
progressPopupClosed.value = false;
|
||||
startProcessingQueue();
|
||||
};
|
||||
|
||||
// --- Parallel Upload System ---
|
||||
// Concurrency limiter: configurable via MAX_CONCURRENT_UPLOADS env var (default 3)
|
||||
let activeUploadCount = 0;
|
||||
const pendingUploadQueue = []; // Functions waiting for a slot
|
||||
|
||||
const acquireUploadSlot = () => {
|
||||
return new Promise(resolve => {
|
||||
if (activeUploadCount < (maxConcurrentUploads?.value || 3)) {
|
||||
activeUploadCount++;
|
||||
resolve();
|
||||
} else {
|
||||
pendingUploadQueue.push(resolve);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const releaseUploadSlot = () => {
|
||||
activeUploadCount--;
|
||||
if (pendingUploadQueue.length > 0) {
|
||||
activeUploadCount++;
|
||||
const next = pendingUploadQueue.shift();
|
||||
next();
|
||||
}
|
||||
// When all uploads are done, clear processing active flag
|
||||
const stillUploading = uploadQueue.value.some(item =>
|
||||
['uploading', 'ready'].includes(item.status)
|
||||
);
|
||||
if (!stillUploading) {
|
||||
isProcessingActive.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetCurrentFileProcessingState = () => {
|
||||
if (pollInterval.value) clearInterval(pollInterval.value);
|
||||
pollInterval.value = null;
|
||||
currentlyProcessingFile.value = null;
|
||||
processingProgress.value = 0;
|
||||
processingMessage.value = '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload a single file to the server.
|
||||
* Acquires a concurrency slot, uploads, then releases.
|
||||
* Status updates are per-item (no global processingProgress).
|
||||
*/
|
||||
const uploadSingleFile = async (fileItem) => {
|
||||
await acquireUploadSlot();
|
||||
|
||||
fileItem.status = 'uploading';
|
||||
fileItem.progress = 5;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileItem.file);
|
||||
|
||||
// Send file's lastModified timestamp for meeting_date
|
||||
if (fileItem.file.lastModified) {
|
||||
const lastModified = fileItem.file.lastModified;
|
||||
formData.append('file_last_modified', lastModified.toString());
|
||||
}
|
||||
|
||||
if (fileItem.notes) {
|
||||
formData.append('notes', fileItem.notes);
|
||||
}
|
||||
|
||||
// Add tags if selected
|
||||
const tagsToUse = fileItem.tags || selectedTags.value || [];
|
||||
tagsToUse.forEach((tag, index) => {
|
||||
const tagId = tag.id || tag;
|
||||
formData.append(`tag_ids[${index}]`, tagId);
|
||||
});
|
||||
|
||||
// Add folder if selected
|
||||
const folderToUse = fileItem.folder_id || selectedFolderId.value;
|
||||
if (folderToUse) {
|
||||
formData.append('folder_id', folderToUse);
|
||||
}
|
||||
|
||||
// Add ASR options
|
||||
const asrOpts = fileItem.asrOptions || {};
|
||||
const language = asrOpts.language || uploadLanguage.value;
|
||||
if (language) {
|
||||
formData.append('language', language);
|
||||
}
|
||||
|
||||
if (connectorSupportsDiarization.value) {
|
||||
const minSpeakers = asrOpts.min_speakers || uploadMinSpeakers.value;
|
||||
const maxSpeakers = asrOpts.max_speakers || uploadMaxSpeakers.value;
|
||||
|
||||
if (minSpeakers && minSpeakers !== '') {
|
||||
formData.append('min_speakers', minSpeakers.toString());
|
||||
}
|
||||
if (maxSpeakers && maxSpeakers !== '') {
|
||||
formData.append('max_speakers', maxSpeakers.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// Add hotwords and initial prompt
|
||||
const hotwords = asrOpts.hotwords || uploadHotwords.value;
|
||||
const initialPrompt = asrOpts.initial_prompt || uploadInitialPrompt.value;
|
||||
if (hotwords && hotwords.trim()) {
|
||||
formData.append('hotwords', hotwords.trim());
|
||||
}
|
||||
if (initialPrompt && initialPrompt.trim()) {
|
||||
formData.append('initial_prompt', initialPrompt.trim());
|
||||
}
|
||||
|
||||
// Refresh CSRF token before upload (prevents stale token after sleep/idle)
|
||||
let csrfToken;
|
||||
if (window.csrfManager) {
|
||||
try {
|
||||
csrfToken = await window.csrfManager.refreshToken();
|
||||
} catch (e) {
|
||||
csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
}
|
||||
} else {
|
||||
csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
}
|
||||
|
||||
// Use XMLHttpRequest for per-file upload progress
|
||||
const data = await new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
// Map upload progress to 5-90% range
|
||||
fileItem.progress = Math.round(5 + (e.loaded / e.total) * 85);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
const contentType = xhr.getResponseHeader('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
const titleMatch = xhr.responseText.match(/<title>([^<]+)<\/title>/i);
|
||||
const h1Match = xhr.responseText.match(/<h1>([^<]+)<\/h1>/i);
|
||||
reject(new Error(titleMatch?.[1] || h1Match?.[1] ||
|
||||
`Server error (${xhr.status}): Response was not JSON`));
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
reject(new Error(`Invalid JSON response (${xhr.status})`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (xhr.status === 202 && parsed.id) {
|
||||
resolve(parsed);
|
||||
} else if (!String(xhr.status).startsWith('2')) {
|
||||
let errorMsg = parsed.error || `Upload failed with status ${xhr.status}`;
|
||||
if (xhr.status === 413) errorMsg = parsed.error || `File too large. Max: ${parsed.max_size_mb?.toFixed(0) || maxFileSizeMB.value} MB.`;
|
||||
reject(new Error(errorMsg));
|
||||
} else {
|
||||
reject(new Error('Unexpected success response from server after upload.'));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new Error('Network error during upload'));
|
||||
xhr.ontimeout = () => reject(new Error('Upload timed out'));
|
||||
|
||||
// Store abort controller on item for cancellation
|
||||
fileItem._xhr = xhr;
|
||||
|
||||
xhr.open('POST', '/upload');
|
||||
if (csrfToken) {
|
||||
xhr.setRequestHeader('X-CSRFToken', csrfToken);
|
||||
}
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
// Upload succeeded - recording is now on the server
|
||||
console.log(`File ${fileItem.file.name} uploaded. Recording ID: ${data.id}. Server will process via job queue.`);
|
||||
fileItem.status = 'pending';
|
||||
fileItem.recordingId = data.id;
|
||||
fileItem.progress = 100;
|
||||
|
||||
// Add to recordings list
|
||||
recordings.value.unshift(data);
|
||||
totalRecordings.value++;
|
||||
|
||||
// Clear recording session only after confirmed upload
|
||||
if (fileItem.onUploadSuccess) {
|
||||
await fileItem.onUploadSuccess();
|
||||
}
|
||||
|
||||
// Handle duplicate warning
|
||||
if (data.duplicate_warning) {
|
||||
const warning = data.duplicate_warning;
|
||||
const existingDate = warning.existing_created_at
|
||||
? new Date(warning.existing_created_at).toLocaleDateString()
|
||||
: '';
|
||||
const existingName = warning.existing_title || 'Unknown';
|
||||
showToast(
|
||||
`⚠️ ${existingName} (${existingDate})`,
|
||||
'fa-copy'
|
||||
);
|
||||
fileItem.duplicateWarning = warning;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Upload Error for ${fileItem.file.name} (Client ID: ${fileItem.clientId}):`, error);
|
||||
fileItem.status = 'failed';
|
||||
fileItem.error = error.message;
|
||||
fileItem.progress = 0;
|
||||
|
||||
// Show friendly error message
|
||||
const friendlyErr = getFriendlyError(error.message, t);
|
||||
setGlobalError(`${friendlyErr.title}: ${friendlyErr.guidance}`);
|
||||
|
||||
// Store failed upload in IndexedDB for background sync retry
|
||||
try {
|
||||
await FailedUploads.storeFailedUpload({
|
||||
file: fileItem.file,
|
||||
fileName: fileItem.file.name,
|
||||
fileSize: fileItem.file.size,
|
||||
clientId: fileItem.clientId,
|
||||
notes: fileItem.notes,
|
||||
tags: fileItem.tags,
|
||||
asrOptions: fileItem.asrOptions,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
await registration.sync.register('sync-uploads');
|
||||
console.log('[Upload] Registered background sync for failed upload');
|
||||
}
|
||||
} catch (syncError) {
|
||||
console.warn('[Upload] Failed to register background sync:', syncError);
|
||||
}
|
||||
} finally {
|
||||
fileItem._xhr = null;
|
||||
releaseUploadSlot();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Start uploading all ready files in parallel (with concurrency limit).
|
||||
* Processing status is tracked via allJobs polling in app.modular.js.
|
||||
*/
|
||||
const startProcessingQueue = async () => {
|
||||
const readyItems = uploadQueue.value.filter(item => item.status === 'ready');
|
||||
if (readyItems.length === 0) {
|
||||
console.log("No files ready to upload.");
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessingActive.value = true;
|
||||
console.log(`Starting parallel upload of ${readyItems.length} file(s) (max ${maxConcurrentUploads?.value || 3} concurrent)...`);
|
||||
|
||||
// Fire off all uploads concurrently (semaphore handles limiting)
|
||||
const uploadPromises = readyItems.map(item => uploadSingleFile(item));
|
||||
// Don't await - let them run in background. isProcessingActive is cleared by releaseUploadSlot.
|
||||
Promise.allSettled(uploadPromises).then(() => {
|
||||
console.log('All uploads settled.');
|
||||
});
|
||||
};
|
||||
|
||||
// Keep backward-compat aliases
|
||||
const startStatusPolling = (fileItem, recordingId) => {
|
||||
// No longer needed - allJobs polling handles status tracking
|
||||
fileItem.recordingId = recordingId;
|
||||
};
|
||||
|
||||
const pollProcessingStatus = () => {
|
||||
// No-op: status tracking is now handled by allJobs polling in app.modular.js
|
||||
};
|
||||
|
||||
// Tag selection helpers
|
||||
const addTagToSelection = (tagId) => {
|
||||
if (!selectedTagIds.value.includes(tagId)) {
|
||||
selectedTagIds.value.push(tagId);
|
||||
applyTagDefaults();
|
||||
}
|
||||
};
|
||||
|
||||
const removeTagFromSelection = (tagId) => {
|
||||
const index = selectedTagIds.value.indexOf(tagId);
|
||||
if (index > -1) {
|
||||
selectedTagIds.value.splice(index, 1);
|
||||
applyTagDefaults();
|
||||
}
|
||||
};
|
||||
|
||||
const applyTagDefaults = () => {
|
||||
const selectedTagsObjects = selectedTagIds.value.map(tagId =>
|
||||
availableTags.value.find(tag => tag.id == tagId)
|
||||
).filter(Boolean);
|
||||
|
||||
const firstTag = selectedTagsObjects[0];
|
||||
if (firstTag && connectorSupportsDiarization.value) {
|
||||
if (firstTag.default_language) {
|
||||
uploadLanguage.value = firstTag.default_language;
|
||||
}
|
||||
if (firstTag.default_min_speakers) {
|
||||
uploadMinSpeakers.value = firstTag.default_min_speakers;
|
||||
}
|
||||
if (firstTag.default_max_speakers) {
|
||||
uploadMaxSpeakers.value = firstTag.default_max_speakers;
|
||||
}
|
||||
}
|
||||
// Apply hotwords/initial_prompt from first tag (works for all connectors)
|
||||
if (firstTag) {
|
||||
if (firstTag.default_hotwords) {
|
||||
uploadHotwords.value = firstTag.default_hotwords;
|
||||
}
|
||||
if (firstTag.default_initial_prompt) {
|
||||
uploadInitialPrompt.value = firstTag.default_initial_prompt;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Computed property for filtered available tags in upload view
|
||||
const filteredAvailableTagsForUpload = computed(() => {
|
||||
const availableForSelection = availableTags.value.filter(tag => !selectedTagIds.value.includes(tag.id));
|
||||
if (!uploadTagSearchFilter.value) return availableForSelection;
|
||||
|
||||
const filter = uploadTagSearchFilter.value.toLowerCase();
|
||||
return availableForSelection.filter(tag =>
|
||||
tag.name.toLowerCase().includes(filter)
|
||||
);
|
||||
});
|
||||
|
||||
// === INCOGNITO MODE FUNCTIONS ===
|
||||
|
||||
/**
|
||||
* Upload and process a file in incognito mode.
|
||||
* The file is processed synchronously and no data is saved to the database.
|
||||
* Results are stored only in sessionStorage.
|
||||
*/
|
||||
const startIncognitoUpload = async () => {
|
||||
const pendingFiles = uploadQueue.value.filter(item => item.status === 'queued');
|
||||
if (pendingFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process the first file for incognito mode
|
||||
const fileItem = pendingFiles[0];
|
||||
|
||||
// Check if incognito mode state is available
|
||||
if (!incognitoMode || !incognitoProcessing || !incognitoRecording) {
|
||||
console.warn('[Incognito] Incognito state not available, falling back to normal upload');
|
||||
startUpload();
|
||||
return;
|
||||
}
|
||||
|
||||
incognitoProcessing.value = true;
|
||||
processingMessage.value = t('incognito.processingInProgress');
|
||||
processingProgress.value = 10;
|
||||
progressPopupMinimized.value = false;
|
||||
progressPopupClosed.value = false;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileItem.file);
|
||||
|
||||
// Add ASR options
|
||||
const asrOpts = fileItem.asrOptions || {};
|
||||
const language = asrOpts.language || uploadLanguage.value;
|
||||
const minSpeakers = asrOpts.min_speakers || uploadMinSpeakers.value;
|
||||
const maxSpeakers = asrOpts.max_speakers || uploadMaxSpeakers.value;
|
||||
|
||||
if (language) {
|
||||
formData.append('language', language);
|
||||
}
|
||||
if (minSpeakers && minSpeakers !== '') {
|
||||
formData.append('min_speakers', minSpeakers.toString());
|
||||
}
|
||||
if (maxSpeakers && maxSpeakers !== '') {
|
||||
formData.append('max_speakers', maxSpeakers.toString());
|
||||
}
|
||||
|
||||
const hotwords = asrOpts.hotwords || uploadHotwords.value;
|
||||
const initialPrompt = asrOpts.initial_prompt || uploadInitialPrompt.value;
|
||||
if (hotwords && hotwords.trim()) {
|
||||
formData.append('hotwords', hotwords.trim());
|
||||
}
|
||||
if (initialPrompt && initialPrompt.trim()) {
|
||||
formData.append('initial_prompt', initialPrompt.trim());
|
||||
}
|
||||
|
||||
// Request auto-summarization
|
||||
formData.append('auto_summarize', 'true');
|
||||
|
||||
processingMessage.value = t('incognito.uploadingFile');
|
||||
processingProgress.value = 20;
|
||||
|
||||
console.log('[Incognito] Uploading file:', fileItem.file.name);
|
||||
|
||||
const response = await fetch('/api/recordings/incognito', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
processingProgress.value = 50;
|
||||
|
||||
// Parse response
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
const text = await response.text();
|
||||
const titleMatch = text.match(/<title>([^<]+)<\/title>/i);
|
||||
throw new Error(titleMatch?.[1] || `Server error (${response.status})`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
throw new Error(data.error || `Processing failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
processingProgress.value = 80;
|
||||
processingMessage.value = t('incognito.processingComplete');
|
||||
|
||||
// Store result in sessionStorage
|
||||
const incognitoData = {
|
||||
id: 'incognito',
|
||||
incognito: true,
|
||||
title: data.title || t('incognito.recordingTitle'),
|
||||
transcription: data.transcription,
|
||||
summary: data.summary,
|
||||
summary_html: data.summary_html,
|
||||
created_at: data.created_at,
|
||||
original_filename: data.original_filename,
|
||||
file_size: data.file_size,
|
||||
audio_duration_seconds: data.audio_duration_seconds,
|
||||
processing_time_seconds: data.processing_time_seconds,
|
||||
status: 'COMPLETED'
|
||||
};
|
||||
|
||||
IncognitoStorage.saveIncognitoRecording(incognitoData);
|
||||
incognitoRecording.value = incognitoData;
|
||||
|
||||
// Remove the processed file from queue
|
||||
const index = uploadQueue.value.findIndex(item => item.clientId === fileItem.clientId);
|
||||
if (index !== -1) {
|
||||
uploadQueue.value.splice(index, 1);
|
||||
}
|
||||
|
||||
processingProgress.value = 100;
|
||||
processingMessage.value = t('incognito.recordingReady');
|
||||
|
||||
// Auto-select the incognito recording and switch to detail view
|
||||
selectedRecording.value = incognitoData;
|
||||
currentView.value = 'detail';
|
||||
|
||||
// Show toast
|
||||
showToast(t('incognito.recordingProcessed'), 'fa-user-secret');
|
||||
|
||||
console.log('[Incognito] Processing complete');
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Incognito] Processing failed:', error);
|
||||
const friendlyErr = getFriendlyError(error.message, t);
|
||||
setGlobalError(`${friendlyErr.title}: ${friendlyErr.guidance}`);
|
||||
fileItem.status = 'failed';
|
||||
fileItem.error = error.message;
|
||||
} finally {
|
||||
incognitoProcessing.value = false;
|
||||
processingProgress.value = 0;
|
||||
processingMessage.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the incognito recording with confirmation
|
||||
*/
|
||||
const clearIncognitoRecordingWithConfirm = () => {
|
||||
if (incognitoRecording && incognitoRecording.value) {
|
||||
if (confirm(t('incognito.discardConfirm'))) {
|
||||
IncognitoStorage.clearIncognitoRecording();
|
||||
incognitoRecording.value = null;
|
||||
// If the incognito recording was selected, clear selection
|
||||
if (selectedRecording.value?.id === 'incognito') {
|
||||
selectedRecording.value = null;
|
||||
}
|
||||
showToast(t('incognito.recordingDiscarded'), 'fa-trash');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Select the incognito recording for viewing
|
||||
*/
|
||||
const selectIncognitoRecording = () => {
|
||||
if (incognitoRecording && incognitoRecording.value) {
|
||||
selectedRecording.value = incognitoRecording.value;
|
||||
currentView.value = 'detail';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Load incognito recording from sessionStorage on app init
|
||||
*/
|
||||
const loadIncognitoRecording = () => {
|
||||
const stored = IncognitoStorage.getIncognitoRecording();
|
||||
if (stored && incognitoRecording) {
|
||||
incognitoRecording.value = stored;
|
||||
console.log('[Incognito] Loaded recording from sessionStorage');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if there's an incognito recording (for navigation guards)
|
||||
*/
|
||||
const hasIncognitoRecording = () => {
|
||||
return IncognitoStorage.hasIncognitoRecording();
|
||||
};
|
||||
|
||||
return {
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
handleFileSelect,
|
||||
addFilesToQueue,
|
||||
removeFromQueue,
|
||||
cancelWaitingFile,
|
||||
clearCompletedUploads,
|
||||
startUpload,
|
||||
startProcessingQueue,
|
||||
resetCurrentFileProcessingState,
|
||||
startStatusPolling,
|
||||
pollProcessingStatus,
|
||||
addTagToSelection,
|
||||
removeTagFromSelection,
|
||||
applyTagDefaults,
|
||||
filteredAvailableTagsForUpload,
|
||||
// Tag drag-and-drop
|
||||
draggedTagIndex,
|
||||
dragOverTagIndex,
|
||||
handleTagDragStart,
|
||||
handleTagDragOver,
|
||||
handleTagDrop,
|
||||
handleTagDragEnd,
|
||||
handleTagTouchStart,
|
||||
handleTagTouchMove,
|
||||
handleTagTouchEnd,
|
||||
// Incognito mode
|
||||
startIncognitoUpload,
|
||||
clearIncognitoRecordingWithConfirm,
|
||||
selectIncognitoRecording,
|
||||
loadIncognitoRecording,
|
||||
hasIncognitoRecording
|
||||
};
|
||||
}
|
||||
204
static/js/modules/composables/virtualScroll.js
Normal file
204
static/js/modules/composables/virtualScroll.js
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Virtual Scrolling Composable
|
||||
*
|
||||
* Renders only visible items plus a buffer for smooth scrolling.
|
||||
* Critical for handling long transcriptions (4500+ segments) without UI lag.
|
||||
*
|
||||
* Usage:
|
||||
* const { visibleItems, spacerBefore, spacerAfter, onScroll, scrollToIndex } = useVirtualScroll({
|
||||
* items: segmentsRef,
|
||||
* itemHeight: 48,
|
||||
* containerRef: scrollContainerRef,
|
||||
* overscan: 5
|
||||
* });
|
||||
*/
|
||||
|
||||
export function useVirtualScroll(options) {
|
||||
const { ref, computed, watch, onMounted, onUnmounted } = Vue;
|
||||
|
||||
const {
|
||||
items, // Ref to the full array of items
|
||||
itemHeight = 48, // Height of each item in pixels (fixed height mode)
|
||||
containerRef, // Ref to the scrollable container element
|
||||
overscan = 5, // Number of items to render outside viewport
|
||||
keyField = null // Optional field to use for unique keys
|
||||
} = options;
|
||||
|
||||
// Internal state
|
||||
const scrollTop = ref(0);
|
||||
const containerHeight = ref(0);
|
||||
const isInitialized = ref(false);
|
||||
|
||||
// Calculate visible range based on scroll position
|
||||
const visibleRange = computed(() => {
|
||||
if (!isInitialized.value || !items.value) {
|
||||
return { start: 0, end: Math.min(20, items.value?.length || 0) };
|
||||
}
|
||||
|
||||
const totalItems = items.value.length;
|
||||
if (totalItems === 0) {
|
||||
return { start: 0, end: 0 };
|
||||
}
|
||||
|
||||
// Calculate first visible item
|
||||
const firstVisible = Math.floor(scrollTop.value / itemHeight);
|
||||
|
||||
// Calculate number of items that fit in viewport
|
||||
const visibleCount = Math.ceil(containerHeight.value / itemHeight);
|
||||
|
||||
// Add overscan for smooth scrolling
|
||||
const start = Math.max(0, firstVisible - overscan);
|
||||
const end = Math.min(totalItems, firstVisible + visibleCount + overscan);
|
||||
|
||||
return { start, end };
|
||||
});
|
||||
|
||||
// Slice of items to actually render
|
||||
const visibleItems = computed(() => {
|
||||
if (!items.value || items.value.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { start, end } = visibleRange.value;
|
||||
|
||||
// Map items with their original indices for proper data binding
|
||||
return items.value.slice(start, end).map((item, localIndex) => ({
|
||||
...item,
|
||||
_virtualIndex: start + localIndex,
|
||||
_originalIndex: start + localIndex
|
||||
}));
|
||||
});
|
||||
|
||||
// Spacer height before visible items (for scroll position)
|
||||
const spacerBefore = computed(() => {
|
||||
return visibleRange.value.start * itemHeight;
|
||||
});
|
||||
|
||||
// Spacer height after visible items
|
||||
const spacerAfter = computed(() => {
|
||||
if (!items.value) return 0;
|
||||
const remainingItems = items.value.length - visibleRange.value.end;
|
||||
return Math.max(0, remainingItems * itemHeight);
|
||||
});
|
||||
|
||||
// Total height of all items (for scroll container)
|
||||
const totalHeight = computed(() => {
|
||||
if (!items.value) return 0;
|
||||
return items.value.length * itemHeight;
|
||||
});
|
||||
|
||||
// Handle scroll events
|
||||
const onScroll = (event) => {
|
||||
scrollTop.value = event.target.scrollTop;
|
||||
};
|
||||
|
||||
// Initialize container height observer
|
||||
let resizeObserver = null;
|
||||
|
||||
const initializeContainer = () => {
|
||||
if (!containerRef.value) return;
|
||||
|
||||
// Get initial height
|
||||
containerHeight.value = containerRef.value.clientHeight;
|
||||
isInitialized.value = true;
|
||||
|
||||
// Watch for container size changes
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
containerHeight.value = entry.contentRect.height;
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(containerRef.value);
|
||||
};
|
||||
|
||||
// Scroll to a specific index
|
||||
const scrollToIndex = (index, behavior = 'smooth') => {
|
||||
if (!containerRef.value || !items.value) return;
|
||||
|
||||
const targetIndex = Math.max(0, Math.min(index, items.value.length - 1));
|
||||
const targetScrollTop = targetIndex * itemHeight;
|
||||
|
||||
containerRef.value.scrollTo({
|
||||
top: targetScrollTop,
|
||||
behavior
|
||||
});
|
||||
};
|
||||
|
||||
// Scroll to make an index visible (centered if possible)
|
||||
const scrollToIndexIfNeeded = (index) => {
|
||||
if (!containerRef.value || !items.value) return;
|
||||
|
||||
const { start, end } = visibleRange.value;
|
||||
|
||||
// Check if index is already visible (with some margin)
|
||||
if (index >= start + overscan && index < end - overscan) {
|
||||
return; // Already visible
|
||||
}
|
||||
|
||||
// Center the index in the viewport
|
||||
const targetIndex = Math.max(0, index - Math.floor(containerHeight.value / itemHeight / 2));
|
||||
scrollToIndex(targetIndex, 'smooth');
|
||||
};
|
||||
|
||||
// Reset scroll state (call when modal opens or items change completely)
|
||||
const reset = () => {
|
||||
scrollTop.value = 0;
|
||||
isInitialized.value = false;
|
||||
// Re-initialize after a tick to allow DOM to render
|
||||
Vue.nextTick(() => {
|
||||
if (containerRef.value) {
|
||||
containerRef.value.scrollTop = 0;
|
||||
initializeContainer();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Watch for containerRef changes and initialize
|
||||
watch(containerRef, (newRef) => {
|
||||
if (newRef) {
|
||||
initializeContainer();
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
// Data
|
||||
visibleItems,
|
||||
visibleRange,
|
||||
|
||||
// Spacer heights for virtual scroll container
|
||||
spacerBefore,
|
||||
spacerAfter,
|
||||
totalHeight,
|
||||
|
||||
// Event handlers
|
||||
onScroll,
|
||||
|
||||
// Navigation
|
||||
scrollToIndex,
|
||||
scrollToIndexIfNeeded,
|
||||
|
||||
// Control
|
||||
reset,
|
||||
|
||||
// State (for debugging/testing)
|
||||
scrollTop,
|
||||
containerHeight,
|
||||
isInitialized
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to generate a unique key for virtual scroll items
|
||||
*/
|
||||
export function getVirtualItemKey(item, prefix = 'vs') {
|
||||
const index = item._originalIndex ?? item._virtualIndex ?? 0;
|
||||
const time = item.startTime ?? item.start_time ?? '';
|
||||
return `${prefix}-${index}-${time}`;
|
||||
}
|
||||
26
static/js/modules/computed/index.js
Normal file
26
static/js/modules/computed/index.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Computed properties module exports
|
||||
*
|
||||
* Note: These computed properties are defined inline in the main app.js
|
||||
* due to their tight coupling with reactive state. This module serves as
|
||||
* a placeholder for future extraction if needed.
|
||||
*/
|
||||
|
||||
// Computed properties that could be extracted:
|
||||
// - filteredRecordings
|
||||
// - groupedRecordings
|
||||
// - highlightedTranscript
|
||||
// - activeRecordingMetadata
|
||||
// - identifiedSpeakers
|
||||
// - processedTranscription
|
||||
// - totalInQueue
|
||||
// - completedInQueue
|
||||
// - finishedFilesInQueue
|
||||
// - isMobileScreen
|
||||
// - datePresetOptions
|
||||
// - languageOptions
|
||||
// - tagsWithCustomPrompts
|
||||
// - filteredAvailableTagsForModal
|
||||
// - isMobileDevice
|
||||
|
||||
export default {};
|
||||
272
static/js/modules/db/failed-uploads.js
Normal file
272
static/js/modules/db/failed-uploads.js
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* IndexedDB Failed Uploads Storage
|
||||
* Handles storing and retrying failed uploads with background sync
|
||||
*/
|
||||
|
||||
const DB_NAME = 'SpeakrFailedUploads';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'failedUploads';
|
||||
|
||||
let dbInstance = null;
|
||||
|
||||
/**
|
||||
* Initialize IndexedDB
|
||||
*/
|
||||
export const initDB = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (dbInstance) {
|
||||
resolve(dbInstance);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('[FailedUploadsDB] Failed to open database:', request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
dbInstance = request.result;
|
||||
console.log('[FailedUploadsDB] Database opened successfully');
|
||||
resolve(dbInstance);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
|
||||
// Create object store for failed uploads
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const objectStore = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
|
||||
objectStore.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
objectStore.createIndex('clientId', 'clientId', { unique: false });
|
||||
console.log('[FailedUploadsDB] Object store created');
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Store a failed upload for later retry
|
||||
*/
|
||||
export const storeFailedUpload = async (uploadData) => {
|
||||
try {
|
||||
const db = await initDB();
|
||||
|
||||
// Convert File to ArrayBuffer BEFORE opening transaction.
|
||||
// IDB transactions auto-close when inactive — the async arrayBuffer()
|
||||
// call would cause the transaction to expire before add().
|
||||
let fileData = uploadData.fileData || null;
|
||||
if (uploadData.file && !fileData) {
|
||||
fileData = await uploadData.file.arrayBuffer();
|
||||
}
|
||||
|
||||
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
||||
const objectStore = transaction.objectStore(STORE_NAME);
|
||||
|
||||
const failedUpload = {
|
||||
timestamp: Date.now(),
|
||||
clientId: uploadData.clientId || `client-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
fileName: uploadData.file?.name || uploadData.fileName || 'unknown',
|
||||
fileSize: uploadData.file?.size || uploadData.fileSize || 0,
|
||||
notes: uploadData.notes || '',
|
||||
tags: uploadData.tags || [],
|
||||
asrOptions: uploadData.asrOptions || {},
|
||||
retryCount: uploadData.retryCount || 0,
|
||||
lastError: uploadData.error || '',
|
||||
fileData: fileData,
|
||||
mimeType: uploadData.file?.type || uploadData.mimeType || 'audio/webm'
|
||||
};
|
||||
|
||||
const request = objectStore.add(failedUpload);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => {
|
||||
console.log('[FailedUploadsDB] Upload stored for retry:', failedUpload.fileName);
|
||||
resolve(request.result); // Returns the ID
|
||||
};
|
||||
request.onerror = () => {
|
||||
console.error('[FailedUploadsDB] Failed to store upload:', request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[FailedUploadsDB] Error storing failed upload:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all failed uploads waiting to retry
|
||||
*/
|
||||
export const getFailedUploads = async () => {
|
||||
try {
|
||||
const db = await initDB();
|
||||
const transaction = db.transaction([STORE_NAME], 'readonly');
|
||||
const objectStore = transaction.objectStore(STORE_NAME);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = objectStore.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
console.log(`[FailedUploadsDB] Retrieved ${request.result.length} failed uploads`);
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('[FailedUploadsDB] Failed to retrieve uploads:', request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[FailedUploadsDB] Error getting failed uploads:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a specific failed upload by ID
|
||||
*/
|
||||
export const getFailedUpload = async (id) => {
|
||||
try {
|
||||
const db = await initDB();
|
||||
const transaction = db.transaction([STORE_NAME], 'readonly');
|
||||
const objectStore = transaction.objectStore(STORE_NAME);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = objectStore.get(id);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('[FailedUploadsDB] Failed to get upload:', request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[FailedUploadsDB] Error getting failed upload:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update retry count for a failed upload
|
||||
*/
|
||||
export const updateRetryCount = async (id, retryCount, error = null) => {
|
||||
try {
|
||||
const db = await initDB();
|
||||
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
||||
const objectStore = transaction.objectStore(STORE_NAME);
|
||||
|
||||
const upload = await getFailedUpload(id);
|
||||
if (!upload) {
|
||||
console.warn('[FailedUploadsDB] Upload not found for retry count update');
|
||||
return;
|
||||
}
|
||||
|
||||
upload.retryCount = retryCount;
|
||||
upload.lastRetry = Date.now();
|
||||
if (error) {
|
||||
upload.lastError = error;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = objectStore.put(upload);
|
||||
|
||||
request.onsuccess = () => {
|
||||
console.log(`[FailedUploadsDB] Updated retry count for upload ${id}: ${retryCount}`);
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('[FailedUploadsDB] Failed to update retry count:', request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[FailedUploadsDB] Error updating retry count:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a failed upload (after successful retry)
|
||||
*/
|
||||
export const deleteFailedUpload = async (id) => {
|
||||
try {
|
||||
const db = await initDB();
|
||||
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
||||
const objectStore = transaction.objectStore(STORE_NAME);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = objectStore.delete(id);
|
||||
|
||||
request.onsuccess = () => {
|
||||
console.log('[FailedUploadsDB] Deleted successful upload:', id);
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('[FailedUploadsDB] Failed to delete upload:', request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[FailedUploadsDB] Error deleting failed upload:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear all failed uploads
|
||||
*/
|
||||
export const clearAllFailedUploads = async () => {
|
||||
try {
|
||||
const db = await initDB();
|
||||
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
||||
const objectStore = transaction.objectStore(STORE_NAME);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = objectStore.clear();
|
||||
|
||||
request.onsuccess = () => {
|
||||
console.log('[FailedUploadsDB] Cleared all failed uploads');
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('[FailedUploadsDB] Failed to clear uploads:', request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[FailedUploadsDB] Error clearing failed uploads:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get count of failed uploads
|
||||
*/
|
||||
export const getFailedUploadCount = async () => {
|
||||
try {
|
||||
const db = await initDB();
|
||||
const transaction = db.transaction([STORE_NAME], 'readonly');
|
||||
const objectStore = transaction.objectStore(STORE_NAME);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = objectStore.count();
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('[FailedUploadsDB] Failed to count uploads:', request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[FailedUploadsDB] Error counting failed uploads:', error);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
76
static/js/modules/db/incognito-storage.js
Normal file
76
static/js/modules/db/incognito-storage.js
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Incognito Mode storage utilities
|
||||
* Uses sessionStorage for temporary storage that auto-clears when tab closes
|
||||
*/
|
||||
|
||||
const INCOGNITO_KEY = 'speakr_incognito_recording';
|
||||
|
||||
/**
|
||||
* Save incognito recording data to sessionStorage
|
||||
* @param {Object} data - Recording data including transcription, summary, title
|
||||
*/
|
||||
export function saveIncognitoRecording(data) {
|
||||
try {
|
||||
sessionStorage.setItem(INCOGNITO_KEY, JSON.stringify(data));
|
||||
console.log('[Incognito] Recording saved to sessionStorage');
|
||||
} catch (e) {
|
||||
console.error('[Incognito] Failed to save recording:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get incognito recording data from sessionStorage
|
||||
* @returns {Object|null} Recording data or null if not found
|
||||
*/
|
||||
export function getIncognitoRecording() {
|
||||
try {
|
||||
const data = sessionStorage.getItem(INCOGNITO_KEY);
|
||||
return data ? JSON.parse(data) : null;
|
||||
} catch (e) {
|
||||
console.error('[Incognito] Failed to retrieve recording:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear incognito recording from sessionStorage
|
||||
*/
|
||||
export function clearIncognitoRecording() {
|
||||
try {
|
||||
sessionStorage.removeItem(INCOGNITO_KEY);
|
||||
console.log('[Incognito] Recording cleared from sessionStorage');
|
||||
} catch (e) {
|
||||
console.error('[Incognito] Failed to clear recording:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an incognito recording exists
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasIncognitoRecording() {
|
||||
try {
|
||||
return sessionStorage.getItem(INCOGNITO_KEY) !== null;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update specific fields of the incognito recording
|
||||
* @param {Object} updates - Fields to update
|
||||
*/
|
||||
export function updateIncognitoRecording(updates) {
|
||||
try {
|
||||
const existing = getIncognitoRecording();
|
||||
if (existing) {
|
||||
const updated = { ...existing, ...updates };
|
||||
saveIncognitoRecording(updated);
|
||||
return updated;
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error('[Incognito] Failed to update recording:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
267
static/js/modules/db/recording-persistence.js
Normal file
267
static/js/modules/db/recording-persistence.js
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* IndexedDB Recording Persistence
|
||||
* Handles saving recording chunks to IndexedDB for crash recovery
|
||||
*/
|
||||
|
||||
const DB_NAME = 'SpeakrRecordings';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'activeRecording';
|
||||
|
||||
let dbInstance = null;
|
||||
|
||||
/**
|
||||
* Helper to promisify IDBRequest
|
||||
*/
|
||||
const promisifyRequest = (request) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize IndexedDB
|
||||
*/
|
||||
export const initDB = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (dbInstance) {
|
||||
resolve(dbInstance);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('[RecordingDB] Failed to open database:', request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
dbInstance = request.result;
|
||||
console.log('[RecordingDB] Database opened successfully');
|
||||
resolve(dbInstance);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
|
||||
// Create object store for active recording
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const objectStore = db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
objectStore.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
console.log('[RecordingDB] Object store created');
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Save recording metadata and initialize session
|
||||
*/
|
||||
export const startRecordingSession = async (recordingData) => {
|
||||
try {
|
||||
const db = await initDB();
|
||||
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
||||
const objectStore = transaction.objectStore(STORE_NAME);
|
||||
|
||||
const session = {
|
||||
id: 'current',
|
||||
timestamp: Date.now(),
|
||||
startTime: new Date().toISOString(),
|
||||
mode: recordingData.mode,
|
||||
notes: recordingData.notes || '',
|
||||
tags: recordingData.tags || [],
|
||||
asrOptions: recordingData.asrOptions || {},
|
||||
chunks: [],
|
||||
mimeType: recordingData.mimeType || 'audio/webm',
|
||||
duration: 0
|
||||
};
|
||||
|
||||
await promisifyRequest(objectStore.put(session));
|
||||
console.log('[RecordingDB] Recording session started:', session.id);
|
||||
return session;
|
||||
} catch (error) {
|
||||
console.error('[RecordingDB] Failed to start session:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Save a recording chunk to IndexedDB
|
||||
*/
|
||||
export const saveChunk = async (chunkBlob, chunkIndex) => {
|
||||
try {
|
||||
// Do async prep work BEFORE creating transaction to avoid auto-close
|
||||
const db = await initDB();
|
||||
const arrayBuffer = await chunkBlob.arrayBuffer();
|
||||
|
||||
// Now create transaction and do all DB operations quickly
|
||||
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
||||
const objectStore = transaction.objectStore(STORE_NAME);
|
||||
|
||||
// Get current session
|
||||
const session = await promisifyRequest(objectStore.get('current'));
|
||||
|
||||
if (!session) {
|
||||
console.warn('[RecordingDB] No active session found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add chunk to session
|
||||
session.chunks.push({
|
||||
index: chunkIndex,
|
||||
data: arrayBuffer,
|
||||
size: chunkBlob.size,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Update session - must happen before transaction auto-closes
|
||||
await promisifyRequest(objectStore.put(session));
|
||||
// Chunk saved silently to avoid spam (happens every 5 seconds)
|
||||
} catch (error) {
|
||||
console.error('[RecordingDB] Failed to save chunk:', error);
|
||||
// Don't throw - recording should continue even if persistence fails
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update recording metadata (notes, duration, etc.)
|
||||
*/
|
||||
export const updateRecordingMetadata = async (updates) => {
|
||||
try {
|
||||
const db = await initDB();
|
||||
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
||||
const objectStore = transaction.objectStore(STORE_NAME);
|
||||
|
||||
const session = await promisifyRequest(objectStore.get('current'));
|
||||
|
||||
if (!session) {
|
||||
console.warn('[RecordingDB] No active session to update');
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge updates
|
||||
Object.assign(session, updates);
|
||||
await promisifyRequest(objectStore.put(session));
|
||||
// Metadata updated silently to avoid spam (happens every 5 seconds)
|
||||
} catch (error) {
|
||||
console.error('[RecordingDB] Failed to update metadata:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if there's a recoverable recording
|
||||
*/
|
||||
export const checkForRecoverableRecording = async () => {
|
||||
try {
|
||||
const db = await initDB();
|
||||
const transaction = db.transaction([STORE_NAME], 'readonly');
|
||||
const objectStore = transaction.objectStore(STORE_NAME);
|
||||
|
||||
const session = await promisifyRequest(objectStore.get('current'));
|
||||
|
||||
if (!session || !session.chunks || session.chunks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate total size
|
||||
const totalSize = session.chunks.reduce((sum, chunk) => sum + chunk.size, 0);
|
||||
|
||||
// Calculate approximate duration (1 second chunks)
|
||||
const duration = session.chunks.length;
|
||||
|
||||
console.log('[RecordingDB] Found recoverable recording:', {
|
||||
chunks: session.chunks.length,
|
||||
size: totalSize,
|
||||
duration: duration,
|
||||
startTime: session.startTime
|
||||
});
|
||||
|
||||
return {
|
||||
...session,
|
||||
totalSize,
|
||||
duration: duration
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[RecordingDB] Failed to check for recoverable recording:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Recover recording from IndexedDB
|
||||
*/
|
||||
export const recoverRecording = async () => {
|
||||
try {
|
||||
const db = await initDB();
|
||||
const transaction = db.transaction([STORE_NAME], 'readonly');
|
||||
const objectStore = transaction.objectStore(STORE_NAME);
|
||||
|
||||
const session = await promisifyRequest(objectStore.get('current'));
|
||||
|
||||
if (!session || !session.chunks || session.chunks.length === 0) {
|
||||
console.warn('[RecordingDB] No recording to recover');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert chunks back to Blobs
|
||||
const chunks = session.chunks.map(chunk => {
|
||||
return new Blob([chunk.data], { type: session.mimeType });
|
||||
});
|
||||
|
||||
console.log(`[RecordingDB] Recovered ${chunks.length} chunks`);
|
||||
|
||||
return {
|
||||
chunks,
|
||||
metadata: {
|
||||
mode: session.mode,
|
||||
notes: session.notes,
|
||||
tags: session.tags,
|
||||
asrOptions: session.asrOptions,
|
||||
mimeType: session.mimeType,
|
||||
duration: session.chunks.length,
|
||||
startTime: session.startTime
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[RecordingDB] Failed to recover recording:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear recording session (after successful upload or discard)
|
||||
*/
|
||||
export const clearRecordingSession = async () => {
|
||||
try {
|
||||
const db = await initDB();
|
||||
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
||||
const objectStore = transaction.objectStore(STORE_NAME);
|
||||
|
||||
await promisifyRequest(objectStore.delete('current'));
|
||||
console.log('[RecordingDB] Recording session cleared');
|
||||
} catch (error) {
|
||||
console.error('[RecordingDB] Failed to clear session:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get database size information
|
||||
*/
|
||||
export const getDatabaseSize = async () => {
|
||||
try {
|
||||
if (!navigator.storage || !navigator.storage.estimate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const estimate = await navigator.storage.estimate();
|
||||
return {
|
||||
usage: estimate.usage,
|
||||
quota: estimate.quota,
|
||||
percentage: ((estimate.usage / estimate.quota) * 100).toFixed(2)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[RecordingDB] Failed to get database size:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
95
static/js/modules/state/audio.js
Normal file
95
static/js/modules/state/audio.js
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Audio recording state management
|
||||
*/
|
||||
|
||||
export function createAudioState(ref, computed) {
|
||||
// --- Audio Recording State ---
|
||||
const isRecording = ref(false);
|
||||
const mediaRecorder = ref(null);
|
||||
const audioChunks = ref([]);
|
||||
const audioBlobURL = ref(null);
|
||||
const recordingTime = ref(0);
|
||||
const recordingInterval = ref(null);
|
||||
const canRecordAudio = ref(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
|
||||
const canRecordSystemAudio = computed(() => navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia);
|
||||
const systemAudioSupported = ref(false);
|
||||
const systemAudioError = ref('');
|
||||
const recordingNotes = ref('');
|
||||
const showSystemAudioHelp = ref(false);
|
||||
|
||||
// ASR options for recording view
|
||||
const asrLanguage = ref('');
|
||||
const asrMinSpeakers = ref('');
|
||||
const asrMaxSpeakers = ref('');
|
||||
|
||||
// Audio context and analyzers
|
||||
const audioContext = ref(null);
|
||||
const analyser = ref(null);
|
||||
const micAnalyser = ref(null);
|
||||
const systemAnalyser = ref(null);
|
||||
const visualizer = ref(null);
|
||||
const micVisualizer = ref(null);
|
||||
const systemVisualizer = ref(null);
|
||||
const animationFrameId = ref(null);
|
||||
const recordingMode = ref('microphone');
|
||||
const activeStreams = ref([]);
|
||||
|
||||
// --- Wake Lock and Background Recording ---
|
||||
const wakeLock = ref(null);
|
||||
const recordingNotification = ref(null);
|
||||
const isPageVisible = ref(true);
|
||||
|
||||
// --- Recording Size Monitoring ---
|
||||
const estimatedFileSize = ref(0);
|
||||
const fileSizeWarningShown = ref(false);
|
||||
const recordingQuality = ref('optimized');
|
||||
const actualBitrate = ref(0);
|
||||
const maxRecordingMB = ref(200);
|
||||
const sizeCheckInterval = ref(null);
|
||||
|
||||
return {
|
||||
// Recording state
|
||||
isRecording,
|
||||
mediaRecorder,
|
||||
audioChunks,
|
||||
audioBlobURL,
|
||||
recordingTime,
|
||||
recordingInterval,
|
||||
canRecordAudio,
|
||||
canRecordSystemAudio,
|
||||
systemAudioSupported,
|
||||
systemAudioError,
|
||||
recordingNotes,
|
||||
showSystemAudioHelp,
|
||||
|
||||
// ASR options
|
||||
asrLanguage,
|
||||
asrMinSpeakers,
|
||||
asrMaxSpeakers,
|
||||
|
||||
// Audio context
|
||||
audioContext,
|
||||
analyser,
|
||||
micAnalyser,
|
||||
systemAnalyser,
|
||||
visualizer,
|
||||
micVisualizer,
|
||||
systemVisualizer,
|
||||
animationFrameId,
|
||||
recordingMode,
|
||||
activeStreams,
|
||||
|
||||
// Wake lock
|
||||
wakeLock,
|
||||
recordingNotification,
|
||||
isPageVisible,
|
||||
|
||||
// Size monitoring
|
||||
estimatedFileSize,
|
||||
fileSizeWarningShown,
|
||||
recordingQuality,
|
||||
actualBitrate,
|
||||
maxRecordingMB,
|
||||
sizeCheckInterval
|
||||
};
|
||||
}
|
||||
23
static/js/modules/state/chat.js
Normal file
23
static/js/modules/state/chat.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Chat state management
|
||||
*/
|
||||
|
||||
export function createChatState(ref) {
|
||||
const showChat = ref(false);
|
||||
const isChatMaximized = ref(false);
|
||||
const chatMessages = ref([]);
|
||||
const chatInput = ref('');
|
||||
const isChatLoading = ref(false);
|
||||
const chatMessagesRef = ref(null);
|
||||
const chatInputRef = ref(null);
|
||||
|
||||
return {
|
||||
showChat,
|
||||
isChatMaximized,
|
||||
chatMessages,
|
||||
chatInput,
|
||||
isChatLoading,
|
||||
chatMessagesRef,
|
||||
chatInputRef
|
||||
};
|
||||
}
|
||||
164
static/js/modules/state/core.js
Normal file
164
static/js/modules/state/core.js
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Core application state
|
||||
*/
|
||||
|
||||
export function createCoreState(ref, computed) {
|
||||
// --- Core State ---
|
||||
const currentView = ref('upload');
|
||||
const dragover = ref(false);
|
||||
const recordings = ref([]);
|
||||
const selectedRecording = ref(null);
|
||||
const selectedTab = ref('summary');
|
||||
const searchQuery = ref('');
|
||||
const isLoadingRecordings = ref(true);
|
||||
const globalError = ref(null);
|
||||
|
||||
// --- Pagination State ---
|
||||
const currentPage = ref(1);
|
||||
const perPage = ref(25);
|
||||
const totalRecordings = ref(0);
|
||||
const totalPages = ref(0);
|
||||
const hasNextPage = ref(false);
|
||||
const hasPrevPage = ref(false);
|
||||
const isLoadingMore = ref(false);
|
||||
const searchDebounceTimer = ref(null);
|
||||
|
||||
// --- Enhanced Search & Organization State ---
|
||||
const sortBy = ref('created_at');
|
||||
const selectedTagFilter = ref(null);
|
||||
|
||||
// Advanced filter state
|
||||
const showAdvancedFilters = ref(false);
|
||||
const filterTags = ref([]);
|
||||
const filterSpeakers = ref([]); // Array of speaker names for filtering
|
||||
const filterDateRange = ref({ start: '', end: '' });
|
||||
const filterDatePreset = ref('');
|
||||
const filterTextQuery = ref('');
|
||||
const showArchivedRecordings = ref(false);
|
||||
const showSharedWithMe = ref(false);
|
||||
|
||||
// --- App Configuration ---
|
||||
const useAsrEndpoint = ref(false);
|
||||
const connectorSupportsDiarization = ref(false); // Connector capability for diarization UI
|
||||
const connectorSupportsSpeakerCount = ref(false); // Connector capability for min/max speakers
|
||||
const currentUserName = ref('');
|
||||
const canDeleteRecordings = ref(true);
|
||||
const enableInternalSharing = ref(false);
|
||||
const enableArchiveToggle = ref(false);
|
||||
const showUsernamesInUI = ref(false);
|
||||
|
||||
// --- Incognito Mode State ---
|
||||
const enableIncognitoMode = ref(false); // Server config - whether feature is available
|
||||
const incognitoMode = ref(false); // User toggle - whether to use incognito for current upload
|
||||
const incognitoRecording = ref(null);
|
||||
const incognitoProcessing = ref(false);
|
||||
|
||||
// Tag Selection
|
||||
const availableTags = ref([]);
|
||||
const selectedTagIds = ref([]);
|
||||
const uploadTagSearchFilter = ref('');
|
||||
|
||||
// Folder Selection
|
||||
const availableFolders = ref([]);
|
||||
const selectedFolderId = ref(null);
|
||||
const foldersEnabled = ref(false);
|
||||
const filterFolder = ref(''); // '' = all, 'none' = no folder, or folder id
|
||||
|
||||
// Speaker Selection
|
||||
const availableSpeakers = ref([]);
|
||||
|
||||
const selectedTags = computed(() => {
|
||||
return selectedTagIds.value.map(tagId =>
|
||||
availableTags.value.find(tag => tag.id == tagId)
|
||||
).filter(Boolean);
|
||||
});
|
||||
|
||||
const filteredAvailableTagsForUpload = computed(() => {
|
||||
const availableForSelection = availableTags.value.filter(tag => !selectedTagIds.value.includes(tag.id));
|
||||
if (!uploadTagSearchFilter.value) return availableForSelection;
|
||||
|
||||
const filter = uploadTagSearchFilter.value.toLowerCase();
|
||||
return availableForSelection.filter(tag =>
|
||||
tag.name.toLowerCase().includes(filter)
|
||||
);
|
||||
});
|
||||
|
||||
const filteredRecordings = computed(() => {
|
||||
return recordings.value;
|
||||
});
|
||||
|
||||
const setGlobalError = (message, duration = 7000) => {
|
||||
globalError.value = message;
|
||||
if (duration > 0) {
|
||||
setTimeout(() => { if (globalError.value === message) globalError.value = null; }, duration);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// Core
|
||||
currentView,
|
||||
dragover,
|
||||
recordings,
|
||||
selectedRecording,
|
||||
selectedTab,
|
||||
searchQuery,
|
||||
isLoadingRecordings,
|
||||
globalError,
|
||||
setGlobalError,
|
||||
|
||||
// Pagination
|
||||
currentPage,
|
||||
perPage,
|
||||
totalRecordings,
|
||||
totalPages,
|
||||
hasNextPage,
|
||||
hasPrevPage,
|
||||
isLoadingMore,
|
||||
searchDebounceTimer,
|
||||
|
||||
// Search & Organization
|
||||
sortBy,
|
||||
selectedTagFilter,
|
||||
showAdvancedFilters,
|
||||
filterTags,
|
||||
filterSpeakers,
|
||||
filterDateRange,
|
||||
filterDatePreset,
|
||||
filterTextQuery,
|
||||
showArchivedRecordings,
|
||||
showSharedWithMe,
|
||||
|
||||
// App Configuration
|
||||
useAsrEndpoint,
|
||||
connectorSupportsDiarization,
|
||||
connectorSupportsSpeakerCount,
|
||||
currentUserName,
|
||||
canDeleteRecordings,
|
||||
enableInternalSharing,
|
||||
enableArchiveToggle,
|
||||
showUsernamesInUI,
|
||||
|
||||
// Tags
|
||||
availableTags,
|
||||
selectedTagIds,
|
||||
uploadTagSearchFilter,
|
||||
selectedTags,
|
||||
filteredAvailableTagsForUpload,
|
||||
filteredRecordings,
|
||||
|
||||
// Folders
|
||||
availableFolders,
|
||||
selectedFolderId,
|
||||
foldersEnabled,
|
||||
filterFolder,
|
||||
|
||||
// Speakers
|
||||
availableSpeakers,
|
||||
|
||||
// Incognito Mode
|
||||
enableIncognitoMode,
|
||||
incognitoMode,
|
||||
incognitoRecording,
|
||||
incognitoProcessing
|
||||
};
|
||||
}
|
||||
11
static/js/modules/state/index.js
Normal file
11
static/js/modules/state/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* State module exports
|
||||
*/
|
||||
|
||||
export { createCoreState } from './core.js';
|
||||
export { createUIState } from './ui.js';
|
||||
export { createUploadState } from './upload.js';
|
||||
export { createAudioState } from './audio.js';
|
||||
export { createModalState } from './modals.js';
|
||||
export { createChatState } from './chat.js';
|
||||
export { createSharingState } from './sharing.js';
|
||||
193
static/js/modules/state/modals.js
Normal file
193
static/js/modules/state/modals.js
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Modal state management
|
||||
*/
|
||||
|
||||
export function createModalState(ref, reactive) {
|
||||
// --- Modal Visibility State ---
|
||||
const showEditModal = ref(false);
|
||||
const showDeleteModal = ref(false);
|
||||
const showEditTagsModal = ref(false);
|
||||
const showReprocessModal = ref(false);
|
||||
const showResetModal = ref(false);
|
||||
const showSpeakerModal = ref(false);
|
||||
const speakerModalTab = ref('speakers'); // 'speakers' or 'transcript' for mobile view
|
||||
const showShareModal = ref(false);
|
||||
const showSharesListModal = ref(false);
|
||||
const showTextEditorModal = ref(false);
|
||||
const showAsrEditorModal = ref(false);
|
||||
const showEditSpeakersModal = ref(false);
|
||||
const showEditTextModal = ref(false);
|
||||
const showAddSpeakerModal = ref(false);
|
||||
const showShareDeleteModal = ref(false);
|
||||
const showUnifiedShareModal = ref(false);
|
||||
const showDateTimePicker = ref(false);
|
||||
|
||||
// --- DateTime Picker State ---
|
||||
const pickerMonth = ref(new Date().getMonth());
|
||||
const pickerYear = ref(new Date().getFullYear());
|
||||
const pickerHour = ref(12);
|
||||
const pickerMinute = ref(0);
|
||||
const pickerAmPm = ref('PM');
|
||||
const pickerSelectedDate = ref(null);
|
||||
const dateTimePickerTarget = ref(null); // 'meeting_date' or other field name
|
||||
const dateTimePickerCallback = ref(null); // callback function after applying
|
||||
|
||||
// --- Modal Data State ---
|
||||
const selectedNewTagId = ref('');
|
||||
const tagSearchFilter = ref('');
|
||||
const editingRecording = ref(null);
|
||||
const editingTranscriptionContent = ref('');
|
||||
const editingSegments = ref([]);
|
||||
const availableSpeakers = ref([]);
|
||||
const editingSpeakersList = ref([]);
|
||||
const databaseSpeakers = ref([]);
|
||||
const editingSpeakerSuggestions = ref({});
|
||||
const recordingToDelete = ref(null);
|
||||
const recordingToReset = ref(null);
|
||||
const reprocessType = ref(null);
|
||||
const reprocessRecording = ref(null);
|
||||
const isAutoIdentifying = ref(false);
|
||||
|
||||
const asrReprocessOptions = reactive({
|
||||
language: '',
|
||||
min_speakers: null,
|
||||
max_speakers: null
|
||||
});
|
||||
|
||||
const summaryReprocessPromptSource = ref('default');
|
||||
const summaryReprocessSelectedTagId = ref('');
|
||||
const summaryReprocessCustomPrompt = ref('');
|
||||
const speakerMap = ref({});
|
||||
const regenerateSummaryAfterSpeakerUpdate = ref(true);
|
||||
const speakerSuggestions = ref({});
|
||||
const loadingSuggestions = ref({});
|
||||
const activeSpeakerInput = ref(null);
|
||||
const voiceSuggestions = ref({});
|
||||
const loadingVoiceSuggestions = ref(false);
|
||||
|
||||
// --- Transcript Editing State ---
|
||||
const editingSegmentIndex = ref(null);
|
||||
const editingSpeakerIndex = ref(null);
|
||||
const editedText = ref('');
|
||||
const newSpeakerName = ref('');
|
||||
const newSpeakerIsMe = ref(false);
|
||||
const editedTranscriptData = ref(null);
|
||||
|
||||
// --- Inline Editing State ---
|
||||
const editingParticipants = ref(false);
|
||||
const editingMeetingDate = ref(false);
|
||||
const editingSummary = ref(false);
|
||||
const editingNotes = ref(false);
|
||||
const tempNotesContent = ref('');
|
||||
const tempSummaryContent = ref('');
|
||||
const autoSaveTimer = ref(null);
|
||||
const autoSaveDelay = 2000;
|
||||
|
||||
// --- Markdown Editor State ---
|
||||
const notesMarkdownEditor = ref(null);
|
||||
const markdownEditorInstance = ref(null);
|
||||
const summaryMarkdownEditor = ref(null);
|
||||
const summaryMarkdownEditorInstance = ref(null);
|
||||
const recordingNotesEditor = ref(null);
|
||||
const recordingMarkdownEditorInstance = ref(null);
|
||||
|
||||
// --- Dropdown Positions ---
|
||||
const dropdownPositions = ref({});
|
||||
const editSpeakerDropdownPositions = ref({});
|
||||
|
||||
// --- Single-ref dropdown tracking (performance optimization) ---
|
||||
// Instead of each segment having showSuggestions property (O(n) to close all),
|
||||
// track which dropdown is open with a single ref (O(1) operations)
|
||||
const openAsrDropdownIndex = ref(null);
|
||||
|
||||
return {
|
||||
// Modal visibility
|
||||
showEditModal,
|
||||
showDeleteModal,
|
||||
showEditTagsModal,
|
||||
showReprocessModal,
|
||||
showResetModal,
|
||||
showSpeakerModal,
|
||||
speakerModalTab,
|
||||
showShareModal,
|
||||
showSharesListModal,
|
||||
showTextEditorModal,
|
||||
showAsrEditorModal,
|
||||
showEditSpeakersModal,
|
||||
showEditTextModal,
|
||||
showAddSpeakerModal,
|
||||
showShareDeleteModal,
|
||||
showUnifiedShareModal,
|
||||
showDateTimePicker,
|
||||
|
||||
// DateTime picker
|
||||
pickerMonth,
|
||||
pickerYear,
|
||||
pickerHour,
|
||||
pickerMinute,
|
||||
pickerAmPm,
|
||||
pickerSelectedDate,
|
||||
dateTimePickerTarget,
|
||||
dateTimePickerCallback,
|
||||
|
||||
// Modal data
|
||||
selectedNewTagId,
|
||||
tagSearchFilter,
|
||||
editingRecording,
|
||||
editingTranscriptionContent,
|
||||
editingSegments,
|
||||
availableSpeakers,
|
||||
editingSpeakersList,
|
||||
databaseSpeakers,
|
||||
editingSpeakerSuggestions,
|
||||
recordingToDelete,
|
||||
recordingToReset,
|
||||
reprocessType,
|
||||
reprocessRecording,
|
||||
isAutoIdentifying,
|
||||
asrReprocessOptions,
|
||||
summaryReprocessPromptSource,
|
||||
summaryReprocessSelectedTagId,
|
||||
summaryReprocessCustomPrompt,
|
||||
speakerMap,
|
||||
regenerateSummaryAfterSpeakerUpdate,
|
||||
speakerSuggestions,
|
||||
loadingSuggestions,
|
||||
activeSpeakerInput,
|
||||
voiceSuggestions,
|
||||
loadingVoiceSuggestions,
|
||||
|
||||
// Transcript editing
|
||||
editingSegmentIndex,
|
||||
editingSpeakerIndex,
|
||||
editedText,
|
||||
newSpeakerName,
|
||||
newSpeakerIsMe,
|
||||
editedTranscriptData,
|
||||
|
||||
// Inline editing
|
||||
editingParticipants,
|
||||
editingMeetingDate,
|
||||
editingSummary,
|
||||
editingNotes,
|
||||
tempNotesContent,
|
||||
tempSummaryContent,
|
||||
autoSaveTimer,
|
||||
autoSaveDelay,
|
||||
|
||||
// Markdown editors
|
||||
notesMarkdownEditor,
|
||||
markdownEditorInstance,
|
||||
summaryMarkdownEditor,
|
||||
summaryMarkdownEditorInstance,
|
||||
recordingNotesEditor,
|
||||
recordingMarkdownEditorInstance,
|
||||
|
||||
// Dropdown positions
|
||||
dropdownPositions,
|
||||
editSpeakerDropdownPositions,
|
||||
|
||||
// Single-ref dropdown tracking
|
||||
openAsrDropdownIndex
|
||||
};
|
||||
}
|
||||
39
static/js/modules/state/pwa.js
Normal file
39
static/js/modules/state/pwa.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* PWA state management
|
||||
*/
|
||||
|
||||
export function createPWAState(ref) {
|
||||
// --- Install Prompt ---
|
||||
const deferredInstallPrompt = ref(null);
|
||||
const showInstallButton = ref(false);
|
||||
const isPWAInstalled = ref(false);
|
||||
|
||||
// --- Notifications ---
|
||||
const notificationPermission = ref('default');
|
||||
const pushSubscription = ref(null);
|
||||
|
||||
// --- Badging ---
|
||||
const appBadgeCount = ref(0);
|
||||
|
||||
// --- Media Session ---
|
||||
const currentMediaMetadata = ref(null);
|
||||
const isMediaSessionActive = ref(false);
|
||||
|
||||
return {
|
||||
// Install prompt
|
||||
deferredInstallPrompt,
|
||||
showInstallButton,
|
||||
isPWAInstalled,
|
||||
|
||||
// Notifications
|
||||
notificationPermission,
|
||||
pushSubscription,
|
||||
|
||||
// Badging
|
||||
appBadgeCount,
|
||||
|
||||
// Media session
|
||||
currentMediaMetadata,
|
||||
isMediaSessionActive
|
||||
};
|
||||
}
|
||||
66
static/js/modules/state/sharing.js
Normal file
66
static/js/modules/state/sharing.js
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Sharing state management
|
||||
*/
|
||||
|
||||
export function createSharingState(ref, reactive) {
|
||||
// --- Public Sharing State ---
|
||||
const recordingToShare = ref(null);
|
||||
const shareOptions = reactive({
|
||||
share_summary: true,
|
||||
share_notes: true,
|
||||
});
|
||||
const generatedShareLink = ref('');
|
||||
const existingShareDetected = ref(false);
|
||||
const userShares = ref([]);
|
||||
const isLoadingShares = ref(false);
|
||||
const shareToDelete = ref(null);
|
||||
|
||||
// --- Internal Sharing State ---
|
||||
const internalShareUserSearch = ref('');
|
||||
const internalShareSearchResults = ref([]);
|
||||
const internalShareRecording = ref(null);
|
||||
const internalSharePermissions = ref({ can_edit: false, can_reshare: false });
|
||||
const recordingInternalShares = ref([]);
|
||||
const isLoadingInternalShares = ref(false);
|
||||
const isSearchingUsers = ref(false);
|
||||
const allUsers = ref([]);
|
||||
const isLoadingAllUsers = ref(false);
|
||||
|
||||
// --- Audio Player State ---
|
||||
const playerVolume = ref(1.0);
|
||||
const audioIsPlaying = ref(false);
|
||||
const audioCurrentTime = ref(0);
|
||||
const audioDuration = ref(0);
|
||||
const audioIsMuted = ref(false);
|
||||
const audioIsLoading = ref(false);
|
||||
|
||||
return {
|
||||
// Public sharing
|
||||
recordingToShare,
|
||||
shareOptions,
|
||||
generatedShareLink,
|
||||
existingShareDetected,
|
||||
userShares,
|
||||
isLoadingShares,
|
||||
shareToDelete,
|
||||
|
||||
// Internal sharing
|
||||
internalShareUserSearch,
|
||||
internalShareSearchResults,
|
||||
internalShareRecording,
|
||||
internalSharePermissions,
|
||||
recordingInternalShares,
|
||||
isLoadingInternalShares,
|
||||
isSearchingUsers,
|
||||
allUsers,
|
||||
isLoadingAllUsers,
|
||||
|
||||
// Audio player
|
||||
playerVolume,
|
||||
audioIsPlaying,
|
||||
audioCurrentTime,
|
||||
audioDuration,
|
||||
audioIsMuted,
|
||||
audioIsLoading
|
||||
};
|
||||
}
|
||||
109
static/js/modules/state/ui.js
Normal file
109
static/js/modules/state/ui.js
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* UI state management
|
||||
*/
|
||||
|
||||
export function createUIState(ref, computed) {
|
||||
// --- UI State ---
|
||||
const browser = ref('unknown');
|
||||
const isSidebarCollapsed = ref(false);
|
||||
const searchTipsExpanded = ref(false);
|
||||
const isUserMenuOpen = ref(false);
|
||||
const isDarkMode = ref(false);
|
||||
const currentColorScheme = ref('blue');
|
||||
const showColorSchemeModal = ref(false);
|
||||
const windowWidth = ref(window.innerWidth);
|
||||
const mobileTab = ref('transcript');
|
||||
const isMetadataExpanded = ref(false);
|
||||
const expandedSection = ref('settings'); // 'notes' or 'settings'
|
||||
|
||||
// --- i18n State ---
|
||||
const currentLanguage = ref('en');
|
||||
const currentLanguageName = ref('English');
|
||||
const availableLanguages = ref([]);
|
||||
const showLanguageMenu = ref(false);
|
||||
|
||||
// --- Column Resizing State ---
|
||||
const leftColumnWidth = ref(60);
|
||||
const rightColumnWidth = ref(40);
|
||||
const isResizing = ref(false);
|
||||
|
||||
// --- Transcription State ---
|
||||
const transcriptionViewMode = ref('simple');
|
||||
const legendExpanded = ref(false);
|
||||
const highlightedSpeaker = ref(null);
|
||||
const processingIndicatorMinimized = ref(false);
|
||||
|
||||
// --- Virtual Scroll State ---
|
||||
// For transcript panel virtual scrolling (performance optimization for long transcriptions)
|
||||
const transcriptScrollTop = ref(0);
|
||||
const transcriptContainerHeight = ref(0);
|
||||
const transcriptItemHeight = 48; // Estimated height per segment in pixels
|
||||
|
||||
// --- Computed Properties ---
|
||||
const isMobileScreen = computed(() => {
|
||||
return windowWidth.value < 1024;
|
||||
});
|
||||
|
||||
// --- Color Scheme Definitions ---
|
||||
const colorSchemes = {
|
||||
light: [
|
||||
{ id: 'blue', name: 'Ocean Blue', description: 'Classic blue theme with professional appeal', class: '' },
|
||||
{ id: 'emerald', name: 'Forest Emerald', description: 'Fresh green theme for a natural feel', class: 'theme-light-emerald' },
|
||||
{ id: 'purple', name: 'Royal Purple', description: 'Elegant purple theme with sophistication', class: 'theme-light-purple' },
|
||||
{ id: 'rose', name: 'Sunset Rose', description: 'Warm pink theme with gentle energy', class: 'theme-light-rose' },
|
||||
{ id: 'amber', name: 'Golden Amber', description: 'Warm yellow theme for brightness', class: 'theme-light-amber' },
|
||||
{ id: 'teal', name: 'Ocean Teal', description: 'Cool teal theme for tranquility', class: 'theme-light-teal' }
|
||||
],
|
||||
dark: [
|
||||
{ id: 'blue', name: 'Midnight Blue', description: 'Deep blue theme for focused work', class: '' },
|
||||
{ id: 'emerald', name: 'Dark Forest', description: 'Rich green theme for comfortable viewing', class: 'theme-dark-emerald' },
|
||||
{ id: 'purple', name: 'Deep Purple', description: 'Mysterious purple theme for creativity', class: 'theme-dark-purple' },
|
||||
{ id: 'rose', name: 'Dark Rose', description: 'Muted pink theme with subtle warmth', class: 'theme-dark-rose' },
|
||||
{ id: 'amber', name: 'Dark Amber', description: 'Warm brown theme for cozy sessions', class: 'theme-dark-amber' },
|
||||
{ id: 'teal', name: 'Deep Teal', description: 'Dark teal theme for calm focus', class: 'theme-dark-teal' }
|
||||
]
|
||||
};
|
||||
|
||||
return {
|
||||
// UI
|
||||
browser,
|
||||
isSidebarCollapsed,
|
||||
searchTipsExpanded,
|
||||
isUserMenuOpen,
|
||||
isDarkMode,
|
||||
currentColorScheme,
|
||||
showColorSchemeModal,
|
||||
windowWidth,
|
||||
mobileTab,
|
||||
isMetadataExpanded,
|
||||
expandedSection,
|
||||
|
||||
// i18n
|
||||
currentLanguage,
|
||||
currentLanguageName,
|
||||
availableLanguages,
|
||||
showLanguageMenu,
|
||||
|
||||
// Column Resizing
|
||||
leftColumnWidth,
|
||||
rightColumnWidth,
|
||||
isResizing,
|
||||
|
||||
// Transcription
|
||||
transcriptionViewMode,
|
||||
legendExpanded,
|
||||
highlightedSpeaker,
|
||||
processingIndicatorMinimized,
|
||||
|
||||
// Virtual Scroll
|
||||
transcriptScrollTop,
|
||||
transcriptContainerHeight,
|
||||
transcriptItemHeight,
|
||||
|
||||
// Computed
|
||||
isMobileScreen,
|
||||
|
||||
// Constants
|
||||
colorSchemes
|
||||
};
|
||||
}
|
||||
77
static/js/modules/state/upload.js
Normal file
77
static/js/modules/state/upload.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Upload state management
|
||||
*/
|
||||
|
||||
export function createUploadState(ref, computed) {
|
||||
// --- Upload State ---
|
||||
const uploadQueue = ref([]);
|
||||
const currentlyProcessingFile = ref(null);
|
||||
const processingProgress = ref(0);
|
||||
const processingMessage = ref('');
|
||||
const isProcessingActive = ref(false);
|
||||
const pollInterval = ref(null);
|
||||
const progressPopupMinimized = ref(false);
|
||||
const progressPopupClosed = ref(false);
|
||||
const maxFileSizeMB = ref(250);
|
||||
const chunkingEnabled = ref(true);
|
||||
const chunkingMode = ref('size');
|
||||
const chunkingLimit = ref(20);
|
||||
const chunkingLimitDisplay = ref('20MB');
|
||||
const maxConcurrentUploads = ref(3);
|
||||
const recordingDisclaimer = ref('');
|
||||
const showRecordingDisclaimerModal = ref(false);
|
||||
const pendingRecordingMode = ref(null);
|
||||
|
||||
// Advanced Options for ASR
|
||||
const showAdvancedOptions = ref(false);
|
||||
const uploadLanguage = ref('');
|
||||
const uploadMinSpeakers = ref('');
|
||||
const uploadMaxSpeakers = ref('');
|
||||
const uploadHotwords = ref('');
|
||||
const uploadInitialPrompt = ref('');
|
||||
|
||||
// --- Computed Properties ---
|
||||
const totalInQueue = computed(() => uploadQueue.value.length);
|
||||
const completedInQueue = computed(() => uploadQueue.value.filter(item => item.status === 'completed' || item.status === 'failed').length);
|
||||
const finishedFilesInQueue = computed(() => uploadQueue.value.filter(item => ['completed', 'failed'].includes(item.status)));
|
||||
|
||||
const clearCompletedUploads = () => {
|
||||
uploadQueue.value = uploadQueue.value.filter(item => !['completed', 'failed'].includes(item.status));
|
||||
};
|
||||
|
||||
return {
|
||||
uploadQueue,
|
||||
currentlyProcessingFile,
|
||||
processingProgress,
|
||||
processingMessage,
|
||||
isProcessingActive,
|
||||
pollInterval,
|
||||
progressPopupMinimized,
|
||||
progressPopupClosed,
|
||||
maxFileSizeMB,
|
||||
chunkingEnabled,
|
||||
chunkingMode,
|
||||
chunkingLimit,
|
||||
chunkingLimitDisplay,
|
||||
maxConcurrentUploads,
|
||||
recordingDisclaimer,
|
||||
showRecordingDisclaimerModal,
|
||||
pendingRecordingMode,
|
||||
|
||||
// Advanced Options
|
||||
showAdvancedOptions,
|
||||
uploadLanguage,
|
||||
uploadMinSpeakers,
|
||||
uploadMaxSpeakers,
|
||||
uploadHotwords,
|
||||
uploadInitialPrompt,
|
||||
|
||||
// Computed
|
||||
totalInQueue,
|
||||
completedInQueue,
|
||||
finishedFilesInQueue,
|
||||
|
||||
// Methods
|
||||
clearCompletedUploads
|
||||
};
|
||||
}
|
||||
61
static/js/modules/utils/api.js
Normal file
61
static/js/modules/utils/api.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* API utility functions with CSRF token handling
|
||||
*/
|
||||
|
||||
export const createApiClient = (csrfToken) => {
|
||||
const getHeaders = (contentType = 'application/json') => {
|
||||
const headers = {
|
||||
'X-CSRFToken': csrfToken.value
|
||||
};
|
||||
if (contentType) {
|
||||
headers['Content-Type'] = contentType;
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
return {
|
||||
get: async (url) => {
|
||||
const response = await fetch(url, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
post: async (url, data = {}) => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
postFormData: async (url, formData) => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken.value
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
delete: async (url) => {
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
put: async (url, data = {}) => {
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return response;
|
||||
}
|
||||
};
|
||||
};
|
||||
50
static/js/modules/utils/colors.js
Normal file
50
static/js/modules/utils/colors.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Color utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Calculate the relative luminance of a color
|
||||
* Based on WCAG contrast ratio formula
|
||||
* @param {string} hexColor - Hex color code (e.g., "#RRGGBB" or "#RGB")
|
||||
* @returns {number} Luminance value between 0 and 1
|
||||
*/
|
||||
function calculateLuminance(hexColor) {
|
||||
// Remove # if present
|
||||
const hex = hexColor.replace('#', '');
|
||||
|
||||
// Convert 3-digit hex to 6-digit
|
||||
const fullHex = hex.length === 3
|
||||
? hex.split('').map(char => char + char).join('')
|
||||
: hex;
|
||||
|
||||
// Parse RGB values
|
||||
const r = parseInt(fullHex.substr(0, 2), 16) / 255;
|
||||
const g = parseInt(fullHex.substr(2, 2), 16) / 255;
|
||||
const b = parseInt(fullHex.substr(4, 2), 16) / 255;
|
||||
|
||||
// Calculate relative luminance using simplified formula
|
||||
// (More accurate would use gamma correction, but this is sufficient)
|
||||
return 0.299 * r + 0.587 * g + 0.114 * b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate text color (black or white) for a given background color
|
||||
* Ensures readable contrast based on background luminance
|
||||
* @param {string} bgColor - Background color in hex format
|
||||
* @returns {string} Either 'white' or 'black'
|
||||
*/
|
||||
export function getContrastTextColor(bgColor) {
|
||||
if (!bgColor) {
|
||||
return 'white'; // Default to white for undefined colors
|
||||
}
|
||||
|
||||
try {
|
||||
const luminance = calculateLuminance(bgColor);
|
||||
// Threshold of 0.65: only very light backgrounds get black text
|
||||
// This ensures medium/dark colors like greens, blues still get white text
|
||||
return luminance > 0.65 ? 'black' : 'white';
|
||||
} catch (e) {
|
||||
console.warn('Failed to calculate contrast color for:', bgColor, e);
|
||||
return 'white'; // Fallback to white
|
||||
}
|
||||
}
|
||||
67
static/js/modules/utils/dates.js
Normal file
67
static/js/modules/utils/dates.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Date comparison utility functions
|
||||
*/
|
||||
|
||||
export const isSameDay = (date1, date2) => {
|
||||
return date1.getFullYear() === date2.getFullYear() &&
|
||||
date1.getMonth() === date2.getMonth() &&
|
||||
date1.getDate() === date2.getDate();
|
||||
};
|
||||
|
||||
export const isToday = (date) => {
|
||||
const today = new Date();
|
||||
return isSameDay(date, today);
|
||||
};
|
||||
|
||||
export const isYesterday = (date) => {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return isSameDay(date, yesterday);
|
||||
};
|
||||
|
||||
export const isThisWeek = (date) => {
|
||||
const now = new Date();
|
||||
const startOfWeek = new Date(now);
|
||||
const day = now.getDay();
|
||||
const diff = now.getDate() - day + (day === 0 ? -6 : 1); // Monday as start of week
|
||||
startOfWeek.setDate(diff);
|
||||
startOfWeek.setHours(0, 0, 0, 0);
|
||||
|
||||
const endOfWeek = new Date(startOfWeek);
|
||||
endOfWeek.setDate(startOfWeek.getDate() + 6);
|
||||
endOfWeek.setHours(23, 59, 59, 999);
|
||||
|
||||
return date >= startOfWeek && date <= endOfWeek;
|
||||
};
|
||||
|
||||
export const isLastWeek = (date) => {
|
||||
const now = new Date();
|
||||
const startOfLastWeek = new Date(now);
|
||||
const day = now.getDay();
|
||||
const diff = now.getDate() - day + (day === 0 ? -6 : 1) - 7; // Previous Monday
|
||||
startOfLastWeek.setDate(diff);
|
||||
startOfLastWeek.setHours(0, 0, 0, 0);
|
||||
|
||||
const endOfLastWeek = new Date(startOfLastWeek);
|
||||
endOfLastWeek.setDate(startOfLastWeek.getDate() + 6);
|
||||
endOfLastWeek.setHours(23, 59, 59, 999);
|
||||
|
||||
return date >= startOfLastWeek && date <= endOfLastWeek;
|
||||
};
|
||||
|
||||
export const isThisMonth = (date) => {
|
||||
const now = new Date();
|
||||
return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth();
|
||||
};
|
||||
|
||||
export const isLastMonth = (date) => {
|
||||
const now = new Date();
|
||||
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
return date.getFullYear() === lastMonth.getFullYear() && date.getMonth() === lastMonth.getMonth();
|
||||
};
|
||||
|
||||
export const getDateForSorting = (recording, sortBy) => {
|
||||
const dateStr = sortBy === 'meeting_date' ? recording.meeting_date : recording.created_at;
|
||||
if (!dateStr) return null;
|
||||
return new Date(dateStr);
|
||||
};
|
||||
271
static/js/modules/utils/errorDisplay.js
Normal file
271
static/js/modules/utils/errorDisplay.js
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Error Display Utility
|
||||
*
|
||||
* Parses and displays user-friendly error messages from the backend.
|
||||
* Handles both JSON-formatted errors (ERROR_JSON:...) and plain text errors.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a stored error message from the backend.
|
||||
* @param {string} text - The stored transcription/error text
|
||||
* @returns {Object|null} - Parsed error object or null if not an error
|
||||
*/
|
||||
export function parseStoredError(text) {
|
||||
if (!text) return null;
|
||||
|
||||
// Check for JSON-formatted error
|
||||
if (text.startsWith('ERROR_JSON:')) {
|
||||
try {
|
||||
const jsonStr = text.substring(11); // Remove 'ERROR_JSON:' prefix
|
||||
const data = JSON.parse(jsonStr);
|
||||
return {
|
||||
title: data.t || 'Error',
|
||||
message: data.m || 'An error occurred',
|
||||
guidance: data.g || '',
|
||||
icon: data.i || 'fa-exclamation-circle',
|
||||
type: data.y || 'unknown',
|
||||
isKnown: data.k || false,
|
||||
technical: data.d || '',
|
||||
isFormattedError: true
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Failed to parse error JSON:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for legacy error format (starts with common error prefixes)
|
||||
const errorPrefixes = [
|
||||
'Transcription failed:',
|
||||
'Processing failed:',
|
||||
'ASR processing failed:',
|
||||
'Audio extraction failed:',
|
||||
'Error:'
|
||||
];
|
||||
|
||||
for (const prefix of errorPrefixes) {
|
||||
if (text.startsWith(prefix)) {
|
||||
// Parse the error using pattern matching
|
||||
return parseUnformattedError(text);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an unformatted error message and try to make it user-friendly.
|
||||
* @param {string} text - The raw error text
|
||||
* @returns {Object} - Parsed error object
|
||||
*/
|
||||
function parseUnformattedError(text) {
|
||||
const lowerText = text.toLowerCase();
|
||||
|
||||
// Known error patterns
|
||||
const patterns = [
|
||||
{
|
||||
patterns: ['maximum content size limit', 'file too large', '413', 'payload too large', 'exceeded'],
|
||||
title: 'File Too Large',
|
||||
message: 'The audio file exceeds the maximum size allowed by the transcription service.',
|
||||
guidance: 'Try enabling audio chunking in your settings, or compress the audio file before uploading.',
|
||||
icon: 'fa-file-audio',
|
||||
type: 'size_limit'
|
||||
},
|
||||
{
|
||||
patterns: ['timed out', 'timeout', 'deadline exceeded'],
|
||||
title: 'Processing Timeout',
|
||||
message: 'The transcription took too long to complete.',
|
||||
guidance: 'This can happen with very long recordings. Try splitting the audio into smaller parts.',
|
||||
icon: 'fa-clock',
|
||||
type: 'timeout'
|
||||
},
|
||||
{
|
||||
patterns: ['401', 'unauthorized', 'invalid api key', 'authentication failed', 'incorrect api key'],
|
||||
title: 'Authentication Error',
|
||||
message: 'The transcription service rejected the API credentials.',
|
||||
guidance: 'Please check that the API key is correct and has not expired.',
|
||||
icon: 'fa-key',
|
||||
type: 'auth'
|
||||
},
|
||||
{
|
||||
patterns: ['rate limit', 'too many requests', '429', 'quota exceeded'],
|
||||
title: 'Rate Limit Exceeded',
|
||||
message: 'Too many requests were sent to the transcription service.',
|
||||
guidance: 'Please wait a few minutes and try reprocessing.',
|
||||
icon: 'fa-hourglass-half',
|
||||
type: 'rate_limit'
|
||||
},
|
||||
{
|
||||
patterns: ['connection refused', 'connection reset', 'could not connect', 'network unreachable'],
|
||||
title: 'Connection Error',
|
||||
message: 'Could not connect to the transcription service.',
|
||||
guidance: 'Check your internet connection and ensure the service is available.',
|
||||
icon: 'fa-wifi',
|
||||
type: 'connection'
|
||||
},
|
||||
{
|
||||
patterns: ['503', '502', '500', 'service unavailable', 'server error', 'internal server error'],
|
||||
title: 'Service Unavailable',
|
||||
message: 'The transcription service is temporarily unavailable.',
|
||||
guidance: 'This is usually temporary. Please try again in a few minutes.',
|
||||
icon: 'fa-server',
|
||||
type: 'service_error'
|
||||
},
|
||||
{
|
||||
patterns: ['invalid file format', 'unsupported format', 'could not decode', 'corrupt', 'not valid audio'],
|
||||
title: 'Invalid Audio Format',
|
||||
message: 'The audio file format is not supported or the file may be corrupted.',
|
||||
guidance: 'Try converting the audio to MP3 or WAV format before uploading.',
|
||||
icon: 'fa-file-audio',
|
||||
type: 'format'
|
||||
},
|
||||
{
|
||||
patterns: ['audio extraction failed', 'ffmpeg failed', 'no audio stream'],
|
||||
title: 'Audio Extraction Failed',
|
||||
message: 'Could not extract audio from the uploaded file.',
|
||||
guidance: 'Try converting the file to a standard audio format (MP3, WAV) before uploading.',
|
||||
icon: 'fa-file-video',
|
||||
type: 'extraction'
|
||||
}
|
||||
];
|
||||
|
||||
// Check patterns
|
||||
for (const pattern of patterns) {
|
||||
for (const p of pattern.patterns) {
|
||||
if (lowerText.includes(p)) {
|
||||
return {
|
||||
title: pattern.title,
|
||||
message: pattern.message,
|
||||
guidance: pattern.guidance,
|
||||
icon: pattern.icon,
|
||||
type: pattern.type,
|
||||
isKnown: true,
|
||||
technical: text,
|
||||
isFormattedError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown error - clean it up as best we can
|
||||
let cleanMessage = text;
|
||||
for (const prefix of ['Transcription failed:', 'Processing failed:', 'Error:', 'ASR processing failed:']) {
|
||||
if (cleanMessage.startsWith(prefix)) {
|
||||
cleanMessage = cleanMessage.substring(prefix.length).trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate if too long
|
||||
if (cleanMessage.length > 200) {
|
||||
cleanMessage = cleanMessage.substring(0, 200) + '...';
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Processing Error',
|
||||
message: cleanMessage,
|
||||
guidance: 'If this error persists, try reprocessing the recording.',
|
||||
icon: 'fa-exclamation-circle',
|
||||
type: 'unknown',
|
||||
isKnown: false,
|
||||
technical: text,
|
||||
isFormattedError: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a transcription text is actually an error message.
|
||||
* @param {string} text - The transcription text
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isErrorMessage(text) {
|
||||
if (!text) return false;
|
||||
|
||||
if (text.startsWith('ERROR_JSON:')) return true;
|
||||
|
||||
const errorPrefixes = [
|
||||
'Transcription failed:',
|
||||
'Processing failed:',
|
||||
'ASR processing failed:',
|
||||
'Audio extraction failed:'
|
||||
];
|
||||
|
||||
return errorPrefixes.some(prefix => text.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML for displaying an error nicely.
|
||||
* @param {Object} error - Parsed error object from parseStoredError
|
||||
* @param {boolean} showTechnical - Whether to show technical details
|
||||
* @returns {string} - HTML string
|
||||
*/
|
||||
export function generateErrorHTML(error, showTechnical = false) {
|
||||
if (!error) return '';
|
||||
|
||||
const typeColors = {
|
||||
size_limit: 'amber',
|
||||
timeout: 'orange',
|
||||
auth: 'red',
|
||||
rate_limit: 'yellow',
|
||||
connection: 'blue',
|
||||
service_error: 'purple',
|
||||
format: 'pink',
|
||||
extraction: 'indigo',
|
||||
billing: 'red',
|
||||
model: 'gray',
|
||||
unknown: 'gray'
|
||||
};
|
||||
|
||||
const color = typeColors[error.type] || 'gray';
|
||||
|
||||
let html = `
|
||||
<div class="error-display bg-${color}-500/10 border border-${color}-500/30 rounded-lg p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-${color}-500/20 flex items-center justify-center">
|
||||
<i class="fas ${error.icon} text-${color}-500"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-${color}-600 dark:text-${color}-400 mb-1">
|
||||
${escapeHtml(error.title)}
|
||||
</h3>
|
||||
<p class="text-[var(--text-primary)] mb-2">
|
||||
${escapeHtml(error.message)}
|
||||
</p>
|
||||
${error.guidance ? `
|
||||
<div class="flex items-start gap-2 text-sm text-[var(--text-secondary)] bg-[var(--bg-tertiary)]/50 rounded p-2">
|
||||
<i class="fas fa-lightbulb text-yellow-500 mt-0.5"></i>
|
||||
<span>${escapeHtml(error.guidance)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${showTechnical && error.technical ? `
|
||||
<details class="mt-3 text-xs">
|
||||
<summary class="cursor-pointer text-[var(--text-muted)] hover:text-[var(--text-secondary)]">
|
||||
Technical details
|
||||
</summary>
|
||||
<pre class="mt-2 p-2 bg-[var(--bg-tertiary)] rounded overflow-x-auto text-[var(--text-muted)]">${escapeHtml(error.technical)}</pre>
|
||||
</details>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters.
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Export for use in Vue components
|
||||
export default {
|
||||
parseStoredError,
|
||||
isErrorMessage,
|
||||
generateErrorHTML
|
||||
};
|
||||
139
static/js/modules/utils/formatters.js
Normal file
139
static/js/modules/utils/formatters.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Formatting utility functions
|
||||
*/
|
||||
|
||||
export const formatFileSize = (bytes) => {
|
||||
if (bytes == null || bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
if (bytes < 0) bytes = 0;
|
||||
const i = bytes === 0 ? 0 : Math.max(0, Math.floor(Math.log(bytes) / Math.log(k)));
|
||||
const size = i === 0 ? bytes : parseFloat((bytes / Math.pow(k, i)).toFixed(2));
|
||||
return size + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
export const formatDisplayDate = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
let date = new Date(dateString);
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||
date = new Date(dateString + 'T00:00:00');
|
||||
} else {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
return dateString;
|
||||
}
|
||||
|
||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
} catch (e) {
|
||||
console.error("Error formatting date:", e);
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatShortDate = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
let date = new Date(dateString);
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||
date = new Date(dateString + 'T00:00:00');
|
||||
} else {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
return dateString;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const isCurrentYear = date.getFullYear() === now.getFullYear();
|
||||
|
||||
if (isCurrentYear) {
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
} else {
|
||||
return date.toLocaleDateString(undefined, { year: '2-digit', month: 'short', day: 'numeric' });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error formatting short date:", e);
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatStatus = (status, t) => {
|
||||
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();
|
||||
};
|
||||
|
||||
export 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';
|
||||
}
|
||||
};
|
||||
|
||||
export const formatTime = (seconds) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export const formatDuration = (totalSeconds) => {
|
||||
if (totalSeconds == null || totalSeconds < 0) return 'N/A';
|
||||
|
||||
if (totalSeconds < 1) {
|
||||
return `${totalSeconds.toFixed(2)} seconds`;
|
||||
}
|
||||
|
||||
totalSeconds = Math.round(totalSeconds);
|
||||
|
||||
if (totalSeconds < 60) {
|
||||
return `${totalSeconds} sec`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
let parts = [];
|
||||
if (hours > 0) {
|
||||
parts.push(`${hours} hr`);
|
||||
}
|
||||
if (minutes > 0) {
|
||||
parts.push(`${minutes} min`);
|
||||
}
|
||||
if (hours === 0 && seconds > 0) {
|
||||
parts.push(`${seconds} sec`);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
export const formatProcessingDuration = (seconds) => {
|
||||
if (!seconds && seconds !== 0) return null;
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
|
||||
};
|
||||
9
static/js/modules/utils/index.js
Normal file
9
static/js/modules/utils/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Utils module exports
|
||||
*/
|
||||
|
||||
export * from './formatters.js';
|
||||
export * from './dates.js';
|
||||
export { createApiClient } from './api.js';
|
||||
export { showToast } from './toast.js';
|
||||
export { getContrastTextColor } from './colors.js';
|
||||
91
static/js/modules/utils/toast.js
Normal file
91
static/js/modules/utils/toast.js
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Toast notification utility
|
||||
*/
|
||||
|
||||
export const showToast = (message, iconClass = 'fa-info-circle', duration = 3000, type = 'info') => {
|
||||
const container = document.getElementById('toastContainer');
|
||||
if (!container) {
|
||||
console.warn('Toast container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine colors and styles based on type
|
||||
let bgColor, textColor, iconColor, borderColor;
|
||||
|
||||
switch (type) {
|
||||
case 'success':
|
||||
bgColor = '#10b981'; // green-500
|
||||
textColor = '#ffffff';
|
||||
iconColor = '#ffffff';
|
||||
borderColor = '#059669'; // green-600
|
||||
break;
|
||||
case 'error':
|
||||
bgColor = '#ef4444'; // red-500
|
||||
textColor = '#ffffff';
|
||||
iconColor = '#ffffff';
|
||||
borderColor = '#dc2626'; // red-600
|
||||
break;
|
||||
case 'warning':
|
||||
bgColor = '#f59e0b'; // amber-500
|
||||
textColor = '#ffffff';
|
||||
iconColor = '#ffffff';
|
||||
borderColor = '#d97706'; // amber-600
|
||||
break;
|
||||
case 'info':
|
||||
default:
|
||||
bgColor = '#3b82f6'; // blue-500
|
||||
textColor = '#ffffff';
|
||||
iconColor = '#ffffff';
|
||||
borderColor = '#2563eb'; // blue-600
|
||||
break;
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast-message px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 opacity-0 min-w-[300px]';
|
||||
toast.style.backgroundColor = bgColor;
|
||||
toast.style.color = textColor;
|
||||
toast.style.border = `1px solid ${borderColor}`;
|
||||
|
||||
// Handle icon class - support both old format (just icon name) and new format (full class)
|
||||
let fullIconClass = iconClass;
|
||||
if (!iconClass.includes(' ')) {
|
||||
// Old format: just the icon name like 'fa-check-circle'
|
||||
fullIconClass = `fas ${iconClass}`;
|
||||
}
|
||||
|
||||
toast.innerHTML = `
|
||||
<i class="${fullIconClass}" style="color: ${iconColor}"></i>
|
||||
<span class="flex-1">${message}</span>
|
||||
`;
|
||||
|
||||
// Make toast clickable to dismiss
|
||||
toast.style.cursor = 'pointer';
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
// Trigger fly-in animation
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.remove('opacity-0');
|
||||
toast.classList.add('opacity-100', 'toast-show');
|
||||
});
|
||||
|
||||
// Function to dismiss the toast
|
||||
const dismissToast = () => {
|
||||
toast.classList.remove('opacity-100', 'toast-show');
|
||||
toast.classList.add('opacity-0');
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Add click handler to dismiss toast
|
||||
toast.addEventListener('click', () => {
|
||||
clearTimeout(timeoutId);
|
||||
dismissToast();
|
||||
});
|
||||
|
||||
// Auto-dismiss after duration
|
||||
const timeoutId = setTimeout(dismissToast, duration);
|
||||
};
|
||||
262
static/js/shared-components.js
Normal file
262
static/js/shared-components.js
Normal file
@@ -0,0 +1,262 @@
|
||||
// Shared UI Components and Functionality
|
||||
// This file contains reusable Vue composition functions and utilities
|
||||
// that can be used across multiple pages (index, inquire, admin, etc.)
|
||||
|
||||
// Dark Mode Composition
|
||||
function useDarkMode() {
|
||||
const isDarkMode = Vue.ref(false);
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
isDarkMode.value = !isDarkMode.value;
|
||||
if (isDarkMode.value) {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('darkMode', 'true');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('darkMode', 'false');
|
||||
}
|
||||
};
|
||||
|
||||
const initializeDarkMode = () => {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const savedMode = localStorage.getItem('darkMode');
|
||||
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
|
||||
isDarkMode.value = true;
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
isDarkMode.value = false;
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isDarkMode,
|
||||
toggleDarkMode,
|
||||
initializeDarkMode
|
||||
};
|
||||
}
|
||||
|
||||
// Color Scheme Composition
|
||||
function useColorScheme() {
|
||||
const showColorSchemeModal = Vue.ref(false);
|
||||
const currentColorScheme = Vue.ref('blue');
|
||||
const isDarkMode = Vue.ref(false);
|
||||
|
||||
const colorSchemes = {
|
||||
light: [
|
||||
{ id: 'blue', name: 'Ocean Blue', description: 'Classic blue theme with professional appeal', accent: '#3b82f6', hover: '#2563eb' },
|
||||
{ id: 'emerald', name: 'Forest Emerald', description: 'Fresh green theme for a natural feel', accent: '#10b981', hover: '#059669' },
|
||||
{ id: 'purple', name: 'Royal Purple', description: 'Elegant purple theme with sophistication', accent: '#8b5cf6', hover: '#7c3aed' },
|
||||
{ id: 'rose', name: 'Sunset Rose', description: 'Warm pink theme with gentle energy', accent: '#f43f5e', hover: '#e11d48' },
|
||||
{ id: 'amber', name: 'Golden Amber', description: 'Warm yellow theme for brightness', accent: '#f59e0b', hover: '#d97706' },
|
||||
{ id: 'teal', name: 'Ocean Teal', description: 'Cool teal theme for tranquility', accent: '#06b6d4', hover: '#0891b2' }
|
||||
],
|
||||
dark: [
|
||||
{ id: 'blue', name: 'Midnight Blue', description: 'Deep blue for focused night work', accent: '#60a5fa', hover: '#3b82f6' },
|
||||
{ id: 'emerald', name: 'Emerald Night', description: 'Rich green for comfortable viewing', accent: '#34d399', hover: '#10b981' },
|
||||
{ id: 'purple', name: 'Deep Purple', description: 'Luxurious purple for creative sessions', accent: '#a78bfa', hover: '#8b5cf6' },
|
||||
{ id: 'rose', name: 'Crimson', description: 'Bold red-pink for energetic work', accent: '#fb7185', hover: '#f43f5e' },
|
||||
{ id: 'amber', name: 'Golden Hour', description: 'Warm amber for reduced eye strain', accent: '#fbbf24', hover: '#f59e0b' },
|
||||
{ id: 'teal', name: 'Electric Cyan', description: 'Vibrant cyan for modern aesthetics', accent: '#22d3ee', hover: '#06b6d4' }
|
||||
]
|
||||
};
|
||||
|
||||
const applyColorScheme = (schemeId) => {
|
||||
const schemes = isDarkMode.value ? colorSchemes.dark : colorSchemes.light;
|
||||
const scheme = schemes.find(s => s.id === schemeId);
|
||||
if (scheme) {
|
||||
// Remove all theme classes
|
||||
const allThemeClasses = [
|
||||
...colorSchemes.light.map(s => `theme-light-${s.id}`),
|
||||
...colorSchemes.dark.map(s => `theme-dark-${s.id}`)
|
||||
].filter(c => !c.includes('blue')); // blue is the default, no class needed
|
||||
|
||||
document.documentElement.classList.remove(...allThemeClasses);
|
||||
|
||||
// Apply new theme class if not blue (default)
|
||||
if (schemeId !== 'blue') {
|
||||
const themeClass = `theme-${isDarkMode.value ? 'dark' : 'light'}-${schemeId}`;
|
||||
document.documentElement.classList.add(themeClass);
|
||||
}
|
||||
|
||||
// Don't set CSS variables - let the theme classes handle all colors
|
||||
localStorage.setItem('colorScheme', schemeId);
|
||||
currentColorScheme.value = schemeId;
|
||||
}
|
||||
};
|
||||
|
||||
const initializeColorScheme = (darkMode) => {
|
||||
isDarkMode.value = darkMode;
|
||||
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
|
||||
currentColorScheme.value = savedScheme;
|
||||
applyColorScheme(savedScheme);
|
||||
};
|
||||
|
||||
// Watch for dark mode changes and reapply color scheme
|
||||
Vue.watch(() => isDarkMode.value, (newValue) => {
|
||||
applyColorScheme(currentColorScheme.value);
|
||||
});
|
||||
|
||||
const openColorSchemeModal = () => {
|
||||
showColorSchemeModal.value = true;
|
||||
};
|
||||
|
||||
const closeColorSchemeModal = () => {
|
||||
showColorSchemeModal.value = false;
|
||||
};
|
||||
|
||||
const selectColorScheme = (schemeId) => {
|
||||
applyColorScheme(schemeId);
|
||||
const scheme = colorSchemes[isDarkMode.value ? 'dark' : 'light'].find(s => s.id === schemeId);
|
||||
if (window.showToast && scheme) {
|
||||
window.showToast(`Applied ${scheme.name} theme`, 'fa-palette');
|
||||
}
|
||||
};
|
||||
|
||||
const resetColorScheme = () => {
|
||||
applyColorScheme('blue');
|
||||
if (window.showToast) {
|
||||
const defaultScheme = colorSchemes[isDarkMode.value ? 'dark' : 'light'].find(s => s.id === 'blue');
|
||||
window.showToast(`Reset to default ${defaultScheme?.name || 'Ocean Blue'} theme`, 'fa-undo');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
showColorSchemeModal,
|
||||
currentColorScheme,
|
||||
colorSchemes,
|
||||
openColorSchemeModal,
|
||||
closeColorSchemeModal,
|
||||
selectColorScheme,
|
||||
resetColorScheme,
|
||||
applyColorScheme,
|
||||
initializeColorScheme
|
||||
};
|
||||
}
|
||||
|
||||
// Shared Transcripts Modal Composition
|
||||
function useSharesModal() {
|
||||
const showSharesListModal = Vue.ref(false);
|
||||
const userShares = Vue.ref([]);
|
||||
const isLoadingShares = Vue.ref(false);
|
||||
|
||||
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) {
|
||||
if (window.setGlobalError) {
|
||||
window.setGlobalError(`Failed to load shared items: ${error.message}`);
|
||||
} else {
|
||||
console.error('Failed to load shared items:', error);
|
||||
}
|
||||
} finally {
|
||||
isLoadingShares.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeSharesList = () => {
|
||||
showSharesListModal.value = false;
|
||||
};
|
||||
|
||||
const copyShareLink = async (shareId) => {
|
||||
const url = `${window.location.origin}/share/${shareId}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
if (window.showToast) {
|
||||
window.showToast('Share link copied to clipboard', 'fa-link');
|
||||
}
|
||||
} catch (err) {
|
||||
if (window.setGlobalError) {
|
||||
window.setGlobalError('Failed to copy link to clipboard');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deleteShare = async (shareId) => {
|
||||
if (!confirm('Are you sure you want to delete this share?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/shares/${shareId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to delete share');
|
||||
}
|
||||
|
||||
userShares.value = userShares.value.filter(share => share.id !== shareId);
|
||||
if (window.showToast) {
|
||||
window.showToast('Share deleted successfully', 'fa-trash');
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.setGlobalError) {
|
||||
window.setGlobalError(`Failed to delete share: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
showSharesListModal,
|
||||
userShares,
|
||||
isLoadingShares,
|
||||
openSharesList,
|
||||
closeSharesList,
|
||||
copyShareLink,
|
||||
deleteShare
|
||||
};
|
||||
}
|
||||
|
||||
// User Menu Composition
|
||||
function useUserMenu() {
|
||||
const isUserMenuOpen = Vue.ref(false);
|
||||
|
||||
const toggleUserMenu = () => {
|
||||
isUserMenuOpen.value = !isUserMenuOpen.value;
|
||||
};
|
||||
|
||||
const closeUserMenu = () => {
|
||||
isUserMenuOpen.value = false;
|
||||
};
|
||||
|
||||
// Close menu when clicking outside
|
||||
Vue.onMounted(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
const userMenuButton = e.target.closest('button[class*="flex items-center gap"]');
|
||||
const userMenuDropdown = e.target.closest('div[class*="absolute right-0"]');
|
||||
const isUserMenuButtonClick = userMenuButton && userMenuButton.querySelector('i.fa-user-circle');
|
||||
|
||||
if (!isUserMenuButtonClick && !userMenuDropdown) {
|
||||
isUserMenuOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
|
||||
Vue.onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
isUserMenuOpen,
|
||||
toggleUserMenu,
|
||||
closeUserMenu
|
||||
};
|
||||
}
|
||||
|
||||
// Export for use in Vue components
|
||||
window.SharedComponents = {
|
||||
useDarkMode,
|
||||
useColorScheme,
|
||||
useSharesModal,
|
||||
useUserMenu
|
||||
};
|
||||
119
static/js/utils/apiClient.js
Normal file
119
static/js/utils/apiClient.js
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* API client utilities for making HTTP requests
|
||||
*/
|
||||
|
||||
class APIError extends Error {
|
||||
constructor(message, status, data) {
|
||||
super(message);
|
||||
this.name = 'APIError';
|
||||
this.status = status;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse JSON response, handling HTML error pages gracefully
|
||||
*/
|
||||
async function safeJsonParse(response) {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
// If response is not JSON, extract useful error from HTML
|
||||
if (!contentType.includes('application/json')) {
|
||||
const text = await response.text();
|
||||
// Try to extract error message from HTML title or h1
|
||||
const titleMatch = text.match(/<title>([^<]+)<\/title>/i);
|
||||
const h1Match = text.match(/<h1>([^<]+)<\/h1>/i);
|
||||
const errorMsg = titleMatch?.[1] || h1Match?.[1] ||
|
||||
`Server returned non-JSON response (status ${response.status})`;
|
||||
throw new APIError(errorMsg, response.status, { htmlResponse: true });
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function apiRequest(url, options = {}) {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
const defaultOptions = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(csrfToken && { 'X-CSRFToken': csrfToken })
|
||||
}
|
||||
};
|
||||
|
||||
const mergedOptions = {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
headers: {
|
||||
...defaultOptions.headers,
|
||||
...options.headers
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, mergedOptions);
|
||||
const data = await safeJsonParse(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
data.error || 'Request failed',
|
||||
response.status,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error instanceof APIError) {
|
||||
throw error;
|
||||
}
|
||||
throw new APIError(error.message, 0, null);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadFile(url, file, onProgress = null) {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
const formData = new FormData();
|
||||
formData.append('audio_file', file);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
if (onProgress) {
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const percentComplete = (e.loaded / e.total) * 100;
|
||||
onProgress(percentComplete);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
resolve(data);
|
||||
} catch (e) {
|
||||
reject(new Error('Invalid response format'));
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const error = JSON.parse(xhr.responseText);
|
||||
reject(new APIError(error.error || 'Upload failed', xhr.status, error));
|
||||
} catch (e) {
|
||||
reject(new APIError('Upload failed', xhr.status, null));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
reject(new Error('Network error'));
|
||||
});
|
||||
|
||||
xhr.open('POST', url);
|
||||
if (csrfToken) {
|
||||
xhr.setRequestHeader('X-CSRFToken', csrfToken);
|
||||
}
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
61
static/js/utils/audioUtils.js
Normal file
61
static/js/utils/audioUtils.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Audio processing and visualization utilities
|
||||
*/
|
||||
|
||||
export function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export function createAudioVisualizer(canvas, stream, color = '#3b82f6') {
|
||||
if (!canvas || !stream) return null;
|
||||
|
||||
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const analyser = audioContext.createAnalyser();
|
||||
const microphone = audioContext.createMediaStreamSource(stream);
|
||||
|
||||
analyser.fftSize = 256;
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
|
||||
microphone.connect(analyser);
|
||||
|
||||
const canvasCtx = canvas.getContext('2d');
|
||||
const WIDTH = canvas.width;
|
||||
const HEIGHT = canvas.height;
|
||||
|
||||
function draw() {
|
||||
requestAnimationFrame(draw);
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
|
||||
canvasCtx.fillStyle = 'rgb(17, 24, 39)';
|
||||
canvasCtx.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
|
||||
const barWidth = (WIDTH / bufferLength) * 2.5;
|
||||
let barHeight;
|
||||
let x = 0;
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
barHeight = (dataArray[i] / 255) * HEIGHT;
|
||||
canvasCtx.fillStyle = color;
|
||||
canvasCtx.fillRect(x, HEIGHT - barHeight, barWidth, barHeight);
|
||||
x += barWidth + 1;
|
||||
}
|
||||
}
|
||||
|
||||
draw();
|
||||
|
||||
return { audioContext, analyser, stop: () => audioContext.close() };
|
||||
}
|
||||
|
||||
export function detectBrowser() {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
if (userAgent.indexOf('firefox') > -1) return 'firefox';
|
||||
if (userAgent.indexOf('chrome') > -1 && userAgent.indexOf('edge') === -1) return 'chrome';
|
||||
if (userAgent.indexOf('safari') > -1 && userAgent.indexOf('chrome') === -1) return 'safari';
|
||||
if (userAgent.indexOf('edge') > -1) return 'edge';
|
||||
return 'unknown';
|
||||
}
|
||||
83
static/js/utils/dateUtils.js
Normal file
83
static/js/utils/dateUtils.js
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Date utility functions for formatting and parsing dates
|
||||
*/
|
||||
|
||||
export function formatDateTime(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return 'Today at ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
} else if (diffDays === 1) {
|
||||
return 'Yesterday at ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
} else if (diffDays < 7) {
|
||||
const dayName = date.toLocaleDateString('en-US', { weekday: 'long' });
|
||||
return dayName + ' at ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
} else {
|
||||
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTimeAgo(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||
if (diffDays < 30) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
||||
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
if (diffMonths < 12) return `${diffMonths} month${diffMonths > 1 ? 's' : ''} ago`;
|
||||
|
||||
const diffYears = Math.floor(diffDays / 365);
|
||||
return `${diffYears} year${diffYears > 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
export function formatDuration(seconds) {
|
||||
if (!seconds || seconds < 0) return '0:00';
|
||||
const hrs = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hrs > 0) {
|
||||
return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function parseDateRange(preset) {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
switch (preset) {
|
||||
case 'today':
|
||||
return { start: today, end: new Date() };
|
||||
case 'yesterday':
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return { start: yesterday, end: today };
|
||||
case 'week':
|
||||
const weekStart = new Date(today);
|
||||
weekStart.setDate(weekStart.getDate() - 7);
|
||||
return { start: weekStart, end: new Date() };
|
||||
case 'month':
|
||||
const monthStart = new Date(today);
|
||||
monthStart.setMonth(monthStart.getMonth() - 1);
|
||||
return { start: monthStart, end: new Date() };
|
||||
case 'year':
|
||||
const yearStart = new Date(today);
|
||||
yearStart.setFullYear(yearStart.getFullYear() - 1);
|
||||
return { start: yearStart, end: new Date() };
|
||||
default:
|
||||
return { start: null, end: null };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user