Initial release: DictIA v0.8.14-alpha (fork de Speakr, AGPL-3.0)

This commit is contained in:
InnovA AI
2026-03-16 21:47:37 +00:00
commit 42772a31ed
365 changed files with 103572 additions and 0 deletions

View 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';

View 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
};
}

View 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
};
}

View 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
};
}

View 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
};
}

View 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
};
}

View 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
};
}

View 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
};
}

View 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
};
}

View 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
};
}

View 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
};
}