Initial release: DictIA v0.8.14-alpha (fork de Speakr, AGPL-3.0)
This commit is contained in:
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user