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

83
static/css/loading.css Normal file
View File

@@ -0,0 +1,83 @@
/* Critical loading styles - inline these in the HTML head for instant loading */
.app-loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--bg-primary, #1a1b26);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.3s ease-out;
}
.app-loading-overlay.fade-out {
opacity: 0;
pointer-events: none;
}
.app-loading-content {
text-align: center;
}
.app-loading-spinner {
width: 50px;
height: 50px;
margin: 0 auto 20px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: var(--text-accent, #7aa2f7);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.app-loading-text {
color: var(--text-muted, #a0a0b0);
font-size: 14px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
letter-spacing: 0.5px;
}
.app-loading-logo {
width: 60px;
height: 60px;
margin: 0 auto 20px;
opacity: 0.8;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Hide body content until ready */
body.app-loading {
overflow: hidden;
}
body.app-loading > *:not(.app-loading-overlay) {
opacity: 0;
}
/* Dark mode default colors */
@media (prefers-color-scheme: dark) {
.app-loading-overlay {
background: #1a1b26;
}
}
/* Light mode if explicitly set */
body.light .app-loading-overlay {
background: #ffffff;
}
body.light .app-loading-spinner {
border-color: rgba(0, 0, 0, 0.1);
border-top-color: #3b82f6;
}
body.light .app-loading-text {
color: #6b7280;
}

4266
static/css/styles.css Normal file

File diff suppressed because it is too large Load Diff

BIN
static/img/dark-mode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

BIN
static/img/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
static/img/icon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
static/img/icon-180x180.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
static/img/icon-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
static/img/icon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
static/img/icon-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

BIN
static/img/light-mode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

BIN
static/img/logo-dictia.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
static/img/main.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

BIN
static/img/main2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

BIN
static/img/rec1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
static/img/rec2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
static/img/rec3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

BIN
static/img/tags.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

2603
static/js/app.modular.js Normal file

File diff suppressed because it is too large Load Diff

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

View File

@@ -0,0 +1,78 @@
/**
* Push Notification Configuration
*
* AUTO-CONFIGURATION:
* ------------------
* Push notifications are now auto-configured!
*
* On first server startup:
* 1. VAPID keys are automatically generated (requires pywebpush)
* 2. Keys are saved to /config/vapid_keys.json (persists across restarts)
* 3. Public key is served via /api/push/config
* 4. Client fetches config dynamically
*
* No manual configuration needed - just make sure:
* - pywebpush is installed: pip install pywebpush
* - /config directory is mounted as Docker volume (for persistence)
*/
// Cached config fetched from server
let cachedConfig = null;
/**
* Fetch push notification config from server
*/
export async function getPushConfig() {
if (cachedConfig) {
return cachedConfig;
}
try {
const response = await fetch('/api/push/config');
if (!response.ok) {
console.warn('[Push Config] Failed to fetch config:', response.status);
return { enabled: false, public_key: null };
}
cachedConfig = await response.json();
console.log('[Push Config] Loaded from server:', cachedConfig.enabled ? 'enabled' : 'disabled');
return cachedConfig;
} catch (error) {
console.error('[Push Config] Error fetching config:', error);
return { enabled: false, public_key: null };
}
}
/**
* Check if push notifications are enabled
*/
export async function isPushEnabled() {
const config = await getPushConfig();
return config.enabled && !!config.public_key;
}
/**
* Get VAPID public key from server
*/
export async function getPublicKey() {
const config = await getPushConfig();
return config.public_key;
}
/**
* Convert a base64 string to Uint8Array (required for push subscription)
*/
export function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}

206
static/js/csrf-refresh.js Normal file
View File

@@ -0,0 +1,206 @@
// CSRF Token Management with Auto-Refresh
class CSRFManager {
constructor() {
this.token = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
this.refreshPromise = null;
this.setupFetchInterceptor();
}
async refreshToken() {
// Prevent multiple simultaneous refresh requests
if (this.refreshPromise) {
return this.refreshPromise;
}
this.refreshPromise = this.performTokenRefresh();
try {
const newToken = await this.refreshPromise;
return newToken;
} finally {
this.refreshPromise = null;
}
}
async performTokenRefresh() {
try {
console.log('Refreshing CSRF token...');
// Use the original fetch to avoid recursion
const originalFetch = window.originalFetch || fetch;
const response = await originalFetch('/api/csrf-token', {
method: 'GET',
credentials: 'same-origin',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Failed to refresh CSRF token: ${response.status} ${response.statusText}`);
}
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
const text = await response.text();
throw new Error(`Expected JSON response but got ${contentType}. Response: ${text.substring(0, 200)}`);
}
const data = await response.json();
if (data.csrf_token) {
this.token = data.csrf_token;
// Update the meta tag for any other code that might read it
const metaTag = document.querySelector('meta[name="csrf-token"]');
if (metaTag) {
metaTag.setAttribute('content', this.token);
}
// Update Vue.js reactive token if available
if (window.app && window.app.csrfToken !== undefined) {
window.app.csrfToken = this.token;
}
console.log('CSRF token refreshed successfully');
return this.token;
} else {
throw new Error('No CSRF token in response');
}
} catch (error) {
console.error('Failed to refresh CSRF token:', error);
throw error;
}
}
setupFetchInterceptor() {
// Store original fetch if not already stored
if (!window.originalFetch) {
window.originalFetch = window.fetch;
}
const originalFetch = window.originalFetch;
const self = this;
window.fetch = async function(url, options = {}) {
// Skip CSRF token for the token refresh endpoint to avoid recursion
if (url.includes('/api/csrf-token')) {
return originalFetch(url, options);
}
// Add CSRF token to headers for API requests
const newOptions = { ...options };
if (url.startsWith('/api/') || url.startsWith('/upload') || url.startsWith('/save') ||
url.startsWith('/recording/') || url.startsWith('/chat') || url.startsWith('/speakers')) {
newOptions.headers = {
'X-CSRFToken': self.token,
...newOptions.headers
};
}
// Make the request
let response = await originalFetch(url, newOptions);
// Check for CSRF token expiration
if ((response.status === 400 || response.status === 403) &&
(url.startsWith('/api/') || url.startsWith('/upload') || url.startsWith('/save') ||
url.startsWith('/recording/') || url.startsWith('/chat') || url.startsWith('/speakers'))) {
try {
// Try to parse as JSON first
const responseClone = response.clone();
let isCSRFError = false;
try {
const errorData = await responseClone.json();
const errorMessage = errorData.error || '';
isCSRFError = errorMessage.toLowerCase().includes('csrf') ||
errorMessage.toLowerCase().includes('token');
} catch (jsonError) {
// If JSON parsing fails, check if it's an HTML error page
const textResponse = await response.clone().text();
isCSRFError = textResponse.toLowerCase().includes('csrf') ||
textResponse.toLowerCase().includes('token') ||
textResponse.includes('<!doctype') || // HTML error page
textResponse.includes('<html');
}
if (isCSRFError) {
console.log('CSRF token expired, attempting refresh and retry...');
try {
// Refresh the token
await self.refreshToken();
// Retry the original request with the new token
newOptions.headers['X-CSRFToken'] = self.token;
response = await originalFetch(url, newOptions);
if (response.ok) {
console.log('Request succeeded after CSRF token refresh');
} else {
console.warn('Request still failed after CSRF token refresh:', response.status);
}
} catch (refreshError) {
console.error('Failed to refresh CSRF token during retry:', refreshError);
// Return the original failed response
}
}
} catch (parseError) {
console.warn('Could not parse error response for CSRF check:', parseError);
}
}
return response;
};
}
// Method to manually get current token
getToken() {
return this.token;
}
// Method to manually refresh token (for periodic refresh)
async manualRefresh() {
try {
await this.refreshToken();
return true;
} catch (error) {
console.error('Manual CSRF token refresh failed:', error);
return false;
}
}
}
// Initialize CSRF manager when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
window.csrfManager = new CSRFManager();
// Set up periodic token refresh every 45 minutes (before 1-hour expiration)
setInterval(() => {
if (window.csrfManager) {
console.log('Performing periodic CSRF token refresh...');
window.csrfManager.manualRefresh();
}
}, 45 * 60 * 1000); // 45 minutes
// Refresh CSRF token when page becomes visible again (wake from sleep, tab switch)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && window.csrfManager) {
console.log('[CSRF] Page visible again, refreshing token...');
window.csrfManager.manualRefresh();
}
});
// Heartbeat gap detection: if setInterval drifts > 2 min, system was asleep
let lastHeartbeat = Date.now();
setInterval(() => {
const now = Date.now();
const drift = now - lastHeartbeat - 60000;
if (drift > 120000) {
console.log(`[CSRF] Heartbeat drift ${Math.round(drift / 1000)}s detected, refreshing token...`);
if (window.csrfManager) {
window.csrfManager.manualRefresh();
}
}
lastHeartbeat = now;
}, 60000);
});

297
static/js/i18n.js Normal file
View File

@@ -0,0 +1,297 @@
/**
* Lightweight i18n (internationalization) system for Speakr
* Handles loading and managing translations with template variable support
*/
class I18n {
constructor() {
this.translations = {};
this.currentLocale = 'en';
this.fallbackLocale = 'en';
this.loadedLocales = new Set();
}
/**
* Initialize i18n with default locale
* @param {string} locale - Initial locale code (e.g., 'en', 'es', 'fr', 'zh')
*/
async init(locale = 'en') {
// Get saved locale from localStorage or use browser language
const savedLocale = localStorage.getItem('preferredLanguage');
const browserLocale = navigator.language.split('-')[0];
this.currentLocale = savedLocale || locale || browserLocale || 'en';
// Load the initial locale
await this.loadLocale(this.currentLocale);
// Load fallback locale if different
if (this.currentLocale !== this.fallbackLocale) {
await this.loadLocale(this.fallbackLocale);
}
}
/**
* Load translations for a specific locale
* @param {string} locale - Locale code to load
*/
async loadLocale(locale) {
if (this.loadedLocales.has(locale)) {
return; // Already loaded
}
try {
const response = await fetch(`/static/locales/${locale}.json`);
if (!response.ok) {
throw new Error(`Failed to load locale: ${locale}`);
}
const translations = await response.json();
this.translations[locale] = translations;
this.loadedLocales.add(locale);
console.log(`Loaded locale: ${locale}`);
} catch (error) {
console.error(`Error loading locale ${locale}:`, error);
// If failed to load requested locale and it's not the fallback, try fallback
if (locale !== this.fallbackLocale) {
console.log(`Failed to load ${locale}, will use ${this.fallbackLocale} as fallback`);
// Don't change currentLocale - keep user's preference
// Just ensure fallback translations are available
await this.loadLocale(this.fallbackLocale);
}
}
}
/**
* Change the current locale
* @param {string} locale - New locale code
*/
async setLocale(locale) {
await this.loadLocale(locale);
this.currentLocale = locale;
localStorage.setItem('preferredLanguage', locale);
// Dispatch custom event for locale change
window.dispatchEvent(new CustomEvent('localeChanged', { detail: { locale } }));
}
/**
* Get the current locale
* @returns {string} Current locale code
*/
getLocale() {
return this.currentLocale;
}
/**
* Get available locales
* @returns {Array} List of available locale codes
*/
getAvailableLocales() {
return [
{ code: 'en', name: 'English', nativeName: 'English' },
{ code: 'es', name: 'Spanish', nativeName: 'Español' },
{ code: 'fr', name: 'French', nativeName: 'Français' },
{ code: 'zh', name: 'Chinese', nativeName: '中文' },
{ code: 'ru', name: 'Russian', nativeName: 'Русский' },
];
}
/**
* Translate a key with optional parameters
* @param {string} key - Translation key (e.g., 'common.save' or 'nav.upload')
* @param {Object} params - Optional parameters for template replacement
* @param {string} locale - Optional specific locale (defaults to current)
* @returns {string} Translated text
*/
t(key, params = {}, locale = null) {
const targetLocale = locale || this.currentLocale;
// Get translation from current locale or fallback
let translation = this.getNestedTranslation(targetLocale, key);
if (!translation && targetLocale !== this.fallbackLocale) {
translation = this.getNestedTranslation(this.fallbackLocale, key);
}
if (!translation) {
console.warn(`Translation not found for key: ${key}`);
return key; // Return the key itself as fallback
}
// Replace template variables
return this.interpolate(translation, params);
}
/**
* Get nested translation value from object
* @param {string} locale - Locale to search in
* @param {string} key - Dot-separated key path
* @returns {string|null} Translation value or null
*/
getNestedTranslation(locale, key) {
if (!this.translations[locale]) {
return null;
}
const keys = key.split('.');
let value = this.translations[locale];
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k];
} else {
return null;
}
}
return typeof value === 'string' ? value : null;
}
/**
* Replace template variables in translation string
* @param {string} text - Text with placeholders like {{variable}}
* @param {Object} params - Parameters to replace
* @returns {string} Interpolated text
*/
interpolate(text, params) {
return text.replace(/\{\{(\w+)\}\}/g, (match, key) => {
return params.hasOwnProperty(key) ? params[key] : match;
});
}
/**
* Handle pluralization
* @param {string} key - Base translation key
* @param {number} count - Count for pluralization
* @param {Object} params - Additional parameters
* @returns {string} Translated text with proper pluralization
*/
tc(key, count, params = {}) {
const pluralKey = count === 1 ? key : `${key}Plural`;
return this.t(pluralKey, { ...params, count });
}
/**
* Format date according to locale
* @param {Date|string} date - Date to format
* @param {Object} options - Intl.DateTimeFormat options
* @returns {string} Formatted date string
*/
formatDate(date, options = {}) {
const d = date instanceof Date ? date : new Date(date);
return new Intl.DateTimeFormat(this.currentLocale, options).format(d);
}
/**
* Format number according to locale
* @param {number} number - Number to format
* @param {Object} options - Intl.NumberFormat options
* @returns {string} Formatted number string
*/
formatNumber(number, options = {}) {
return new Intl.NumberFormat(this.currentLocale, options).format(number);
}
/**
* Format file size with appropriate unit
* @param {number} bytes - Size in bytes
* @returns {string} Formatted file size
*/
formatFileSize(bytes) {
const units = ['bytes', 'kilobytes', 'megabytes', 'gigabytes'];
const unitValues = [1, 1024, 1048576, 1073741824];
let unitIndex = 0;
for (let i = unitValues.length - 1; i >= 0; i--) {
if (bytes >= unitValues[i]) {
unitIndex = i;
break;
}
}
const value = Math.round(bytes / unitValues[unitIndex] * 10) / 10;
return this.t(`fileSize.${units[unitIndex]}`, { count: value });
}
/**
* Format duration with appropriate unit
* @param {number} seconds - Duration in seconds
* @returns {string} Formatted duration
*/
formatDuration(seconds) {
if (seconds < 60) {
return this.tc('duration.seconds', Math.round(seconds), { count: Math.round(seconds) });
} else if (seconds < 3600) {
const minutes = Math.round(seconds / 60);
return this.tc('duration.minutes', minutes, { count: minutes });
} else {
const hours = Math.round(seconds / 3600 * 10) / 10;
return this.tc('duration.hours', hours, { count: hours });
}
}
/**
* Format relative time (e.g., "2 hours ago")
* @param {Date|string} date - Date to format
* @returns {string} Formatted relative time
*/
formatRelativeTime(date) {
const d = date instanceof Date ? date : new Date(date);
const now = new Date();
const diffSeconds = Math.floor((now - d) / 1000);
if (diffSeconds < 60) {
return this.t('time.justNow');
} else if (diffSeconds < 3600) {
const minutes = Math.floor(diffSeconds / 60);
return minutes === 1
? this.t('time.minuteAgo')
: this.t('time.minutesAgo', { count: minutes });
} else if (diffSeconds < 86400) {
const hours = Math.floor(diffSeconds / 3600);
return hours === 1
? this.t('time.hourAgo')
: this.t('time.hoursAgo', { count: hours });
} else if (diffSeconds < 604800) {
const days = Math.floor(diffSeconds / 86400);
return days === 1
? this.t('time.dayAgo')
: this.t('time.daysAgo', { count: days });
} else if (diffSeconds < 2592000) {
const weeks = Math.floor(diffSeconds / 604800);
return weeks === 1
? this.t('time.weekAgo')
: this.t('time.weeksAgo', { count: weeks });
} else if (diffSeconds < 31536000) {
const months = Math.floor(diffSeconds / 2592000);
return months === 1
? this.t('time.monthAgo')
: this.t('time.monthsAgo', { count: months });
} else {
const years = Math.floor(diffSeconds / 31536000);
return years === 1
? this.t('time.yearAgo')
: this.t('time.yearsAgo', { count: years });
}
}
}
// Create global i18n instance
const i18n = new I18n();
// Create a fallback t function immediately
if (typeof window !== 'undefined') {
// Ensure window.i18n exists with at least a basic t function
window.i18n = i18n;
// Add a fallback t function if the class method isn't ready
if (!window.i18n.t) {
window.i18n.t = function(key, params) {
console.warn('i18n.t called before initialization, returning key:', key);
return key;
};
}
}

201
static/js/loading.js Normal file
View File

@@ -0,0 +1,201 @@
/**
* App loading overlay management
* Prevents FOUC (Flash of Unstyled Content) during page initialization
*/
window.AppLoader = {
initialized: false,
readyChecks: [],
initTime: Date.now(),
/**
* Initialize the loading system
*/
init() {
if (this.initialized) return;
this.initialized = true;
this.initTime = Date.now();
// Add loading class to body
document.body.classList.add('app-loading');
// Create loading overlay if it doesn't exist
if (!document.querySelector('.app-loading-overlay')) {
this.createOverlay();
}
// Set up ready checks
this.setupReadyChecks();
},
/**
* Create the loading overlay element
*/
createOverlay() {
const overlay = document.createElement('div');
overlay.className = 'app-loading-overlay';
overlay.innerHTML = `
<div class="app-loading-content">
<div class="app-loading-spinner"></div>
<div class="app-loading-text">Loading...</div>
</div>
`;
document.body.appendChild(overlay);
},
/**
* Add a ready check condition
*/
addReadyCheck(checkFn) {
this.readyChecks.push(checkFn);
},
/**
* Setup default ready checks
*/
setupReadyChecks() {
// Check if DOM is ready
this.addReadyCheck(() => {
return document.readyState === 'complete' || document.readyState === 'interactive';
});
// Check if styles are loaded (optional - don't block on this)
this.addReadyCheck(() => {
try {
const styles = document.querySelector('link[href*="styles.css"]');
// If stylesheet isn't found or loaded, continue anyway after 2 seconds
return !styles || styles.sheet || (Date.now() - this.initTime) > 2000;
} catch (e) {
console.warn('Error checking stylesheet:', e);
return true; // Don't block on stylesheet errors
}
});
// Check if theme is initialized (optional - don't block on this)
this.addReadyCheck(() => {
try {
const computed = window.getComputedStyle(document.documentElement);
const bgPrimary = computed.getPropertyValue('--bg-primary').trim();
// Accept if property exists or if 2 seconds have passed
return bgPrimary !== '' || (Date.now() - this.initTime) > 2000;
} catch (e) {
console.warn('Error checking CSS variables:', e);
return true; // Don't block on CSS variable errors
}
});
},
/**
* Check if all conditions are met
*/
isReady() {
if (this.readyChecks.length === 0) return true;
return this.readyChecks.every(check => {
try {
return check();
} catch (e) {
return false;
}
});
},
/**
* Hide the loading overlay
*/
hide() {
try {
// Remove app-loading class immediately to show content
document.body.classList.remove('app-loading');
// Find all loading overlays (might be multiple)
const overlays = document.querySelectorAll('.app-loading-overlay');
if (overlays.length > 0) {
overlays.forEach(overlay => {
// Force display none immediately in Firefox
overlay.style.display = 'none';
// Then do graceful removal
overlay.classList.add('fade-out');
setTimeout(() => {
try {
overlay.remove();
} catch (e) {
console.warn('Could not remove overlay:', e);
}
}, 100);
});
}
console.log('Loader hidden successfully');
} catch (error) {
console.error('Error hiding loader:', error);
// Force hide everything as last resort
document.body.classList.remove('app-loading');
const overlays = document.querySelectorAll('.app-loading-overlay');
overlays.forEach(o => {
o.style.display = 'none';
try { o.remove(); } catch (e) {}
});
}
},
/**
* Wait for app to be ready then hide overlay
*/
async waitForReady(timeout = 5000) {
const startTime = Date.now();
let hideExecuted = false;
const safeHide = () => {
if (!hideExecuted) {
hideExecuted = true;
try {
this.hide();
} catch (error) {
console.error('Error hiding loader:', error);
// Force hide even if error occurs
const overlay = document.querySelector('.app-loading-overlay');
if (overlay) overlay.remove();
document.body.classList.remove('app-loading');
}
}
};
const checkReady = () => {
try {
const elapsed = Date.now() - startTime;
if (this.isReady()) {
console.log('App ready, hiding loader');
safeHide();
} else if (elapsed > timeout) {
console.warn('Loader timeout reached, forcing hide');
safeHide();
} else {
requestAnimationFrame(checkReady);
}
} catch (error) {
console.error('Error in checkReady:', error);
safeHide();
}
};
// Hard timeout as absolute failsafe - hide after 10 seconds no matter what
setTimeout(() => {
if (!hideExecuted) {
console.warn('Hard timeout reached (10s), forcing loader hide');
safeHide();
}
}, 10000);
// Start checking after a minimum display time
setTimeout(checkReady, 300);
}
};
// Auto-initialize on script load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => AppLoader.init());
} else {
AppLoader.init();
}

View File

@@ -0,0 +1,895 @@
/**
* Audio recording composable
* Handles microphone/system audio recording with visualizers and wake lock
*/
import * as RecordingDB from '../db/recording-persistence.js';
import * as IncognitoStorage from '../db/incognito-storage.js';
export function useAudio(state, utils) {
const {
isRecording, mediaRecorder, audioContext, analyser, micAnalyser, systemAnalyser,
audioChunks, recordingTime, recordingInterval, recordingMode, audioBlobURL,
estimatedFileSize, actualBitrate, recordingNotes, recordingQuality,
maxRecordingMB, fileSizeWarningShown, sizeCheckInterval, recordingDisclaimer,
showRecordingDisclaimerModal, pendingRecordingMode, currentView, isDarkMode, wakeLock, animationFrameId,
activeStreams, visualizer, micVisualizer, systemVisualizer, canRecordAudio,
canRecordSystemAudio, systemAudioSupported, systemAudioError, globalError,
selectedTagIds, selectedFolderId, asrLanguage, asrMinSpeakers, asrMaxSpeakers, uploadQueue,
progressPopupMinimized, progressPopupClosed,
// Incognito mode
enableIncognitoMode, incognitoMode, incognitoRecording, incognitoProcessing,
processingMessage, processingProgress, selectedRecording
} = state;
const { showToast, setGlobalError, formatFileSize, startUploadQueue } = utils;
// Local state for pending streams and chunk tracking
let pendingDisplayStream = null;
let currentChunkIndex = 0;
// iOS detection
const isiOS = () => {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
};
// Silent audio for iOS wake lock alternative
let silentAudio = null;
// Create silent audio using data URL (1 second of silence)
const createSilentAudio = () => {
if (!silentAudio) {
// Base64 encoded 1-second silent MP3
const silentMp3 = 'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA//tQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWGluZwAAAA8AAAACAAADhAC7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7v////////////////////////////////////////////////////////////AAAAAExhdmM1OC4xMwAAAAAAAAAAAAAAACQCgAAAAAAAAAOEfxVqYQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//sQZAAP8AAAaQAAAAgAAA0gAAABAAABpAAAACAAADSAAAAETEFNRTMuMTAwVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//sQZDwP8AAAaQAAAAgAAA0gAAABAAABpAAAACAAADSAAAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVU=';
silentAudio = new Audio(silentMp3);
silentAudio.loop = true;
silentAudio.volume = 0.01; // Very low volume, almost silent
}
return silentAudio;
};
// Start iOS wake lock (play silent audio)
const startiOSWakeLock = async () => {
try {
const audio = createSilentAudio();
await audio.play();
console.log('[iOS Wake Lock] Silent audio playing to prevent sleep');
return true;
} catch (error) {
console.warn('[iOS Wake Lock] Failed to start silent audio:', error);
showToast('iOS wake lock may not work - keep screen active', 'warning');
return false;
}
};
// Stop iOS wake lock (stop silent audio)
const stopiOSWakeLock = () => {
if (silentAudio) {
silentAudio.pause();
silentAudio.currentTime = 0;
console.log('[iOS Wake Lock] Silent audio stopped');
}
};
// Acquire wake lock to prevent screen from sleeping during recording
const acquireWakeLock = async () => {
// iOS doesn't support Wake Lock API - use silent audio instead
if (isiOS()) {
return await startiOSWakeLock();
}
// Android/Desktop: use native Wake Lock API
try {
if ('wakeLock' in navigator) {
wakeLock.value = await navigator.wakeLock.request('screen');
console.log('[WakeLock] Acquired - screen will stay awake during recording');
// Listen for wake lock release
wakeLock.value.addEventListener('release', () => {
console.log('[WakeLock] Released');
});
return true;
} else {
console.warn('[WakeLock] Wake Lock API not supported');
showToast('Screen may sleep during recording', 'info');
return false;
}
} catch (err) {
console.warn('[WakeLock] Could not acquire:', err.message);
if (err.name === 'NotAllowedError') {
showToast('Screen lock permission denied', 'warning');
} else if (err.name === 'NotSupportedError') {
showToast('Wake lock not supported on this device', 'info');
}
return false;
}
};
// Release wake lock
const releaseWakeLock = async () => {
// iOS: stop silent audio
if (isiOS()) {
stopiOSWakeLock();
return;
}
// Android/Desktop: release native wake lock
if (wakeLock.value) {
try {
await wakeLock.value.release();
wakeLock.value = null;
console.log('[WakeLock] Released');
} catch (err) {
console.warn('[WakeLock] Could not release:', err.message);
}
}
};
// Show recording notification
const showRecordingNotification = async () => {
if ('Notification' in window && Notification.permission === 'granted') {
// Notifications handled by service worker
}
};
// Note: System audio capability detection is now handled by computed property
// canRecordSystemAudio = computed(() => navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia)
// Hide recording notification
const hideRecordingNotification = async () => {
// Notifications cleared when recording stops
};
// Handle visibility change (for wake lock re-acquisition)
const handleVisibilityChange = async () => {
if (document.visibilityState === 'visible' && isRecording.value) {
console.log('[Visibility] Page visible, re-acquiring wake lock');
const acquired = await acquireWakeLock();
if (acquired) {
showToast('Recording resumed - screen will stay awake', 'success');
}
} else if (document.visibilityState === 'hidden' && isRecording.value) {
console.log('[Visibility] Page hidden, wake lock may be released by browser');
}
};
// Start recording
// IMPORTANT: For Firefox, getDisplayMedia MUST be the first async call from user gesture
const startRecording = async (mode = 'microphone') => {
const needsDisplayMedia = mode === 'system' || mode === 'both';
// For system audio modes, get display media FIRST before any other operations
// This is required for Firefox's "transient activation" security model
if (needsDisplayMedia) {
try {
const displayStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: true
});
// Check if we got an audio track
const audioTrack = displayStream.getAudioTracks()[0];
if (!audioTrack) {
displayStream.getTracks().forEach(track => track.stop());
showToast('No audio track - check "Share audio" option', 'error');
return;
}
// Store stream for use after disclaimer (if any)
pendingDisplayStream = displayStream;
} catch (error) {
console.error('[Recording] Failed to get display media:', error);
if (error.name === 'NotAllowedError') {
showToast('Screen sharing was cancelled', 'error');
} else {
showToast(`Failed to capture: ${error.message}`, 'error');
}
return;
}
}
// Now check for disclaimer (after we've secured the display stream)
if (recordingDisclaimer.value && recordingDisclaimer.value.trim() !== '') {
showRecordingDisclaimerModal.value = true;
pendingRecordingMode.value = mode;
return;
}
await startRecordingInternal(mode);
};
// Accept recording disclaimer and start recording
const acceptRecordingDisclaimer = async () => {
showRecordingDisclaimerModal.value = false;
await startRecordingInternal(pendingRecordingMode.value || 'microphone');
};
// Cancel recording disclaimer
const cancelRecordingDisclaimer = () => {
showRecordingDisclaimerModal.value = false;
// Clean up pending display stream if user cancels
if (pendingDisplayStream) {
pendingDisplayStream.getTracks().forEach(track => track.stop());
pendingDisplayStream = null;
}
pendingRecordingMode.value = null;
};
// Internal start recording function
const startRecordingInternal = async (mode) => {
try {
recordingMode.value = mode;
audioChunks.value = [];
recordingTime.value = 0;
estimatedFileSize.value = 0;
fileSizeWarningShown.value = false;
// Initialize IndexedDB session
currentChunkIndex = 0;
let stream;
let combinedStream;
if (mode === 'microphone') {
if (!canRecordAudio.value) {
throw new Error('Microphone recording is not available. Make sure you are using HTTPS.');
}
stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 48000
}
});
activeStreams.value = [stream];
audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
const source = audioContext.value.createMediaStreamSource(stream);
analyser.value = audioContext.value.createAnalyser();
analyser.value.fftSize = 256;
source.connect(analyser.value);
} else if (mode === 'system') {
if (!canRecordSystemAudio.value) {
throw new Error('System audio recording is not available. Make sure you are using HTTPS.');
}
// Use pre-obtained display stream (required for Firefox user gesture)
// or get it now for browsers that don't require immediate call
const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
if (pendingDisplayStream) {
stream = pendingDisplayStream;
pendingDisplayStream = null;
} else {
const displayMediaConstraints = {
video: true,
audio: isFirefox ? true : {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false
}
};
stream = await navigator.mediaDevices.getDisplayMedia(displayMediaConstraints);
}
const audioTrack = stream.getAudioTracks()[0];
if (!audioTrack) {
stream.getTracks().forEach(track => track.stop());
const browserName = isFirefox ? 'Firefox' : 'your browser';
throw new Error(
`No system audio track available. In ${browserName}, please:\n` +
`1. Share a BROWSER TAB that is actively playing audio\n` +
`2. Make sure "Share tab audio" checkbox is checked\n` +
`3. The audio must be playing when you start sharing`
);
}
// Stop video track
stream.getVideoTracks().forEach(track => track.stop());
stream = new MediaStream([audioTrack]);
activeStreams.value = [stream];
audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
const source = audioContext.value.createMediaStreamSource(stream);
analyser.value = audioContext.value.createAnalyser();
analyser.value.fftSize = 256;
source.connect(analyser.value);
} else if (mode === 'both') {
if (!canRecordAudio.value || !canRecordSystemAudio.value) {
throw new Error('Recording is not available. Make sure you are using HTTPS.');
}
const micStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 48000
}
});
// Use pre-obtained display stream or get it now
const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
let displayStream;
if (pendingDisplayStream) {
displayStream = pendingDisplayStream;
pendingDisplayStream = null;
} else {
displayStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: isFirefox ? true : {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false
}
});
}
const systemAudioTrack = displayStream.getAudioTracks()[0];
if (!systemAudioTrack) {
micStream.getTracks().forEach(track => track.stop());
displayStream.getTracks().forEach(track => track.stop());
const browserName = isFirefox ? 'Firefox' : 'your browser';
throw new Error(
`No system audio track available. In ${browserName}, please:\n` +
`1. Share a BROWSER TAB that is actively playing audio\n` +
`2. Make sure "Share tab audio" checkbox is checked\n` +
`3. The audio must be playing when you start sharing`
);
}
// Stop video tracks
displayStream.getVideoTracks().forEach(track => track.stop());
// Create audio context and combine streams
audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
const destination = audioContext.value.createMediaStreamDestination();
const micSource = audioContext.value.createMediaStreamSource(micStream);
const systemSource = audioContext.value.createMediaStreamSource(new MediaStream([systemAudioTrack]));
// Create analysers for each source
micAnalyser.value = audioContext.value.createAnalyser();
micAnalyser.value.fftSize = 256;
systemAnalyser.value = audioContext.value.createAnalyser();
systemAnalyser.value.fftSize = 256;
micSource.connect(micAnalyser.value);
micSource.connect(destination);
systemSource.connect(systemAnalyser.value);
systemSource.connect(destination);
combinedStream = destination.stream;
activeStreams.value = [micStream, displayStream];
stream = combinedStream;
}
// Determine best mime type
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm';
const recorder = new MediaRecorder(stream, { mimeType });
// Start IndexedDB recording session - convert Vue reactive objects to plain objects
try {
await RecordingDB.startRecordingSession({
mode,
notes: recordingNotes.value || '',
tags: selectedTagIds.value ? [...selectedTagIds.value] : [], // Convert reactive array to plain array
asrOptions: {
language: asrLanguage.value || '',
min_speakers: asrMinSpeakers.value || '',
max_speakers: asrMaxSpeakers.value || ''
},
mimeType
});
} catch (dbError) {
console.warn('[Recording] IndexedDB persistence failed, continuing without persistence:', dbError);
}
recorder.ondataavailable = async (event) => {
if (event.data.size > 0) {
audioChunks.value.push(event.data);
// Save chunk to IndexedDB for crash recovery
try {
await RecordingDB.saveChunk(event.data, currentChunkIndex);
await RecordingDB.updateRecordingMetadata({
duration: recordingTime.value,
notes: recordingNotes.value || ''
});
currentChunkIndex++;
} catch (dbError) {
// Don't spam console - recording continues in memory regardless
}
}
};
recorder.onstop = () => {
const blob = new Blob(audioChunks.value, { type: mimeType });
audioBlobURL.value = URL.createObjectURL(blob);
stopSizeMonitoring();
};
mediaRecorder.value = recorder;
recorder.start(5000); // 5-second chunks for less overhead while still enabling crash recovery
isRecording.value = true;
// Switch to recording view immediately so pending wake-lock/notification awaits don't block Safari rendering
currentView.value = 'recording';
// Start timer
recordingInterval.value = setInterval(() => {
recordingTime.value++;
}, 1000);
// Start size monitoring
startSizeMonitoring();
// Acquire wake lock
await acquireWakeLock();
// Show notification
await showRecordingNotification();
// Start visualizers
drawVisualizers();
// Notify service worker
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
type: 'RECORDING_STATE',
isRecording: true
});
}
} catch (error) {
console.error('Recording error:', error);
setGlobalError(`Failed to start recording: ${error.message}`);
// Clean up any started streams
if (activeStreams.value.length > 0) {
activeStreams.value.forEach(stream => {
stream.getTracks().forEach(track => track.stop());
});
activeStreams.value = [];
}
}
};
// Stop recording
const stopRecording = async () => {
if (mediaRecorder.value && isRecording.value) {
mediaRecorder.value.stop();
isRecording.value = false;
// Clear the recording timer
if (recordingInterval.value) {
clearInterval(recordingInterval.value);
recordingInterval.value = null;
}
stopSizeMonitoring();
cancelAnimationFrame(animationFrameId.value);
animationFrameId.value = null;
// Stop all active media streams (mic, screen share, etc.)
if (activeStreams.value.length > 0) {
activeStreams.value.forEach(stream => {
stream.getTracks().forEach(track => track.stop());
});
activeStreams.value = [];
}
// Release wake lock
await releaseWakeLock();
// Hide recording notification
await hideRecordingNotification();
// Notify service worker
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
type: 'RECORDING_STATE',
isRecording: false,
duration: recordingTime.value
});
}
}
};
// Upload recorded audio
const uploadRecordedAudio = async () => {
if (!audioBlobURL.value) {
setGlobalError("No recorded audio to upload.");
return;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const recordedFile = new File(audioChunks.value, `recording-${timestamp}.webm`, { type: 'audio/webm' });
// Get selected tags as objects and create a DEEP copy to prevent reactivity issues
const selectedTagsTemp = selectedTagIds.value.map(tagId => {
const tag = state.availableTags.value.find(t => t.id == tagId);
return tag || null;
}).filter(Boolean);
// Deep clone to completely break reactivity chain - JSON parse/stringify removes all proxies
const selectedTags = JSON.parse(JSON.stringify(selectedTagsTemp));
// Add to upload queue
uploadQueue.value.push({
file: recordedFile,
notes: recordingNotes.value,
tags: selectedTags, // Completely non-reactive deep copy
folder_id: selectedFolderId.value,
preserveOptions: true, // Prevents startUpload from overwriting recording's options
asrOptions: {
language: asrLanguage.value,
min_speakers: asrMinSpeakers.value,
max_speakers: asrMaxSpeakers.value
},
status: 'queued',
recordingId: null,
clientId: `client-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
error: null,
willAutoSummarize: false, // Server will tell us via SUMMARIZING status
// Callback to clear IndexedDB session AFTER successful upload (not before)
onUploadSuccess: async () => {
try {
await RecordingDB.clearRecordingSession();
console.log('[Recording] IndexedDB session cleared after successful upload');
} catch (dbError) {
console.warn('[Recording] Failed to clear IndexedDB session:', dbError);
}
}
});
discardRecording();
// Return to upload view (main UI)
currentView.value = 'upload';
// Start upload immediately
progressPopupMinimized.value = false;
progressPopupClosed.value = false;
if (startUploadQueue) {
startUploadQueue();
}
};
// Upload recorded audio in incognito mode
const uploadRecordedAudioIncognito = async () => {
if (!audioBlobURL.value) {
setGlobalError("No recorded audio to upload.");
return;
}
// Check if incognito state is available
if (!incognitoProcessing || !incognitoRecording) {
console.warn('[Incognito] Incognito state not available, falling back to normal upload');
uploadRecordedAudio();
return;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const recordedFile = new File(audioChunks.value, `recording-${timestamp}.webm`, { type: 'audio/webm' });
incognitoProcessing.value = true;
processingMessage.value = 'Processing recording in incognito mode...';
processingProgress.value = 10;
progressPopupMinimized.value = false;
progressPopupClosed.value = false;
try {
const formData = new FormData();
formData.append('file', recordedFile);
// Add ASR options
if (asrLanguage.value) {
formData.append('language', asrLanguage.value);
}
if (asrMinSpeakers.value && asrMinSpeakers.value !== '') {
formData.append('min_speakers', asrMinSpeakers.value.toString());
}
if (asrMaxSpeakers.value && asrMaxSpeakers.value !== '') {
formData.append('max_speakers', asrMaxSpeakers.value.toString());
}
// Request auto-summarization
formData.append('auto_summarize', 'true');
processingMessage.value = 'Uploading recording for incognito processing...';
processingProgress.value = 20;
console.log('[Incognito] Uploading recorded audio');
const response = await fetch('/api/recordings/incognito', {
method: 'POST',
body: formData
});
processingProgress.value = 50;
// Parse response
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
const text = await response.text();
const titleMatch = text.match(/<title>([^<]+)<\/title>/i);
throw new Error(titleMatch?.[1] || `Server error (${response.status})`);
}
const data = await response.json();
if (!response.ok || data.error) {
throw new Error(data.error || `Processing failed with status ${response.status}`);
}
processingProgress.value = 80;
processingMessage.value = 'Processing complete!';
// Store result in sessionStorage
const incognitoData = {
id: 'incognito',
incognito: true,
title: data.title || 'Incognito Recording',
transcription: data.transcription,
summary: data.summary,
summary_html: data.summary_html,
created_at: data.created_at,
original_filename: data.original_filename,
file_size: data.file_size,
audio_duration_seconds: data.audio_duration_seconds,
processing_time_seconds: data.processing_time_seconds,
status: 'COMPLETED'
};
IncognitoStorage.saveIncognitoRecording(incognitoData);
incognitoRecording.value = incognitoData;
// Clear IndexedDB session
try {
await RecordingDB.clearRecordingSession();
} catch (dbError) {
console.warn('[Recording] Failed to clear IndexedDB session:', dbError);
}
// Clear recording state (must await so currentView='upload' completes
// before we override it with 'detail', otherwise the deferred
// currentView='upload' fires after 'detail' and the view watcher
// clears incognito data thinking we navigated away)
await discardRecording();
processingProgress.value = 100;
processingMessage.value = 'Incognito recording ready!';
// Auto-select the incognito recording and switch to detail view
selectedRecording.value = incognitoData;
currentView.value = 'detail';
// Reset incognito mode toggle
incognitoMode.value = false;
// Show toast
showToast('Incognito recording processed - data will be lost when tab closes', 'fa-user-secret');
console.log('[Incognito] Recording processing complete');
} catch (error) {
console.error('[Incognito] Recording processing failed:', error);
setGlobalError(`Incognito processing failed: ${error.message}`);
} finally {
incognitoProcessing.value = false;
}
};
// Discard recording
const discardRecording = async () => {
if (audioBlobURL.value) {
URL.revokeObjectURL(audioBlobURL.value);
}
audioBlobURL.value = null;
audioChunks.value = [];
isRecording.value = false;
recordingTime.value = 0;
if (recordingInterval.value) clearInterval(recordingInterval.value);
recordingNotes.value = '';
selectedTagIds.value = [];
asrLanguage.value = '';
asrMinSpeakers.value = '';
asrMaxSpeakers.value = '';
// Clear IndexedDB session
try {
await RecordingDB.clearRecordingSession();
} catch (dbError) {
console.warn('[Recording] Failed to clear IndexedDB session:', dbError);
}
await releaseWakeLock();
await hideRecordingNotification();
// Return to upload view
currentView.value = 'upload';
};
// Draw single visualizer
const drawSingleVisualizer = (analyserNode, canvasElement) => {
if (!analyserNode || !canvasElement) return;
const bufferLength = analyserNode.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
analyserNode.getByteFrequencyData(dataArray);
const canvasCtx = canvasElement.getContext('2d');
const WIDTH = canvasElement.width;
const HEIGHT = canvasElement.height;
canvasCtx.clearRect(0, 0, WIDTH, HEIGHT);
const barWidth = (WIDTH / bufferLength) * 1.5;
let barHeight;
let x = 0;
const buttonColor = getComputedStyle(document.documentElement).getPropertyValue('--bg-button').trim();
const buttonHoverColor = getComputedStyle(document.documentElement).getPropertyValue('--bg-button-hover').trim();
const gradient = canvasCtx.createLinearGradient(0, 0, 0, HEIGHT);
if (isDarkMode.value) {
gradient.addColorStop(0, buttonColor);
gradient.addColorStop(0.6, buttonHoverColor);
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.2)');
} else {
gradient.addColorStop(0, buttonColor);
gradient.addColorStop(0.5, buttonHoverColor);
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.1)');
}
for (let i = 0; i < bufferLength; i++) {
barHeight = dataArray[i] / 2.5;
canvasCtx.fillStyle = gradient;
canvasCtx.fillRect(x, HEIGHT - barHeight, barWidth, barHeight);
x += barWidth + 2;
}
};
// Draw visualizers
const drawVisualizers = () => {
if (!isRecording.value) {
if (animationFrameId.value) {
cancelAnimationFrame(animationFrameId.value);
animationFrameId.value = null;
}
return;
}
animationFrameId.value = requestAnimationFrame(drawVisualizers);
if (recordingMode.value === 'both') {
drawSingleVisualizer(micAnalyser.value, micVisualizer.value);
drawSingleVisualizer(systemAnalyser.value, systemVisualizer.value);
} else {
drawSingleVisualizer(analyser.value, visualizer.value);
}
};
// Update file size estimate
const updateFileSizeEstimate = () => {
if (!isRecording.value || !audioChunks.value.length) return;
const totalSize = audioChunks.value.reduce((sum, chunk) => sum + chunk.size, 0);
estimatedFileSize.value = totalSize;
if (recordingTime.value > 0) {
actualBitrate.value = (totalSize * 8) / recordingTime.value;
}
// Check for size warning
const sizeMB = totalSize / (1024 * 1024);
const warningThresholdMB = maxRecordingMB.value * 0.8;
if (sizeMB > warningThresholdMB && !fileSizeWarningShown.value) {
fileSizeWarningShown.value = true;
showToast(
`Recording size is ${formatFileSize(totalSize)}. Consider stopping soon.`,
'fa-exclamation-triangle',
5000
);
}
// Auto-stop if max size reached
if (sizeMB > maxRecordingMB.value) {
stopRecording();
showToast(
`Recording automatically stopped at ${formatFileSize(totalSize)}`,
'fa-stop-circle',
7000
);
}
};
// Start size monitoring
const startSizeMonitoring = () => {
if (sizeCheckInterval.value) {
clearInterval(sizeCheckInterval.value);
}
sizeCheckInterval.value = setInterval(updateFileSizeEstimate, 2000);
};
// Stop size monitoring
const stopSizeMonitoring = () => {
if (sizeCheckInterval.value) {
clearInterval(sizeCheckInterval.value);
sizeCheckInterval.value = null;
}
};
// Check if there's an unsaved recording
const hasUnsavedRecording = () => {
return isRecording.value || audioBlobURL.value;
};
// Recover recording from IndexedDB
const recoverRecordingFromDB = async () => {
try {
const recovered = await RecordingDB.recoverRecording();
if (!recovered) {
return null;
}
// Restore chunks
audioChunks.value = recovered.chunks;
// Create blob URL
const blob = new Blob(recovered.chunks, { type: recovered.metadata.mimeType });
audioBlobURL.value = URL.createObjectURL(blob);
// Restore metadata
recordingMode.value = recovered.metadata.mode;
recordingNotes.value = recovered.metadata.notes;
selectedTagIds.value = recovered.metadata.tags;
recordingTime.value = recovered.metadata.duration;
if (recovered.metadata.asrOptions) {
asrLanguage.value = recovered.metadata.asrOptions.language || '';
asrMinSpeakers.value = recovered.metadata.asrOptions.min_speakers || '';
asrMaxSpeakers.value = recovered.metadata.asrOptions.max_speakers || '';
}
console.log('[Recording] Successfully recovered recording from IndexedDB');
return recovered.metadata;
} catch (error) {
console.error('[Recording] Failed to recover recording:', error);
return null;
}
};
// No initialization needed - system audio detection is handled by computed property
const initializeAudio = async () => {
// Placeholder for future initialization if needed
};
return {
startRecording,
stopRecording,
discardRecording,
uploadRecordedAudio,
uploadRecordedAudioIncognito,
acceptRecordingDisclaimer,
cancelRecordingDisclaimer,
updateFileSizeEstimate,
startSizeMonitoring,
stopSizeMonitoring,
drawVisualizers,
drawSingleVisualizer,
handleVisibilityChange,
hasUnsavedRecording,
acquireWakeLock,
releaseWakeLock,
initializeAudio,
recoverRecordingFromDB,
checkForRecoverableRecording: RecordingDB.checkForRecoverableRecording,
clearRecordingSession: RecordingDB.clearRecordingSession
};
}

View File

@@ -0,0 +1,338 @@
/**
* Audio Player Composable
*
* Centralized audio playback functionality for consistent behavior across the app.
* This module handles:
* - Playback state (playing, paused, loading)
* - Time tracking (current time, duration)
* - Volume/mute control
* - Seeking with progress bar support
* - Server-side duration support (for formats like WebM that don't report duration)
*
* Usage:
* const player = useAudioPlayer(ref, computed);
* // In template: @loadedmetadata="player.handleLoadedMetadata"
* // When recording changes: player.setServerDuration(recording.audio_duration)
*/
export function useAudioPlayer(ref, computed) {
// --- State ---
const isPlaying = ref(false);
const currentTime = ref(0);
const duration = ref(0);
const isMuted = ref(false);
const isLoading = ref(false);
const volume = ref(1.0);
// Progress bar drag state
const isDragging = ref(false);
const dragPreviewPercent = ref(0);
// Track if we have a reliable server-side duration
let hasServerDuration = false;
// --- Computed ---
const progressPercent = computed(() => {
// Use preview position while dragging for smooth UI
if (isDragging.value) {
return dragPreviewPercent.value;
}
if (!duration.value) return 0;
return (currentTime.value / duration.value) * 100;
});
// Preview time display while dragging
const displayCurrentTime = computed(() => {
if (isDragging.value && duration.value) {
return (dragPreviewPercent.value / 100) * duration.value;
}
return currentTime.value;
});
// --- Duration Management ---
/**
* Set duration from server-side ffprobe value.
* This is more reliable than browser metadata for some formats (WebM, etc.)
*/
const setServerDuration = (serverDuration) => {
if (serverDuration && isFinite(serverDuration) && serverDuration > 0) {
duration.value = serverDuration;
hasServerDuration = true;
} else {
hasServerDuration = false;
}
};
/**
* Try to set duration from browser, only if we don't have a server-side value.
* Browser duration can be Infinity for some WebM files.
*/
const trySetBrowserDuration = (browserDuration) => {
if (hasServerDuration) {
// Don't overwrite reliable server-side duration
return;
}
if (browserDuration && isFinite(browserDuration) && browserDuration > 0) {
duration.value = browserDuration;
}
};
// --- Event Handlers ---
const handlePlayPause = (event) => {
isPlaying.value = !event.target.paused;
};
const handleLoadedMetadata = (event) => {
trySetBrowserDuration(event.target.duration);
isLoading.value = false;
};
const handleDurationChange = (event) => {
// WebM and some formats may initially report Infinity duration
// This handler catches when the actual duration becomes available
trySetBrowserDuration(event.target.duration);
};
const handleTimeUpdate = (event) => {
currentTime.value = event.target.currentTime;
// Fallback: if duration wasn't set yet, try to get it now
if (!duration.value || duration.value === 0) {
trySetBrowserDuration(event.target.duration);
}
};
const handleEnded = () => {
isPlaying.value = false;
currentTime.value = 0;
};
const handleWaiting = () => {
isLoading.value = true;
};
const handleCanPlay = (event) => {
isLoading.value = false;
// Fallback: try to get duration if not set yet
if (!duration.value || duration.value === 0) {
trySetBrowserDuration(event.target.duration);
}
};
const handleVolumeChange = (event) => {
volume.value = event.target.volume;
isMuted.value = event.target.muted;
};
// --- Actions ---
/**
* Get the audio element. Override this for custom element selection.
*/
let getAudioElement = () => {
return document.querySelector('audio[ref="audioPlayerElement"]') ||
document.querySelector('video[ref="audioPlayerElement"]') ||
document.querySelector('audio') ||
document.querySelector('video');
};
/**
* Set custom audio element getter.
*/
const setAudioElementGetter = (getter) => {
getAudioElement = getter;
};
const play = () => {
const audio = getAudioElement();
if (audio) {
audio.play().catch(err => console.warn('Play failed:', err));
}
};
const pause = () => {
const audio = getAudioElement();
if (audio) {
audio.pause();
}
};
const togglePlayback = () => {
const audio = getAudioElement();
if (!audio) return;
if (audio.paused) {
audio.play().catch(err => console.warn('Play failed:', err));
} else {
audio.pause();
}
};
const seekTo = (time) => {
const audio = getAudioElement();
if (!audio || !isFinite(time)) return;
const maxTime = isFinite(audio.duration) ? audio.duration : time;
audio.currentTime = Math.max(0, Math.min(time, maxTime));
};
const seekByPercent = (percent) => {
const audio = getAudioElement();
if (!audio || !duration.value || !isFinite(duration.value)) return;
const time = (percent / 100) * duration.value;
audio.currentTime = time;
};
const setVolume = (value) => {
const audio = getAudioElement();
if (audio) {
audio.volume = Math.max(0, Math.min(1, value));
volume.value = audio.volume;
}
};
const toggleMute = () => {
const audio = getAudioElement();
if (!audio) return;
if (audio.muted || audio.volume === 0) {
audio.muted = false;
if (audio.volume === 0) {
audio.volume = 0.5;
}
isMuted.value = false;
} else {
audio.muted = true;
isMuted.value = true;
}
};
// --- Progress Bar Drag Support ---
const startProgressDrag = (event) => {
const bar = event.currentTarget.querySelector('.h-2') || event.currentTarget;
const rect = bar.getBoundingClientRect();
const isTouch = event.type === 'touchstart';
const getPercent = (evt) => {
const clientX = isTouch ? evt.touches[0].clientX : evt.clientX;
return Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
};
// Start dragging - show preview
isDragging.value = true;
dragPreviewPercent.value = getPercent(event);
const onMove = (evt) => {
evt.preventDefault();
const clientX = isTouch ? evt.touches[0].clientX : evt.clientX;
dragPreviewPercent.value = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
};
const onUp = () => {
document.removeEventListener(isTouch ? 'touchmove' : 'mousemove', onMove);
document.removeEventListener(isTouch ? 'touchend' : 'mouseup', onUp);
// Seek to final position on release
seekByPercent(dragPreviewPercent.value);
isDragging.value = false;
};
document.addEventListener(isTouch ? 'touchmove' : 'mousemove', onMove, { passive: false });
document.addEventListener(isTouch ? 'touchend' : 'mouseup', onUp);
};
// --- Utility ---
const formatTime = (seconds) => {
if (!seconds || isNaN(seconds)) return '0:00';
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
/**
* Reset all player state (call when changing recordings)
*/
const reset = () => {
isPlaying.value = false;
currentTime.value = 0;
duration.value = 0;
isMuted.value = false;
isLoading.value = false;
hasServerDuration = false;
};
/**
* Initialize with a recording object.
* Automatically uses server-side duration if available.
*/
const initWithRecording = (recording) => {
reset();
if (recording && recording.audio_duration) {
setServerDuration(recording.audio_duration);
}
};
return {
// State
isPlaying,
currentTime,
duration,
isMuted,
isLoading,
volume,
isDragging,
dragPreviewPercent,
// Computed
progressPercent,
displayCurrentTime,
// Duration management
setServerDuration,
trySetBrowserDuration,
// Event handlers (wire these to <audio> element)
handlePlayPause,
handleLoadedMetadata,
handleDurationChange,
handleTimeUpdate,
handleEnded,
handleWaiting,
handleCanPlay,
handleVolumeChange,
// Actions
play,
pause,
togglePlayback,
seekTo,
seekByPercent,
setVolume,
toggleMute,
startProgressDrag,
setAudioElementGetter,
// Utility
formatTime,
reset,
initWithRecording
};
}
/**
* Create a standalone audio player instance.
* Use this for pages that don't have Vue's ref/computed (like share.html).
*/
export function createStandalonePlayer(Vue) {
const { ref, computed } = Vue;
return useAudioPlayer(ref, computed);
}

View File

@@ -0,0 +1,475 @@
/**
* Bulk Operations Composable
* Handles bulk API operations for multiple recordings
*/
const { ref, computed } = Vue;
export function useBulkOperations({
selectedRecordingIds,
selectedRecordings,
recordings,
selectedRecording,
bulkActionInProgress,
availableTags,
availableFolders,
showToast,
setGlobalError,
startReprocessingPoll
}) {
// Modal state
const showBulkDeleteModal = ref(false);
const showBulkTagModal = ref(false);
const showBulkReprocessModal = ref(false);
const showBulkFolderModal = ref(false);
const bulkTagAction = ref('add'); // 'add' or 'remove'
const bulkTagSelectedId = ref('');
const bulkReprocessType = ref('summary'); // 'transcription' or 'summary'
// Get CSRF token
const getCsrfToken = () => {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
};
// Helper to get selected IDs as array
const getSelectedIds = () => {
return Array.from(selectedRecordingIds.value);
};
// =========================================
// Bulk Delete
// =========================================
const openBulkDeleteModal = () => {
showBulkDeleteModal.value = true;
};
const closeBulkDeleteModal = () => {
showBulkDeleteModal.value = false;
};
const executeBulkDelete = async () => {
const ids = getSelectedIds();
if (ids.length === 0) return;
bulkActionInProgress.value = true;
closeBulkDeleteModal();
try {
const response = await fetch('/api/recordings/bulk', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify({ recording_ids: ids })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to delete recordings');
}
// Remove deleted recordings from local state
const deletedIds = new Set(data.deleted_ids || ids);
recordings.value = recordings.value.filter(r => !deletedIds.has(r.id));
// Clear selected recording if it was deleted
if (selectedRecording.value && deletedIds.has(selectedRecording.value.id)) {
selectedRecording.value = null;
}
// Remove deleted IDs from selection
deletedIds.forEach(id => selectedRecordingIds.value.delete(id));
const count = deletedIds.size;
showToast(`${count} recording${count !== 1 ? 's' : ''} deleted`, 'fa-trash', 3000, 'success');
} catch (error) {
console.error('Bulk delete error:', error);
setGlobalError(`Failed to delete recordings: ${error.message}`);
} finally {
bulkActionInProgress.value = false;
}
};
// =========================================
// Bulk Tag Operations
// =========================================
const openBulkTagModal = (action = 'add') => {
bulkTagAction.value = action;
bulkTagSelectedId.value = '';
showBulkTagModal.value = true;
};
const closeBulkTagModal = () => {
showBulkTagModal.value = false;
bulkTagSelectedId.value = '';
};
const executeBulkTag = async () => {
const ids = getSelectedIds();
const tagId = bulkTagSelectedId.value;
const action = bulkTagAction.value;
// Validate before making API call
if (ids.length === 0) {
console.warn('No recordings selected for bulk tag operation');
return;
}
if (!tagId && tagId !== 0) {
console.warn('No tag selected for bulk tag operation');
return;
}
bulkActionInProgress.value = true;
closeBulkTagModal();
try {
const response = await fetch('/api/recordings/bulk-tags', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify({
recording_ids: ids,
tag_id: parseInt(tagId),
action: action
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `Failed to ${action} tag`);
}
// Update local state
const tag = availableTags.value.find(t => t.id == tagId);
if (tag) {
const affectedIds = new Set(data.affected_ids || ids);
recordings.value.forEach(recording => {
if (affectedIds.has(recording.id)) {
if (!recording.tags) recording.tags = [];
if (action === 'add') {
// Add tag if not already present
if (!recording.tags.find(t => t.id === tag.id)) {
recording.tags.push(tag);
}
} else {
// Remove tag
recording.tags = recording.tags.filter(t => t.id !== tag.id);
}
}
});
// Update selected recording if affected
if (selectedRecording.value && affectedIds.has(selectedRecording.value.id)) {
if (!selectedRecording.value.tags) selectedRecording.value.tags = [];
if (action === 'add') {
if (!selectedRecording.value.tags.find(t => t.id === tag.id)) {
selectedRecording.value.tags.push(tag);
}
} else {
selectedRecording.value.tags = selectedRecording.value.tags.filter(t => t.id !== tag.id);
}
}
}
const count = data.affected_ids?.length || ids.length;
const actionText = action === 'add' ? 'added to' : 'removed from';
showToast(`Tag ${actionText} ${count} recording${count !== 1 ? 's' : ''}`, 'fa-tags', 3000, 'success');
} catch (error) {
console.error('Bulk tag error:', error);
setGlobalError(`Failed to ${action} tag: ${error.message}`);
} finally {
bulkActionInProgress.value = false;
}
};
// =========================================
// Bulk Reprocess
// =========================================
const openBulkReprocessModal = () => {
bulkReprocessType.value = 'summary';
showBulkReprocessModal.value = true;
};
const closeBulkReprocessModal = () => {
showBulkReprocessModal.value = false;
};
const executeBulkReprocess = async () => {
const ids = getSelectedIds();
if (ids.length === 0) return;
bulkActionInProgress.value = true;
closeBulkReprocessModal();
try {
const response = await fetch('/api/recordings/bulk-reprocess', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify({
recording_ids: ids,
type: bulkReprocessType.value
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to queue reprocessing');
}
// Update status for queued recordings
const queuedIds = new Set(data.queued_ids || ids);
const newStatus = bulkReprocessType.value === 'transcription' ? 'PROCESSING' : 'SUMMARIZING';
recordings.value.forEach(recording => {
if (queuedIds.has(recording.id)) {
recording.status = newStatus;
// Start polling for each
if (startReprocessingPoll) {
startReprocessingPoll(recording.id);
}
}
});
if (selectedRecording.value && queuedIds.has(selectedRecording.value.id)) {
selectedRecording.value.status = newStatus;
}
const count = queuedIds.size;
const typeText = bulkReprocessType.value === 'transcription' ? 'Transcription' : 'Summary';
showToast(`${typeText} reprocessing queued for ${count} recording${count !== 1 ? 's' : ''}`, 'fa-sync-alt', 3000, 'success');
} catch (error) {
console.error('Bulk reprocess error:', error);
setGlobalError(`Failed to queue reprocessing: ${error.message}`);
} finally {
bulkActionInProgress.value = false;
}
};
// =========================================
// Bulk Toggle (Inbox/Highlight)
// =========================================
const bulkToggleInbox = async (value = null) => {
const ids = getSelectedIds();
if (ids.length === 0) return;
// If no value specified, toggle based on majority
if (value === null) {
const inboxCount = selectedRecordings.value.filter(r => r.is_inbox).length;
value = inboxCount < ids.length / 2;
}
bulkActionInProgress.value = true;
try {
const response = await fetch('/api/recordings/bulk-toggle', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify({
recording_ids: ids,
field: 'inbox',
value: value
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to update inbox status');
}
// Update local state
const affectedIds = new Set(data.affected_ids || ids);
recordings.value.forEach(recording => {
if (affectedIds.has(recording.id)) {
recording.is_inbox = value;
}
});
if (selectedRecording.value && affectedIds.has(selectedRecording.value.id)) {
selectedRecording.value.is_inbox = value;
}
const count = affectedIds.size;
const actionText = value ? 'added to' : 'removed from';
showToast(`${count} recording${count !== 1 ? 's' : ''} ${actionText} inbox`, 'fa-inbox', 3000, 'success');
} catch (error) {
console.error('Bulk toggle inbox error:', error);
setGlobalError(`Failed to update inbox status: ${error.message}`);
} finally {
bulkActionInProgress.value = false;
}
};
const bulkToggleHighlight = async (value = null) => {
const ids = getSelectedIds();
if (ids.length === 0) return;
// If no value specified, toggle based on majority
if (value === null) {
const highlightCount = selectedRecordings.value.filter(r => r.is_highlighted).length;
value = highlightCount < ids.length / 2;
}
bulkActionInProgress.value = true;
try {
const response = await fetch('/api/recordings/bulk-toggle', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify({
recording_ids: ids,
field: 'highlight',
value: value
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to update highlight status');
}
// Update local state
const affectedIds = new Set(data.affected_ids || ids);
recordings.value.forEach(recording => {
if (affectedIds.has(recording.id)) {
recording.is_highlighted = value;
}
});
if (selectedRecording.value && affectedIds.has(selectedRecording.value.id)) {
selectedRecording.value.is_highlighted = value;
}
const count = affectedIds.size;
const actionText = value ? 'highlighted' : 'unhighlighted';
showToast(`${count} recording${count !== 1 ? 's' : ''} ${actionText}`, 'fa-star', 3000, 'success');
} catch (error) {
console.error('Bulk toggle highlight error:', error);
setGlobalError(`Failed to update highlight status: ${error.message}`);
} finally {
bulkActionInProgress.value = false;
}
};
// =========================================
// Bulk Folder Assignment
// =========================================
const bulkAssignFolder = async (folderId) => {
const ids = getSelectedIds();
if (ids.length === 0) return;
bulkActionInProgress.value = true;
showBulkFolderModal.value = false;
try {
const response = await fetch('/api/recordings/bulk/folder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify({
recording_ids: ids,
folder_id: folderId
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to update folders');
}
// Update local state
const folder = folderId ? availableFolders.value.find(f => f.id === folderId) : null;
recordings.value.forEach(recording => {
if (ids.includes(recording.id)) {
recording.folder_id = folderId;
recording.folder = folder;
}
});
// Update selected recording if affected
if (selectedRecording.value && ids.includes(selectedRecording.value.id)) {
selectedRecording.value.folder_id = folderId;
selectedRecording.value.folder = folder;
}
// Update folder recording counts
if (availableFolders.value) {
availableFolders.value.forEach(f => {
const count = recordings.value.filter(r => r.folder_id === f.id).length;
f.recording_count = count;
});
}
const count = data.updated_count || ids.length;
if (folderId) {
showToast(`${count} recording${count !== 1 ? 's' : ''} moved to "${folder?.name || 'folder'}"`, 'fa-folder', 3000, 'success');
} else {
showToast(`${count} recording${count !== 1 ? 's' : ''} removed from folder`, 'fa-folder-minus', 3000, 'success');
}
} catch (error) {
console.error('Bulk folder assignment error:', error);
setGlobalError(`Failed to update folders: ${error.message}`);
} finally {
bulkActionInProgress.value = false;
}
};
return {
// Modal state
showBulkDeleteModal,
showBulkTagModal,
showBulkReprocessModal,
showBulkFolderModal,
bulkTagAction,
bulkTagSelectedId,
bulkReprocessType,
// Bulk Delete
openBulkDeleteModal,
closeBulkDeleteModal,
executeBulkDelete,
// Bulk Tag
openBulkTagModal,
closeBulkTagModal,
executeBulkTag,
// Bulk Reprocess
openBulkReprocessModal,
closeBulkReprocessModal,
executeBulkReprocess,
// Bulk Toggle
bulkToggleInbox,
bulkToggleHighlight,
// Bulk Folder
bulkAssignFolder
};
}

View File

@@ -0,0 +1,111 @@
/**
* Bulk Selection Composable
* Handles multi-select functionality for recordings
*/
const { computed } = Vue;
export function useBulkSelection({
selectionMode,
selectedRecordingIds,
recordings,
selectedRecording,
currentView
}) {
// Computed
const selectedCount = computed(() => selectedRecordingIds.value.size);
const selectedRecordings = computed(() => {
return recordings.value.filter(r => selectedRecordingIds.value.has(r.id));
});
const allVisibleSelected = computed(() => {
if (recordings.value.length === 0) return false;
return recordings.value.every(r => selectedRecordingIds.value.has(r.id));
});
const isSelected = (id) => {
return selectedRecordingIds.value.has(id);
};
// Methods
const enterSelectionMode = () => {
selectionMode.value = true;
selectedRecordingIds.value = new Set();
};
const exitSelectionMode = () => {
selectionMode.value = false;
selectedRecordingIds.value = new Set();
};
const toggleSelection = (id) => {
const newSet = new Set(selectedRecordingIds.value);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
selectedRecordingIds.value = newSet;
};
const selectAll = () => {
const newSet = new Set();
recordings.value.forEach(r => newSet.add(r.id));
selectedRecordingIds.value = newSet;
};
const clearSelection = () => {
selectedRecordingIds.value = new Set();
};
// Keyboard handler for selection mode
const handleSelectionKeyboard = (event) => {
if (!selectionMode.value) return;
// Escape to exit selection mode
if (event.key === 'Escape') {
exitSelectionMode();
event.preventDefault();
}
// Ctrl/Cmd + A to select all
if ((event.ctrlKey || event.metaKey) && event.key === 'a') {
// Only if not in an input field
if (document.activeElement.tagName !== 'INPUT' &&
document.activeElement.tagName !== 'TEXTAREA' &&
!document.activeElement.isContentEditable) {
event.preventDefault();
selectAll();
}
}
};
// Initialize keyboard listener
const initSelectionKeyboardListeners = () => {
document.addEventListener('keydown', handleSelectionKeyboard);
};
const cleanupSelectionKeyboardListeners = () => {
document.removeEventListener('keydown', handleSelectionKeyboard);
};
return {
// Computed
selectedCount,
selectedRecordings,
allVisibleSelected,
// Methods
isSelected,
enterSelectionMode,
exitSelectionMode,
toggleSelection,
selectAll,
clearSelection,
// Keyboard
initSelectionKeyboardListeners,
cleanupSelectionKeyboardListeners
};
}

View File

@@ -0,0 +1,380 @@
/**
* Chat composable
* Handles AI chat functionality with streaming responses
*/
export function useChat(state, utils) {
const {
showChat, isChatMaximized, chatMessages, chatInput,
isChatLoading, chatMessagesRef, chatInputRef, selectedRecording, csrfToken
} = state;
const { showToast, setGlobalError, onChatComplete, t } = utils;
// Helper function to check if chat is scrolled to bottom (within bottom 5%)
const isChatScrolledToBottom = () => {
if (!chatMessagesRef.value) return true;
const { scrollTop, scrollHeight, clientHeight } = chatMessagesRef.value;
const scrollableHeight = scrollHeight - clientHeight;
if (scrollableHeight <= 0) return true;
const scrollPercentage = scrollTop / scrollableHeight;
return scrollPercentage >= 0.95;
};
// Helper function to scroll chat to bottom
const scrollChatToBottom = () => {
if (chatMessagesRef.value) {
requestAnimationFrame(() => {
if (chatMessagesRef.value) {
chatMessagesRef.value.scrollTop = chatMessagesRef.value.scrollHeight;
}
});
}
};
const focusChatInput = () => {
Vue.nextTick(() => {
if (chatInputRef.value) {
chatInputRef.value.focus();
}
});
};
const toggleChatMaximize = () => {
if (isChatMaximized.value) {
isChatMaximized.value = false;
} else {
isChatMaximized.value = true;
if (!showChat.value) {
showChat.value = true;
}
}
};
const sendChatMessage = async () => {
if (!chatInput.value.trim() || isChatLoading.value || !selectedRecording.value || selectedRecording.value.status !== 'COMPLETED') {
return;
}
const message = chatInput.value.trim();
if (!Array.isArray(chatMessages.value)) {
chatMessages.value = [];
}
chatMessages.value.push({ role: 'user', content: message });
chatInput.value = '';
isChatLoading.value = true;
focusChatInput();
await Vue.nextTick();
scrollChatToBottom();
let assistantMessage = null;
try {
const messageHistory = chatMessages.value
.slice(0, -1)
.map(msg => ({ role: msg.role, content: msg.content }));
// Check if this is an incognito recording
const isIncognito = selectedRecording.value.incognito === true;
let response;
if (isIncognito) {
// Use incognito chat endpoint - pass transcription directly
response = await fetch('/api/recordings/incognito/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
transcription: selectedRecording.value.transcription,
participants: selectedRecording.value.participants || '',
notes: selectedRecording.value.notes || '',
message: message,
message_history: messageHistory
})
});
} else {
// Use regular chat endpoint
response = await fetch('/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recording_id: selectedRecording.value.id,
message: message,
message_history: messageHistory
})
});
}
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to get chat response');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const processStream = async () => {
let isFirstChunk = true;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.substring(6);
// Handle [DONE] marker from incognito endpoint
if (jsonStr === '[DONE]') {
return;
}
if (jsonStr) {
try {
const data = JSON.parse(jsonStr);
if (data.thinking) {
const shouldScroll = isChatScrolledToBottom();
if (isFirstChunk) {
isChatLoading.value = false;
assistantMessage = Vue.reactive({
role: 'assistant',
content: '',
html: '',
thinking: data.thinking,
thinkingExpanded: false
});
chatMessages.value.push(assistantMessage);
isFirstChunk = false;
} else if (assistantMessage) {
if (assistantMessage.thinking) {
assistantMessage.thinking += '\n\n' + data.thinking;
} else {
assistantMessage.thinking = data.thinking;
}
}
if (shouldScroll) {
await Vue.nextTick();
scrollChatToBottom();
}
}
// Handle both 'delta' (regular) and 'content' (incognito) formats
const textContent = data.delta || data.content;
if (textContent) {
const shouldScroll = isChatScrolledToBottom();
if (isFirstChunk) {
isChatLoading.value = false;
assistantMessage = Vue.reactive({
role: 'assistant',
content: '',
html: '',
thinking: '',
thinkingExpanded: false
});
chatMessages.value.push(assistantMessage);
isFirstChunk = false;
}
assistantMessage.content += textContent;
assistantMessage.html = marked.parse(assistantMessage.content);
if (shouldScroll) {
await Vue.nextTick();
scrollChatToBottom();
}
}
if (data.end_of_stream) {
return;
}
if (data.error) {
if (data.budget_exceeded) {
throw new Error(t('adminDashboard.tokenBudgetExceeded'));
}
throw new Error(data.error);
}
} catch (e) {
console.error('Error parsing stream data:', e);
}
}
}
}
}
};
await processStream();
} catch (error) {
console.error('Chat Error:', error);
if (assistantMessage) {
assistantMessage.content = `Error: ${error.message}`;
assistantMessage.html = `<span class="text-red-500">Error: ${error.message}</span>`;
} else {
chatMessages.value.push({
role: 'assistant',
content: `Error: ${error.message}`,
html: `<span class="text-red-500">Error: ${error.message}</span>`
});
}
} finally {
isChatLoading.value = false;
await Vue.nextTick();
if (isChatScrolledToBottom()) {
scrollChatToBottom();
}
focusChatInput();
// Refresh token budget after chat completion
if (onChatComplete) {
onChatComplete();
}
}
};
const handleChatKeydown = (event) => {
if (event.key === 'Enter') {
if (event.ctrlKey || event.shiftKey) {
return;
} else {
event.preventDefault();
sendChatMessage();
}
}
};
const clearChat = () => {
if (chatMessages.value.length > 0) {
chatMessages.value = [];
showToast(t('chat.cleared'), 'fa-broom');
}
};
const downloadChat = async () => {
if (!selectedRecording.value || chatMessages.value.length === 0) {
showToast(t('chat.noMessagesToDownload'), 'fa-exclamation-circle');
return;
}
try {
const csrfTokenValue = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/recording/${selectedRecording.value.id}/download/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfTokenValue
},
body: JSON.stringify({
messages: chatMessages.value
})
});
if (!response.ok) {
const error = await response.json();
showToast(error.error || t('chat.downloadFailed'), 'fa-exclamation-circle');
return;
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
const contentDisposition = response.headers.get('Content-Disposition');
let filename = 'chat.docx';
if (contentDisposition) {
const utf8Match = /filename\*=utf-8''(.+)/.exec(contentDisposition);
if (utf8Match) {
filename = decodeURIComponent(utf8Match[1]);
} else {
const regularMatch = /filename="(.+)"/.exec(contentDisposition);
if (regularMatch) {
filename = regularMatch[1];
}
}
}
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showToast(t('chat.downloadSuccess'));
} catch (error) {
console.error('Download failed:', error);
showToast(t('chat.downloadFailed'), 'fa-exclamation-circle');
}
};
const copyMessage = (text, event) => {
const button = event.currentTarget;
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text)
.then(() => {
showToast(t('messages.copiedSuccessfully'));
animateCopyButton(button);
})
.catch(err => {
console.error('Copy failed:', err);
showToast(t('messages.copyFailed') + ': ' + err.message, 'fa-exclamation-circle');
fallbackCopyTextToClipboard(text, button);
});
} else {
fallbackCopyTextToClipboard(text, button);
}
};
const animateCopyButton = (button) => {
button.classList.add('copy-success');
const originalContent = button.innerHTML;
button.innerHTML = '<i class="fas fa-check"></i>';
setTimeout(() => {
button.classList.remove('copy-success');
button.innerHTML = originalContent;
}, 1500);
};
const fallbackCopyTextToClipboard = (text, button = null) => {
try {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
textArea.style.top = "-999999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
showToast(t('messages.copiedSuccessfully'));
if (button) animateCopyButton(button);
} else {
showToast(t('messages.copyNotSupported'), 'fa-exclamation-circle');
}
} catch (err) {
console.error('Fallback copy failed:', err);
showToast(t('messages.copyFailed') + ': ' + err.message, 'fa-exclamation-circle');
}
};
return {
isChatScrolledToBottom,
scrollChatToBottom,
toggleChatMaximize,
sendChatMessage,
handleChatKeydown,
clearChat,
downloadChat,
copyMessage,
animateCopyButton,
fallbackCopyTextToClipboard
};
}

View File

@@ -0,0 +1,173 @@
/**
* Folders Management Composable
* Handles folder operations for recordings
*/
const { computed, ref } = Vue;
export function useFolders({
recordings,
availableFolders,
selectedRecording,
showToast,
setGlobalError
}) {
// Computed / Helpers
const getRecordingFolder = (recording) => {
if (!recording || !recording.folder_id) return null;
// Try to get from recording.folder first, then lookup
if (recording.folder) return recording.folder;
return availableFolders.value?.find(f => f.id === recording.folder_id) || null;
};
const getFolderById = (folderId) => {
if (!folderId || !availableFolders.value) return null;
// Use == for loose equality to handle string/number type mismatch (e.g., from localStorage)
return availableFolders.value.find(f => f.id == folderId) || null;
};
const getFolderColor = (folderId) => {
const folder = getFolderById(folderId);
return folder?.color || '#10B981';
};
const getFolderName = (folderId) => {
const folder = getFolderById(folderId);
return folder?.name || 'Folder';
};
const getAvailableFoldersForRecording = () => {
if (!availableFolders.value) return [];
return availableFolders.value;
};
// Methods
const assignFolderToRecording = async (recordingId, folderId) => {
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/recordings/${recordingId}/folder`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ folder_id: folderId || null })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update folder');
}
const updatedRecording = await response.json();
// Update local recording data
const recordingInList = recordings.value.find(r => r.id === recordingId);
if (recordingInList) {
recordingInList.folder_id = updatedRecording.folder_id;
recordingInList.folder = updatedRecording.folder;
}
// Update selectedRecording if it matches
if (selectedRecording.value && selectedRecording.value.id === recordingId) {
selectedRecording.value.folder_id = updatedRecording.folder_id;
selectedRecording.value.folder = updatedRecording.folder;
}
// Update folder recording counts
if (availableFolders.value) {
availableFolders.value.forEach(f => {
const count = recordings.value.filter(r => r.folder_id === f.id).length;
f.recording_count = count;
});
}
if (folderId) {
const folder = availableFolders.value?.find(f => f.id === folderId);
showToast(`Moved to folder "${folder?.name || 'Unknown'}"`, 'fa-folder', 2000, 'success');
} else {
showToast('Removed from folder', 'fa-folder-minus', 2000, 'success');
}
return updatedRecording;
} catch (error) {
console.error('Error updating folder:', error);
setGlobalError(`Failed to update folder: ${error.message}`);
return null;
}
};
const removeRecordingFromFolder = async (recordingId) => {
return assignFolderToRecording(recordingId, null);
};
const bulkAssignFolder = async (recordingIds, folderId) => {
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch('/api/recordings/bulk/folder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
recording_ids: recordingIds,
folder_id: folderId || null
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update folders');
}
const result = await response.json();
// Update local recording data
recordingIds.forEach(id => {
const recording = recordings.value.find(r => r.id === id);
if (recording) {
recording.folder_id = folderId || null;
recording.folder = folderId ? availableFolders.value?.find(f => f.id === folderId) : null;
}
});
// Update folder recording counts
if (availableFolders.value) {
availableFolders.value.forEach(f => {
const count = recordings.value.filter(r => r.folder_id === f.id).length;
f.recording_count = count;
});
}
if (folderId) {
const folder = availableFolders.value?.find(f => f.id === folderId);
showToast(`${result.updated_count} recording(s) moved to "${folder?.name || 'Unknown'}"`, 'fa-folder', 2000, 'success');
} else {
showToast(`${result.updated_count} recording(s) removed from folder`, 'fa-folder-minus', 2000, 'success');
}
return result;
} catch (error) {
console.error('Error bulk updating folders:', error);
setGlobalError(`Failed to update folders: ${error.message}`);
return null;
}
};
return {
// Methods
getRecordingFolder,
getFolderById,
getFolderColor,
getFolderName,
getAvailableFoldersForRecording,
assignFolderToRecording,
removeRecordingFromFolder,
bulkAssignFolder
};
}

View File

@@ -0,0 +1,33 @@
/**
* Composables module exports
*
* Each composable encapsulates related functionality:
* - recordings: Loading, selecting, filtering recordings
* - upload: File upload queue management
* - audio: Microphone/system audio recording
* - ui: Dark mode, color schemes, sidebar
* - transcription: Transcription editing (ASR editor, text editor)
* - speakers: Speaker identification and management
* - reprocess: Reprocessing transcription/summary
* - sharing: Public/internal sharing
* - modals: Modal dialog management
* - chat: AI chat functionality
* - pwa: PWA features (install prompt, notifications, badging, media session)
* - tokens: API token management
*/
export { useRecordings } from './recordings.js';
export { useUpload } from './upload.js';
export { useAudio } from './audio.js';
export { useUI } from './ui.js';
export { useModals } from './modals.js';
export { useSharing } from './sharing.js';
export { useReprocess } from './reprocess.js';
export { useTranscription } from './transcription.js';
export { useSpeakers } from './speakers.js';
export { useChat } from './chat.js';
export { usePWA } from './pwa.js';
export { useTokens } from './tokens.js';
export { useBulkSelection } from './bulk-selection.js';
export { useBulkOperations } from './bulk-operations.js';
export { useFolders } from './folders.js';

View File

@@ -0,0 +1,659 @@
/**
* Modal management composable
* Handles opening, closing, and saving modal dialogs
*/
export function useModals(state, utils) {
const {
showEditModal, showDeleteModal, showEditTagsModal,
showReprocessModal, showResetModal, showShareModal,
showSharesListModal, showTextEditorModal, showAsrEditorModal,
showEditSpeakersModal, showAddSpeakerModal, showEditTextModal,
showShareDeleteModal, showUnifiedShareModal, showColorSchemeModal,
showSystemAudioHelpModal, editingRecording, recordingToDelete, recordingToReset,
selectedRecording, recordings, selectedNewTagId, tagSearchFilter,
availableTags, currentView, totalRecordings, toasts, uploadQueue, allJobs,
// DateTime picker state
showDateTimePicker, pickerMonth, pickerYear, pickerHour, pickerMinute,
pickerAmPm, pickerSelectedDate, dateTimePickerTarget, dateTimePickerCallback
} = state;
const { showToast, setGlobalError } = utils;
const { computed } = Vue;
// =========================================
// Edit Recording Modal
// =========================================
const openEditModal = (recording) => {
editingRecording.value = { ...recording };
showEditModal.value = true;
};
const cancelEdit = () => {
showEditModal.value = false;
editingRecording.value = null;
};
const saveEdit = async () => {
if (!editingRecording.value) return;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/recordings/${editingRecording.value.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
title: editingRecording.value.title,
participants: editingRecording.value.participants,
meeting_date: editingRecording.value.meeting_date,
notes: editingRecording.value.notes
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to save changes');
// Update local data
const index = recordings.value.findIndex(r => r.id === editingRecording.value.id);
if (index !== -1) {
recordings.value[index] = { ...recordings.value[index], ...editingRecording.value };
}
if (selectedRecording.value && selectedRecording.value.id === editingRecording.value.id) {
selectedRecording.value = { ...selectedRecording.value, ...editingRecording.value };
}
showToast('Recording updated!', 'fa-check-circle');
showEditModal.value = false;
editingRecording.value = null;
} catch (error) {
setGlobalError(`Failed to save changes: ${error.message}`);
}
};
// =========================================
// Delete Recording Modal
// =========================================
const confirmDelete = (recording) => {
recordingToDelete.value = recording;
showDeleteModal.value = true;
};
const cancelDelete = () => {
showDeleteModal.value = false;
recordingToDelete.value = null;
};
const deleteRecording = async () => {
if (!recordingToDelete.value) return;
const deletedId = recordingToDelete.value.id;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/recording/${deletedId}`, {
method: 'DELETE',
headers: { 'X-CSRFToken': csrfToken }
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to delete recording');
// Remove from recordings list
recordings.value = recordings.value.filter(r => r.id !== deletedId);
totalRecordings.value--;
// Remove from upload queue if present (frontend tracking)
if (uploadQueue?.value) {
uploadQueue.value = uploadQueue.value.filter(item => item.recordingId !== deletedId);
}
// Remove from backend job queue if present (backend processing tracking)
// This is critical - without this, deleted recordings remain in processing queue
if (allJobs?.value) {
allJobs.value = allJobs.value.filter(job => job.recording_id !== deletedId);
}
// Clear selected recording if it's the one being deleted
if (selectedRecording.value?.id === deletedId) {
selectedRecording.value = null;
currentView.value = 'upload';
}
showToast('Recording deleted.', 'fa-trash');
showDeleteModal.value = false;
recordingToDelete.value = null;
} catch (error) {
setGlobalError(`Failed to delete recording: ${error.message}`);
}
};
// =========================================
// Archive Recording
// =========================================
const archiveRecording = async (recording) => {
if (!recording) return;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/recordings/${recording.id}/archive`, {
method: 'POST',
headers: { 'X-CSRFToken': csrfToken }
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to archive recording');
recording.is_archived = true;
recording.audio_deleted_at = data.audio_deleted_at;
// Update in recordings list
const index = recordings.value.findIndex(r => r.id === recording.id);
if (index !== -1) {
recordings.value[index].is_archived = true;
recordings.value[index].audio_deleted_at = data.audio_deleted_at;
}
showToast('Recording archived (audio deleted)', 'fa-archive');
} catch (error) {
setGlobalError(`Failed to archive recording: ${error.message}`);
}
};
// =========================================
// Edit Tags Modal
// =========================================
const openEditTagsModal = () => {
selectedNewTagId.value = '';
tagSearchFilter.value = '';
showEditTagsModal.value = true;
};
const closeEditTagsModal = () => {
showEditTagsModal.value = false;
};
const addTagToRecording = async (tagId) => {
if (!selectedRecording.value || !tagId) return;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/recordings/${selectedRecording.value.id}/tags`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ tag_id: tagId })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to add tag');
// Find the tag object
const tag = availableTags.value.find(t => t.id === tagId);
if (tag) {
if (!selectedRecording.value.tags) {
selectedRecording.value.tags = [];
}
selectedRecording.value.tags.push(tag);
}
// Update in recordings list
const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id);
if (index !== -1 && tag) {
if (!recordings.value[index].tags) {
recordings.value[index].tags = [];
}
recordings.value[index].tags.push(tag);
}
selectedNewTagId.value = '';
showToast('Tag added!', 'fa-tag');
} catch (error) {
setGlobalError(`Failed to add tag: ${error.message}`);
}
};
const removeTagFromRecording = async (tagId) => {
if (!selectedRecording.value || !tagId) return;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/recordings/${selectedRecording.value.id}/tags/${tagId}`, {
method: 'DELETE',
headers: { 'X-CSRFToken': csrfToken }
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to remove tag');
// Remove from selected recording
if (selectedRecording.value.tags) {
selectedRecording.value.tags = selectedRecording.value.tags.filter(t => t.id !== tagId);
}
// Update in recordings list
const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id);
if (index !== -1 && recordings.value[index].tags) {
recordings.value[index].tags = recordings.value[index].tags.filter(t => t.id !== tagId);
}
showToast('Tag removed!', 'fa-tag');
} catch (error) {
setGlobalError(`Failed to remove tag: ${error.message}`);
}
};
// =========================================
// Reset Modal
// =========================================
const openResetModal = (recording) => {
recordingToReset.value = recording;
showResetModal.value = true;
};
const cancelReset = () => {
showResetModal.value = false;
recordingToReset.value = null;
};
const resetRecording = async () => {
if (!recordingToReset.value) return;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/recording/${recordingToReset.value.id}/reset_status`, {
method: 'POST',
headers: { 'X-CSRFToken': csrfToken }
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to reset recording');
// Update recording status
const index = recordings.value.findIndex(r => r.id === recordingToReset.value.id);
if (index !== -1) {
recordings.value[index].status = 'PENDING';
recordings.value[index].transcription = '';
recordings.value[index].summary = '';
}
if (selectedRecording.value?.id === recordingToReset.value.id) {
selectedRecording.value.status = 'PENDING';
selectedRecording.value.transcription = '';
selectedRecording.value.summary = '';
}
showToast('Recording reset for reprocessing.', 'fa-redo');
showResetModal.value = false;
recordingToReset.value = null;
} catch (error) {
setGlobalError(`Failed to reset recording: ${error.message}`);
}
};
// =========================================
// System Audio Help Modal
// =========================================
const openSystemAudioHelpModal = () => {
showSystemAudioHelpModal.value = true;
};
const closeSystemAudioHelpModal = () => {
showSystemAudioHelpModal.value = false;
};
// =========================================
// Toast Management
// =========================================
const dismissToast = (id) => {
toasts.value = toasts.value.filter(t => t.id !== id);
};
// Aliases for template compatibility
const editRecording = openEditModal;
const editRecordingTags = openEditTagsModal;
// =========================================
// DateTime Picker
// =========================================
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
const dayNames = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
// Generate available years (10 years before and after current year)
const availableYears = computed(() => {
const currentYear = new Date().getFullYear();
const years = [];
for (let y = currentYear - 10; y <= currentYear + 10; y++) {
years.push(y);
}
return years;
});
// Generate hours for 12-hour format
const hours12 = computed(() => {
const hours = [];
for (let h = 1; h <= 12; h++) {
hours.push({ value: h, label: h.toString() });
}
return hours;
});
// Generate minutes
const minutes = computed(() => {
const mins = [];
for (let m = 0; m < 60; m++) {
mins.push(m);
}
return mins;
});
// Generate calendar days for current month view
const calendarDays = computed(() => {
const days = [];
const year = pickerYear.value;
const month = pickerMonth.value;
// First day of the month
const firstDay = new Date(year, month, 1);
const startingDay = firstDay.getDay();
// Last day of the month
const lastDay = new Date(year, month + 1, 0);
const totalDays = lastDay.getDate();
// Previous month days to fill the grid
const prevMonthLastDay = new Date(year, month, 0).getDate();
for (let i = startingDay - 1; i >= 0; i--) {
days.push({
day: prevMonthLastDay - i,
date: new Date(year, month - 1, prevMonthLastDay - i),
inMonth: false,
isToday: false,
isSelected: false
});
}
// Current month days
const today = new Date();
for (let d = 1; d <= totalDays; d++) {
const date = new Date(year, month, d);
const isToday = date.toDateString() === today.toDateString();
const isSelected = pickerSelectedDate.value &&
date.toDateString() === pickerSelectedDate.value.toDateString();
days.push({
day: d,
date: date,
inMonth: true,
isToday: isToday,
isSelected: isSelected
});
}
// Next month days to fill the grid (6 rows * 7 days = 42 total)
const remainingDays = 42 - days.length;
for (let d = 1; d <= remainingDays; d++) {
days.push({
day: d,
date: new Date(year, month + 1, d),
inMonth: false,
isToday: false,
isSelected: false
});
}
return days;
});
const openDateTimePicker = (target, currentValue, callback) => {
dateTimePickerTarget.value = target;
dateTimePickerCallback.value = callback;
// Parse current value if exists
if (currentValue) {
const date = new Date(currentValue);
if (!isNaN(date.getTime())) {
pickerSelectedDate.value = date;
pickerMonth.value = date.getMonth();
pickerYear.value = date.getFullYear();
let hours = date.getHours();
const ampm = hours >= 12 ? 'PM' : 'AM';
hours = hours % 12;
hours = hours === 0 ? 12 : hours;
pickerHour.value = hours;
pickerMinute.value = date.getMinutes();
pickerAmPm.value = ampm;
} else {
setPickerToNow();
}
} else {
setPickerToNow();
}
showDateTimePicker.value = true;
};
const setPickerToNow = () => {
const now = new Date();
pickerSelectedDate.value = now;
pickerMonth.value = now.getMonth();
pickerYear.value = now.getFullYear();
let hours = now.getHours();
const ampm = hours >= 12 ? 'PM' : 'AM';
hours = hours % 12;
hours = hours === 0 ? 12 : hours;
pickerHour.value = hours;
pickerMinute.value = now.getMinutes();
pickerAmPm.value = ampm;
};
const closeDateTimePicker = () => {
showDateTimePicker.value = false;
dateTimePickerTarget.value = null;
dateTimePickerCallback.value = null;
};
const prevMonth = () => {
if (pickerMonth.value === 0) {
pickerMonth.value = 11;
pickerYear.value--;
} else {
pickerMonth.value--;
}
};
const nextMonth = () => {
if (pickerMonth.value === 11) {
pickerMonth.value = 0;
pickerYear.value++;
} else {
pickerMonth.value++;
}
};
const updatePickerView = () => {
// Called when month/year dropdowns change
// The computed calendarDays will automatically update
};
const selectDate = (date) => {
pickerSelectedDate.value = date;
};
const setToNow = () => {
setPickerToNow();
};
const setToToday = () => {
const today = new Date();
pickerSelectedDate.value = today;
pickerMonth.value = today.getMonth();
pickerYear.value = today.getFullYear();
// Keep the current time
};
const clearDateTime = () => {
pickerSelectedDate.value = null;
const now = new Date();
pickerMonth.value = now.getMonth();
pickerYear.value = now.getFullYear();
pickerHour.value = 12;
pickerMinute.value = 0;
pickerAmPm.value = 'PM';
};
const formatPickerPreview = () => {
if (!pickerSelectedDate.value) return '';
const date = pickerSelectedDate.value;
const monthName = monthNames[date.getMonth()];
const day = date.getDate();
const year = date.getFullYear();
const hour = pickerHour.value;
const minute = pickerMinute.value.toString().padStart(2, '0');
const ampm = pickerAmPm.value;
return `${monthName} ${day}, ${year} at ${hour}:${minute} ${ampm}`;
};
const applyDateTime = () => {
if (!pickerSelectedDate.value) {
// If no date selected, just close
closeDateTimePicker();
return;
}
// Build the full datetime
const date = new Date(pickerSelectedDate.value);
let hours = pickerHour.value;
// Convert 12-hour to 24-hour
if (pickerAmPm.value === 'AM') {
hours = hours === 12 ? 0 : hours;
} else {
hours = hours === 12 ? 12 : hours + 12;
}
date.setHours(hours);
date.setMinutes(pickerMinute.value);
date.setSeconds(0);
date.setMilliseconds(0);
// Format as ISO string for storage (YYYY-MM-DDTHH:mm:ss)
const isoString = date.toISOString().slice(0, 19);
// Call the callback with the result
if (dateTimePickerCallback.value) {
dateTimePickerCallback.value(isoString, date);
}
closeDateTimePicker();
};
// Helper to open datetime picker for meeting date
const openMeetingDatePicker = () => {
if (!selectedRecording.value) return;
openDateTimePicker(
'meeting_date',
selectedRecording.value.meeting_date,
(isoString) => {
selectedRecording.value.meeting_date = isoString;
// Auto-save the change
saveInlineMeetingDate();
}
);
};
// Save meeting date inline (similar to other inline edits)
const saveInlineMeetingDate = async () => {
if (!selectedRecording.value) return;
const fullPayload = {
id: selectedRecording.value.id,
title: selectedRecording.value.title,
participants: selectedRecording.value.participants,
notes: selectedRecording.value.notes,
summary: selectedRecording.value.summary,
meeting_date: selectedRecording.value.meeting_date
};
try {
const csrfTokenValue = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch('/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfTokenValue
},
body: JSON.stringify(fullPayload)
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to save meeting date');
showToast('Meeting date updated!', 'fa-calendar-check');
} catch (error) {
showToast(`Failed to save: ${error.message}`, 'fa-exclamation-circle', 3000, 'error');
}
};
return {
// Edit modal
openEditModal,
editRecording,
cancelEdit,
saveEdit,
// Delete modal
confirmDelete,
cancelDelete,
deleteRecording,
// Archive
archiveRecording,
// Tags modal
openEditTagsModal,
editRecordingTags,
closeEditTagsModal,
addTagToRecording,
removeTagFromRecording,
// Reset modal
openResetModal,
cancelReset,
resetRecording,
// System audio help
openSystemAudioHelpModal,
closeSystemAudioHelpModal,
// Toast
dismissToast,
// DateTime picker
monthNames,
dayNames,
availableYears,
hours12,
minutes,
calendarDays,
openDateTimePicker,
closeDateTimePicker,
prevMonth,
nextMonth,
updatePickerView,
selectDate,
setToNow,
setToToday,
clearDateTime,
formatPickerPreview,
applyDateTime,
openMeetingDatePicker
};
}

View File

@@ -0,0 +1,518 @@
/**
* PWA Features Composable
* Handles install prompt, push notifications, badging, and other PWA APIs
*/
import { isPushEnabled, getPublicKey, urlBase64ToUint8Array } from '../../config/push-config.js';
export function usePWA(state, utils) {
const {
deferredInstallPrompt,
showInstallButton,
isPWAInstalled,
notificationPermission,
pushSubscription,
appBadgeCount
} = state;
const { showToast } = utils;
// --- Install Prompt ---
/**
* Handle beforeinstallprompt event
* This event is fired when the browser detects the app can be installed
*/
const handleBeforeInstallPrompt = (e) => {
console.log('[PWA] beforeinstallprompt event fired');
// Prevent the mini-infobar from appearing on mobile
e.preventDefault();
// Stash the event so it can be triggered later
deferredInstallPrompt.value = e;
// Show our custom install button
showInstallButton.value = true;
};
/**
* Prompt user to install the PWA
*/
const promptInstall = async () => {
if (!deferredInstallPrompt.value) {
console.log('[PWA] No deferred install prompt available');
return;
}
// Show the install prompt
deferredInstallPrompt.value.prompt();
// Wait for the user's response
const { outcome } = await deferredInstallPrompt.value.userChoice;
console.log(`[PWA] User response to install prompt: ${outcome}`);
if (outcome === 'accepted') {
showToast('Installing Speakr...', 'success');
}
// Clear the deferred prompt since it can only be used once
deferredInstallPrompt.value = null;
showInstallButton.value = false;
};
/**
* Check if app is already installed
*/
const checkIfInstalled = () => {
// Check if running in standalone mode (installed PWA)
if (window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true) {
isPWAInstalled.value = true;
showInstallButton.value = false;
console.log('[PWA] App is installed and running in standalone mode');
}
};
/**
* Handle appinstalled event
*/
const handleAppInstalled = () => {
console.log('[PWA] App was installed');
isPWAInstalled.value = true;
showInstallButton.value = false;
showToast('Speakr installed successfully!', 'success');
};
// --- Push Notifications ---
/**
* Request notification permission
*/
const requestNotificationPermission = async () => {
if (!('Notification' in window)) {
console.warn('[PWA] This browser does not support notifications');
return false;
}
try {
const permission = await Notification.requestPermission();
notificationPermission.value = permission;
console.log(`[PWA] Notification permission: ${permission}`);
if (permission === 'granted') {
showToast('Notifications enabled', 'success');
return true;
} else if (permission === 'denied') {
showToast('Notification permission denied', 'error');
return false;
}
} catch (error) {
console.error('[PWA] Error requesting notification permission:', error);
return false;
}
};
/**
* Subscribe to push notifications
*/
const subscribeToPushNotifications = async () => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.warn('[PWA] Push notifications not supported');
showToast('Push notifications not supported in this browser', 'warning');
return null;
}
// Check if push is enabled on server
const enabled = await isPushEnabled();
if (!enabled) {
console.warn('[PWA] Push notifications not configured on server');
showToast('Push notifications not available. Install pywebpush on server.', 'warning');
return null;
}
// Get public key from server
const publicKey = await getPublicKey();
if (!publicKey) {
console.error('[PWA] Failed to get VAPID public key from server');
showToast('Failed to configure push notifications', 'error');
return null;
}
try {
const registration = await navigator.serviceWorker.ready;
// Check if already subscribed
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
// Subscribe to push notifications
console.log('[PWA] Creating new push subscription...');
const applicationServerKey = urlBase64ToUint8Array(publicKey);
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey
});
// Send subscription to server
const success = await sendSubscriptionToServer(subscription);
if (success) {
pushSubscription.value = subscription;
showToast('Push notifications enabled', 'success');
console.log('[PWA] Push subscription successful:', subscription);
} else {
console.warn('[PWA] Failed to save subscription on server');
showToast('Failed to enable push notifications', 'error');
return null;
}
} else {
pushSubscription.value = subscription;
console.log('[PWA] Already subscribed to push notifications');
}
return subscription;
} catch (error) {
console.error('[PWA] Failed to subscribe to push notifications:', error);
if (error.name === 'NotAllowedError') {
showToast('Push notification permission denied', 'error');
} else {
showToast('Failed to enable push notifications', 'error');
}
return null;
}
};
/**
* Send subscription to server for storage
*/
const sendSubscriptionToServer = async (subscription) => {
try {
const response = await fetch('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(subscription),
credentials: 'same-origin'
});
if (!response.ok) {
console.error('[PWA] Server rejected push subscription:', response.status);
return false;
}
const data = await response.json();
console.log('[PWA] Subscription saved on server:', data);
return true;
} catch (error) {
console.error('[PWA] Failed to send subscription to server:', error);
return false;
}
};
/**
* Unsubscribe from push notifications
*/
const unsubscribeFromPushNotifications = async () => {
if (!pushSubscription.value) {
console.log('[PWA] No active push subscription to unsubscribe');
return true;
}
try {
// Unsubscribe on client
await pushSubscription.value.unsubscribe();
// Remove from server
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(pushSubscription.value),
credentials: 'same-origin'
});
pushSubscription.value = null;
showToast('Push notifications disabled', 'info');
console.log('[PWA] Unsubscribed from push notifications');
return true;
} catch (error) {
console.error('[PWA] Failed to unsubscribe from push notifications:', error);
showToast('Failed to disable push notifications', 'error');
return false;
}
};
/**
* Show a local notification
*/
const showNotification = async (title, options = {}) => {
if (!('Notification' in window)) {
console.warn('[PWA] Notifications not supported');
return;
}
if (Notification.permission !== 'granted') {
const granted = await requestNotificationPermission();
if (!granted) return;
}
try {
const registration = await navigator.serviceWorker.ready;
const defaultOptions = {
icon: '/static/img/icon-192x192.png',
badge: '/static/img/icon-192x192.png',
vibrate: [200, 100, 200],
tag: 'speakr-notification',
renotify: true,
...options
};
await registration.showNotification(title, defaultOptions);
} catch (error) {
console.error('[PWA] Error showing notification:', error);
}
};
// --- Badging API ---
/**
* Set app badge count
*/
const setAppBadge = async (count) => {
if (!('setAppBadge' in navigator)) {
console.log('[PWA] Badging API not supported');
return;
}
try {
if (count > 0) {
await navigator.setAppBadge(count);
appBadgeCount.value = count;
console.log(`[PWA] App badge set to ${count}`);
} else {
await navigator.clearAppBadge();
appBadgeCount.value = 0;
console.log('[PWA] App badge cleared');
}
} catch (error) {
console.error('[PWA] Error setting app badge:', error);
}
};
/**
* Clear app badge
*/
const clearAppBadge = async () => {
await setAppBadge(0);
};
/**
* Update badge with unread count
*/
const updateBadgeCount = async (audioFiles) => {
if (!audioFiles || !Array.isArray(audioFiles)) return;
// Count unread recordings (those still in inbox)
const unreadCount = audioFiles.filter(file => file.in_inbox).length;
await setAppBadge(unreadCount);
};
// --- Media Session API ---
/**
* Set up Media Session for audio playback control
* @param {Object} metadata - Track metadata { title, artist, album, artwork }
* @param {Object} handlers - Action handlers { play, pause, seekbackward, seekforward, previoustrack, nexttrack }
*/
const setupMediaSession = (metadata, handlers = {}) => {
if (!('mediaSession' in navigator)) {
console.log('[PWA] Media Session API not supported');
return false;
}
try {
// Set metadata
if (metadata) {
navigator.mediaSession.metadata = new MediaMetadata({
title: metadata.title || 'Untitled Recording',
artist: metadata.artist || 'Speakr',
album: metadata.album || 'Recordings',
artwork: metadata.artwork || [
{ src: '/static/img/icon-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: '/static/img/icon-512x512.png', sizes: '512x512', type: 'image/png' }
]
});
currentMediaMetadata.value = metadata;
}
// Set action handlers
const actions = ['play', 'pause', 'seekbackward', 'seekforward', 'previoustrack', 'nexttrack', 'stop'];
actions.forEach(action => {
if (handlers[action]) {
try {
navigator.mediaSession.setActionHandler(action, handlers[action]);
} catch (error) {
console.warn(`[PWA] The ${action} action is not supported`);
}
}
});
// Set position state if provided
if (handlers.setPositionState) {
try {
navigator.mediaSession.setPositionState(handlers.setPositionState);
} catch (error) {
console.warn('[PWA] setPositionState not supported:', error);
}
}
isMediaSessionActive.value = true;
console.log('[PWA] Media Session configured successfully');
return true;
} catch (error) {
console.error('[PWA] Error setting up Media Session:', error);
return false;
}
};
/**
* Update Media Session position state
* @param {Object} state - { duration, playbackRate, position }
*/
const updateMediaSessionPosition = (state) => {
if (!('mediaSession' in navigator) || !isMediaSessionActive.value) return;
try {
navigator.mediaSession.setPositionState({
duration: state.duration || 0,
playbackRate: state.playbackRate || 1.0,
position: state.position || 0
});
} catch (error) {
console.warn('[PWA] Error updating position state:', error);
}
};
/**
* Update Media Session playback state
* @param {string} state - 'playing' | 'paused' | 'none'
*/
const updateMediaSessionPlaybackState = (state) => {
if (!('mediaSession' in navigator) || !isMediaSessionActive.value) return;
try {
navigator.mediaSession.playbackState = state;
} catch (error) {
console.warn('[PWA] Error updating playback state:', error);
}
};
/**
* Clear Media Session
*/
const clearMediaSession = () => {
if (!('mediaSession' in navigator)) return;
try {
navigator.mediaSession.metadata = null;
const actions = ['play', 'pause', 'seekbackward', 'seekforward', 'previoustrack', 'nexttrack', 'stop'];
actions.forEach(action => {
try {
navigator.mediaSession.setActionHandler(action, null);
} catch (e) { /* ignore */ }
});
isMediaSessionActive.value = false;
currentMediaMetadata.value = null;
console.log('[PWA] Media Session cleared');
} catch (error) {
console.error('[PWA] Error clearing Media Session:', error);
}
};
// --- Background Sync ---
/**
* Register background sync for upload retry
*/
const registerBackgroundSync = async (tag = 'sync-uploads') => {
if (!('serviceWorker' in navigator) || !('sync' in ServiceWorkerRegistration.prototype)) {
console.log('[PWA] Background sync not supported');
return false;
}
try {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register(tag);
console.log(`[PWA] Background sync registered: ${tag}`);
return true;
} catch (error) {
console.error('[PWA] Failed to register background sync:', error);
return false;
}
};
/**
* Initialize PWA features
*/
const initPWA = () => {
// Check if already installed
checkIfInstalled();
// Listen for beforeinstallprompt event
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
// Listen for appinstalled event
window.addEventListener('appinstalled', handleAppInstalled);
// Check notification permission status
if ('Notification' in window) {
notificationPermission.value = Notification.permission;
}
console.log('[PWA] PWA features initialized');
};
/**
* Cleanup PWA event listeners
*/
const cleanupPWA = () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.removeEventListener('appinstalled', handleAppInstalled);
};
return {
// Install prompt
promptInstall,
checkIfInstalled,
// Notifications
requestNotificationPermission,
subscribeToPushNotifications,
unsubscribeFromPushNotifications,
showNotification,
// Badging
setAppBadge,
clearAppBadge,
updateBadgeCount,
// Media Session
setupMediaSession,
updateMediaSessionPosition,
updateMediaSessionPlaybackState,
clearMediaSession,
// Background sync
registerBackgroundSync,
// Initialization
initPWA,
cleanupPWA
};
}

View File

@@ -0,0 +1,482 @@
/**
* Recording management composable
* Handles loading, selecting, filtering, and managing recordings
*/
import * as IncognitoStorage from '../db/incognito-storage.js';
export function useRecordings(state, utils, reprocessComposable) {
const {
recordings, selectedRecording, isLoadingRecordings, isLoadingMore,
currentPage, perPage, totalRecordings, totalPages, hasNextPage, hasPrevPage,
showSharedWithMe, showArchivedRecordings, searchQuery, searchDebounceTimer,
filterTags, filterSpeakers, filterDatePreset, filterDateRange, filterTextQuery,
filterStarred, filterInbox, filterFolder, sortBy,
availableTags, availableSpeakers, availableFolders, selectedTagIds, uploadLanguage, uploadMinSpeakers, uploadMaxSpeakers, uploadHotwords, uploadInitialPrompt,
useAsrEndpoint, connectorSupportsDiarization, globalError, uploadQueue, isProcessingActive, currentView,
isMobileScreen, isSidebarCollapsed, isRecording, audioBlobURL,
speakerColorMap,
// Incognito mode
incognitoRecording
} = state;
const { setGlobalError, showToast } = utils;
// Load recordings from API
const loadRecordings = async (page = 1, append = false, searchQueryParam = '') => {
globalError.value = null;
if (!append) {
isLoadingRecordings.value = true;
} else {
isLoadingMore.value = true;
}
try {
const endpoint = '/api/recordings';
const params = new URLSearchParams({
page: page.toString(),
per_page: perPage.value.toString()
});
if (searchQueryParam.trim()) {
params.set('q', searchQueryParam.trim());
}
// Add sort parameter
if (sortBy.value) {
params.set('sort_by', sortBy.value);
}
// Add archived/shared/starred/inbox filters as query params (ANDed with other filters)
if (showArchivedRecordings.value) {
params.set('archived', 'true');
}
if (showSharedWithMe.value) {
params.set('shared', 'true');
}
if (filterStarred.value) {
params.set('starred', 'true');
}
if (filterInbox.value) {
params.set('inbox', 'true');
}
// Add folder filter
if (filterFolder && filterFolder.value) {
params.set('folder', filterFolder.value);
}
const response = await fetch(`${endpoint}?${params}`);
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to load recordings');
const recordingsList = data.recordings;
const pagination = data.pagination;
if (!Array.isArray(recordingsList)) {
console.error('Unexpected response format:', data);
throw new Error('Invalid response format from server');
}
if (pagination) {
currentPage.value = pagination.page;
totalRecordings.value = pagination.total;
totalPages.value = pagination.total_pages;
hasNextPage.value = pagination.has_next;
hasPrevPage.value = pagination.has_prev;
} else {
currentPage.value = 1;
totalRecordings.value = recordingsList.length;
totalPages.value = 1;
hasNextPage.value = false;
hasPrevPage.value = false;
}
if (append) {
recordings.value = [...recordings.value, ...recordingsList];
} else {
recordings.value = recordingsList;
const lastRecordingId = localStorage.getItem('lastSelectedRecordingId');
if (lastRecordingId && recordingsList.length > 0) {
const recordingToSelect = recordingsList.find(r => r.id == lastRecordingId);
if (recordingToSelect) {
selectRecording(recordingToSelect);
}
}
}
// NOTE: Removed auto-queueing of incomplete recordings.
// Backend processing recordings are now shown via backendProcessingRecordings
// computed property, which filters recordings by status (PENDING, PROCESSING, etc.)
// The job queue system (ProcessingJob) handles background processing.
} catch (error) {
console.error('Load Recordings Error:', error);
setGlobalError(`Failed to load recordings: ${error.message}`);
if (!append) {
recordings.value = [];
}
} finally {
isLoadingRecordings.value = false;
isLoadingMore.value = false;
}
};
const loadMoreRecordings = async () => {
if (!hasNextPage.value || isLoadingMore.value) return;
await loadRecordings(currentPage.value + 1, true, searchQuery.value);
};
const performSearch = async (query = '') => {
currentPage.value = 1;
await loadRecordings(1, false, query);
};
const debouncedSearch = (query) => {
if (searchDebounceTimer.value) {
clearTimeout(searchDebounceTimer.value);
}
searchDebounceTimer.value = setTimeout(() => {
performSearch(query);
}, 300);
};
const loadTags = async () => {
try {
const response = await fetch('/api/tags');
if (response.ok) {
availableTags.value = await response.json();
} else {
availableTags.value = [];
}
} catch (error) {
console.warn('Error loading tags:', error);
availableTags.value = [];
}
};
const loadFolders = async () => {
try {
const response = await fetch('/api/folders');
if (response.ok) {
availableFolders.value = await response.json();
} else {
availableFolders.value = [];
}
} catch (error) {
console.warn('Error loading folders:', error);
availableFolders.value = [];
}
};
const loadSpeakers = async () => {
try {
const response = await fetch('/speakers');
if (response.ok) {
availableSpeakers.value = await response.json();
} else {
availableSpeakers.value = [];
}
} catch (error) {
console.warn('Error loading speakers:', error);
availableSpeakers.value = [];
}
};
const selectRecording = async (recording) => {
if (hasUnsavedRecording()) {
if (!confirm('You have an unsaved recording. Are you sure you want to leave?')) {
return;
}
}
// Check if switching away from incognito recording to a regular recording
if (incognitoRecording && incognitoRecording.value &&
selectedRecording.value?.id === 'incognito' &&
recording?.id !== 'incognito') {
if (!confirm('Switching to another recording will discard your incognito recording. Continue?')) {
return;
}
// Clear incognito recording immediately - this is the "incognito" promise
IncognitoStorage.clearIncognitoRecording();
incognitoRecording.value = null;
}
// Also clear any orphaned incognito data when selecting a non-incognito recording
// This handles edge cases like page refresh where the above check doesn't trigger
if (recording?.id !== 'incognito' && IncognitoStorage.hasIncognitoRecording()) {
console.log('[Incognito] Clearing orphaned incognito data');
IncognitoStorage.clearIncognitoRecording();
if (incognitoRecording) {
incognitoRecording.value = null;
}
}
// Reset modal audio state when switching recordings
if (utils.resetModalAudioState) {
utils.resetModalAudioState();
}
// Clear speaker color map when switching recordings - new colors will be assigned on first render
if (speakerColorMap) {
speakerColorMap.value = {};
}
selectedRecording.value = recording;
if (recording && recording.id) {
localStorage.setItem('lastSelectedRecordingId', recording.id);
try {
const response = await fetch(`/api/recordings/${recording.id}`);
if (response.ok) {
const fullRecording = await response.json();
selectedRecording.value = fullRecording;
const index = recordings.value.findIndex(r => r.id === recording.id);
if (index !== -1) {
recordings.value[index] = fullRecording;
}
// Auto-start polling if recording is still processing or summarizing
if (['PROCESSING', 'SUMMARIZING'].includes(fullRecording.status)) {
console.log(`[AUTO-POLL] Recording ${fullRecording.id} is in ${fullRecording.status} state, starting auto-polling`);
if (reprocessComposable && reprocessComposable.startReprocessingPoll) {
reprocessComposable.startReprocessingPoll(fullRecording.id);
} else {
console.warn('[AUTO-POLL] reprocessComposable.startReprocessingPoll not available');
}
}
}
} catch (error) {
console.error('Error loading full recording:', error);
}
}
if (isMobileScreen.value) {
isSidebarCollapsed.value = true;
}
currentView.value = 'detail';
if (isRecording.value) {
// Don't interrupt recording
}
if (audioBlobURL.value) {
// Don't discard recorded audio
}
};
const hasUnsavedRecording = () => {
return isRecording.value || audioBlobURL.value;
};
const toggleInbox = async (recording) => {
if (!recording || !recording.id) return;
try {
const response = await fetch(`/recording/${recording.id}/toggle_inbox`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to toggle inbox status');
// Update the recording in the UI
recording.is_inbox = data.is_inbox;
// Update in the recordings list
const index = recordings.value.findIndex(r => r.id === recording.id);
if (index !== -1) {
recordings.value[index].is_inbox = data.is_inbox;
}
showToast(`Recording ${data.is_inbox ? 'moved to inbox' : 'marked as read'}`);
} catch (error) {
console.error('Toggle Inbox Error:', error);
setGlobalError(`Failed to toggle inbox status: ${error.message}`);
}
};
const toggleHighlight = async (recording) => {
if (!recording || !recording.id) return;
try {
const response = await fetch(`/recording/${recording.id}/toggle_highlight`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to toggle highlighted status');
// Update the recording in the UI
recording.is_highlighted = data.is_highlighted;
// Update in the recordings list
const index = recordings.value.findIndex(r => r.id === recording.id);
if (index !== -1) {
recordings.value[index].is_highlighted = data.is_highlighted;
}
showToast(`Recording ${data.is_highlighted ? 'highlighted' : 'unhighlighted'}`);
} catch (error) {
console.error('Toggle Highlight Error:', error);
setGlobalError(`Failed to toggle highlighted status: ${error.message}`);
}
};
const getRecordingTags = (recording) => {
if (!recording || !recording.tags) return [];
return recording.tags || [];
};
const getAvailableTagsForRecording = (recording) => {
if (!recording || !availableTags.value) return [];
const recordingTagIds = getRecordingTags(recording).map(tag => tag.id);
return availableTags.value.filter(tag => !recordingTagIds.includes(tag.id));
};
const filterByTag = (tag) => {
filterTags.value = [tag.id];
applyAdvancedFilters();
};
const buildSearchQuery = () => {
let query = [];
if (filterTextQuery.value.trim()) {
query.push(filterTextQuery.value.trim());
}
if (filterTags.value.length > 0) {
const tagNames = filterTags.value.map(tagId => {
const tag = availableTags.value.find(t => t.id === tagId);
return tag ? `tag:${tag.name.replace(/\s+/g, '_')}` : '';
}).filter(Boolean);
query.push(...tagNames);
}
if (filterSpeakers.value.length > 0) {
const speakerNames = filterSpeakers.value.map(name =>
`speaker:${name.replace(/\s+/g, '_')}`
);
query.push(...speakerNames);
}
if (filterDatePreset.value) {
query.push(`date:${filterDatePreset.value}`);
} else if (filterDateRange.value.start || filterDateRange.value.end) {
if (filterDateRange.value.start) {
query.push(`date_from:${filterDateRange.value.start}`);
}
if (filterDateRange.value.end) {
query.push(`date_to:${filterDateRange.value.end}`);
}
}
return query.join(' ');
};
const applyAdvancedFilters = () => {
searchQuery.value = buildSearchQuery();
};
const clearAllFilters = () => {
filterTags.value = [];
filterSpeakers.value = [];
filterDateRange.value = { start: '', end: '' };
filterDatePreset.value = '';
filterTextQuery.value = '';
filterStarred.value = false;
filterInbox.value = false;
// Note: filterFolder is NOT cleared here - it's a navigation element, not a filter
searchQuery.value = '';
};
const clearTagFilter = () => {
searchQuery.value = '';
clearAllFilters();
};
const addTagToSelection = (tagId) => {
if (!selectedTagIds.value.includes(tagId)) {
selectedTagIds.value.push(tagId);
applyTagDefaults();
}
};
const removeTagFromSelection = (tagId) => {
const index = selectedTagIds.value.indexOf(tagId);
if (index > -1) {
selectedTagIds.value.splice(index, 1);
applyTagDefaults();
}
};
const applyTagDefaults = () => {
const selectedTags = selectedTagIds.value.map(tagId =>
availableTags.value.find(tag => tag.id == tagId)
).filter(Boolean);
const firstTag = selectedTags[0];
if (firstTag && connectorSupportsDiarization.value) {
if (firstTag.default_language) {
uploadLanguage.value = firstTag.default_language;
}
if (firstTag.default_min_speakers) {
uploadMinSpeakers.value = firstTag.default_min_speakers;
}
if (firstTag.default_max_speakers) {
uploadMaxSpeakers.value = firstTag.default_max_speakers;
}
}
if (firstTag) {
if (firstTag.default_hotwords) {
uploadHotwords.value = firstTag.default_hotwords;
}
if (firstTag.default_initial_prompt) {
uploadInitialPrompt.value = firstTag.default_initial_prompt;
}
}
};
const pollInboxRecordings = async () => {
try {
const response = await fetch('/api/recordings/inbox-count');
if (response.ok) {
const data = await response.json();
// Update inbox count in UI if needed
}
} catch (error) {
// Silent fail for polling
}
};
return {
loadRecordings,
loadMoreRecordings,
performSearch,
debouncedSearch,
loadTags,
loadFolders,
loadSpeakers,
selectRecording,
hasUnsavedRecording,
toggleInbox,
toggleHighlight,
getRecordingTags,
getAvailableTagsForRecording,
filterByTag,
buildSearchQuery,
applyAdvancedFilters,
clearAllFilters,
clearTagFilter,
addTagToSelection,
removeTagFromSelection,
applyTagDefaults,
pollInboxRecordings
};
}

View File

@@ -0,0 +1,450 @@
/**
* Reprocessing composable
* Handles reprocessing transcription and summary
*/
import * as IncognitoStorage from '../db/incognito-storage.js';
export function useReprocess(state, utils) {
const { nextTick } = Vue;
const {
showReprocessModal, showResetModal, reprocessType,
reprocessRecording, recordingToReset, selectedRecording,
recordings, asrReprocessOptions, summaryReprocessPromptSource,
summaryReprocessSelectedTagId, summaryReprocessCustomPrompt,
availableTags, processingProgress, processingMessage,
currentlyProcessingFile, uploadQueue
} = state;
const { showToast, setGlobalError, onChatComplete } = utils;
// Store for active polling intervals
const reprocessingPolls = new Map();
// =========================================
// Reprocess Modal
// =========================================
const openReprocessModal = (type, recording = null) => {
reprocessType.value = type;
reprocessRecording.value = recording || selectedRecording.value;
showReprocessModal.value = true;
// Reset options
if (type === 'transcription') {
asrReprocessOptions.language = '';
asrReprocessOptions.min_speakers = '';
asrReprocessOptions.max_speakers = '';
} else {
summaryReprocessPromptSource.value = 'default';
summaryReprocessSelectedTagId.value = '';
summaryReprocessCustomPrompt.value = '';
}
};
const closeReprocessModal = () => {
showReprocessModal.value = false;
reprocessRecording.value = null;
reprocessType.value = null;
};
const confirmReprocess = openReprocessModal;
const cancelReprocess = closeReprocessModal;
// =========================================
// Reset Status
// =========================================
const confirmReset = (recording) => {
recordingToReset.value = recording;
showResetModal.value = true;
};
const cancelReset = () => {
showResetModal.value = false;
recordingToReset.value = null;
};
const executeReset = async () => {
if (!recordingToReset.value) return;
const recordingId = recordingToReset.value.id;
// Close the modal first
cancelReset();
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/recording/${recordingId}/reset_status`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to reset recording status');
// Update recording status in list
const index = recordings.value.findIndex(r => r.id === recordingId);
if (index !== -1) {
recordings.value[index].status = 'FAILED';
}
if (selectedRecording.value?.id === recordingId) {
selectedRecording.value.status = 'FAILED';
}
showToast('Recording status reset to FAILED', 'fa-undo');
} catch (error) {
setGlobalError(`Failed to reset status: ${error.message}`);
}
};
const executeReprocess = async () => {
if (!reprocessRecording.value || !reprocessType.value) return;
const recordingId = reprocessRecording.value.id;
const type = reprocessType.value;
closeReprocessModal();
if (type === 'transcription') {
await reprocessTranscription(
recordingId,
asrReprocessOptions.language,
asrReprocessOptions.min_speakers,
asrReprocessOptions.max_speakers
);
} else {
await reprocessSummary(
recordingId,
summaryReprocessPromptSource.value,
summaryReprocessSelectedTagId.value,
summaryReprocessCustomPrompt.value
);
}
};
// =========================================
// Transcription Reprocessing
// =========================================
const reprocessTranscription = async (recordingId, language, minSpeakers, maxSpeakers) => {
if (!recordingId) {
setGlobalError('No recording ID provided for reprocessing.');
return;
}
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const requestBody = {
language: language || '' // Always send language - empty string means auto-detect
};
if (minSpeakers && minSpeakers !== '') requestBody.min_speakers = parseInt(minSpeakers);
if (maxSpeakers && maxSpeakers !== '') requestBody.max_speakers = parseInt(maxSpeakers);
const response = await fetch(`/recording/${recordingId}/reprocess_transcription`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(requestBody)
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to start transcription reprocessing');
// Update recording status in list
const index = recordings.value.findIndex(r => r.id === recordingId);
if (index !== -1) {
recordings.value[index].status = 'PROCESSING';
}
if (selectedRecording.value?.id === recordingId) {
selectedRecording.value.status = 'PROCESSING';
}
showToast('Transcription reprocessing started', 'fa-sync-alt');
// Start polling for progress
startReprocessingPoll(recordingId);
} catch (error) {
setGlobalError(`Failed to start transcription reprocessing: ${error.message}`);
}
};
// =========================================
// Summary Reprocessing
// =========================================
const reprocessSummary = async (recordingId, promptSource, selectedTagId, customPrompt) => {
if (!recordingId) {
setGlobalError('No recording ID provided for reprocessing.');
return;
}
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const requestBody = { reprocess_summary: true };
if (promptSource === 'tag' && selectedTagId) {
const selectedTag = availableTags.value.find(t => t.id == selectedTagId);
if (selectedTag && selectedTag.custom_prompt) {
requestBody.custom_prompt = selectedTag.custom_prompt;
}
} else if (promptSource === 'custom' && customPrompt) {
requestBody.custom_prompt = customPrompt;
}
const response = await fetch(`/recording/${recordingId}/reprocess_summary`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(requestBody)
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to start summary reprocessing');
// Update recording status in list
const index = recordings.value.findIndex(r => r.id === recordingId);
if (index !== -1) {
recordings.value[index].status = 'SUMMARIZING';
}
if (selectedRecording.value?.id === recordingId) {
selectedRecording.value.status = 'SUMMARIZING';
}
showToast('Summary reprocessing started', 'fa-sync-alt');
// Start polling for progress
startReprocessingPoll(recordingId);
} catch (error) {
setGlobalError(`Failed to start summary reprocessing: ${error.message}`);
}
};
// =========================================
// Generate Summary
// =========================================
const generateSummary = async () => {
if (!selectedRecording.value) return;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
// Check if this is an incognito recording
if (selectedRecording.value.incognito === true) {
// Use incognito summary endpoint - generate synchronously
const response = await fetch('/api/recordings/incognito/summary', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
transcription: selectedRecording.value.transcription
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to generate summary');
// Update the incognito recording with the new summary
selectedRecording.value.summary = data.summary;
selectedRecording.value.summary_html = data.summary_html;
// Update sessionStorage
IncognitoStorage.updateIncognitoRecording({
summary: data.summary,
summary_html: data.summary_html
});
showToast('Summary generated', 'fa-file-alt');
return;
}
// Regular recording - use existing flow
const response = await fetch(`/recording/${selectedRecording.value.id}/generate_summary`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to start summary generation');
selectedRecording.value.status = 'SUMMARIZING';
const recordingInList = recordings.value.find(r => r.id === selectedRecording.value.id);
if (recordingInList) {
recordingInList.status = 'SUMMARIZING';
}
showToast('Summary generation started', 'fa-file-alt');
// Start polling for progress
startReprocessingPoll(selectedRecording.value.id);
} catch (error) {
setGlobalError(`Failed to generate summary: ${error.message}`);
}
};
// =========================================
// Progress Polling
// =========================================
const startReprocessingPoll = (recordingId) => {
// Stop existing poll if any
stopReprocessingPoll(recordingId);
// Track if we've already fetched full data for SUMMARIZING status
let hasFetchedForSummarizing = false;
const pollInterval = setInterval(async () => {
try {
// Use lightweight status-only endpoint
const response = await fetch(`/recording/${recordingId}/status`);
if (!response.ok) throw new Error('Status check failed');
const statusData = await response.json();
// Update status in recordings list
const index = recordings.value.findIndex(r => r.id === recordingId);
if (index !== -1) {
// Create new object to ensure Vue reactivity
recordings.value[index] = {
...recordings.value[index],
status: statusData.status
};
}
// Update selectedRecording with new object reference for reactivity
if (selectedRecording.value?.id === recordingId) {
selectedRecording.value = {
...selectedRecording.value,
status: statusData.status
};
}
// Check if summarization has started (fetch transcript) or processing is complete
if (statusData.status === 'SUMMARIZING' || statusData.status === 'COMPLETED') {
// Only fetch once when status first becomes SUMMARIZING
const shouldFetch = (statusData.status === 'SUMMARIZING' && !hasFetchedForSummarizing) ||
statusData.status === 'COMPLETED';
if (shouldFetch) {
// Mark that we've fetched for SUMMARIZING
if (statusData.status === 'SUMMARIZING') {
hasFetchedForSummarizing = true;
}
// Only stop polling if COMPLETED, keep polling during SUMMARIZING
if (statusData.status === 'COMPLETED') {
stopReprocessingPoll(recordingId);
}
// Fetch the full recording with updated data
const fullResponse = await fetch(`/api/recordings/${recordingId}`);
if (fullResponse.ok) {
const fullData = await fullResponse.json();
// Update in recordings list first
const currentIndex = recordings.value.findIndex(r => r.id === recordingId);
if (currentIndex !== -1) {
recordings.value[currentIndex] = fullData;
}
// Always update selectedRecording if it's the current recording
if (selectedRecording.value?.id === recordingId) {
selectedRecording.value = fullData;
await nextTick();
}
}
if (statusData.status === 'COMPLETED') {
showToast('Processing completed!', 'fa-check-circle');
// Refresh token budget after LLM operations complete
if (onChatComplete) onChatComplete();
}
}
} else if (statusData.status === 'FAILED') {
stopReprocessingPoll(recordingId);
// Fetch full recording data to get error details for display
try {
const failedResponse = await fetch(`/api/recordings/${recordingId}`);
if (failedResponse.ok) {
const failedData = await failedResponse.json();
// Update in recordings list
const currentIndex = recordings.value.findIndex(r => r.id === recordingId);
if (currentIndex !== -1) {
recordings.value[currentIndex] = failedData;
}
// Update selectedRecording to show error in transcription panel
if (selectedRecording.value?.id === recordingId) {
selectedRecording.value = failedData;
await nextTick();
}
}
} catch (err) {
console.error('Failed to fetch error details:', err);
}
showToast('Processing failed', 'fa-exclamation-circle');
}
} catch (error) {
console.error('Polling error:', error);
stopReprocessingPoll(recordingId);
}
}, 3000);
reprocessingPolls.set(recordingId, pollInterval);
};
const stopReprocessingPoll = (recordingId) => {
if (reprocessingPolls.has(recordingId)) {
clearInterval(reprocessingPolls.get(recordingId));
reprocessingPolls.delete(recordingId);
}
};
return {
// Reprocess modal
openReprocessModal,
closeReprocessModal,
confirmReprocess,
cancelReprocess,
executeReprocess,
// Reset status
confirmReset,
cancelReset,
executeReset,
// Transcription
reprocessTranscription,
// Summary
reprocessSummary,
generateSummary,
// Polling
startReprocessingPoll,
stopReprocessingPoll
};
}

View File

@@ -0,0 +1,659 @@
/**
* Sharing composable
* Handles public and internal sharing of recordings
*/
export function useSharing(state, utils) {
const {
showShareModal, showSharesListModal, showShareDeleteModal,
showUnifiedShareModal, recordingToShare, shareOptions,
generatedShareLink, existingShareDetected, recordingPublicShares, isLoadingPublicShares,
userShares, isLoadingShares, copiedShareId, shareToDelete, selectedRecording, recordings,
internalShareUserSearch, internalShareSearchResults,
internalShareRecording, internalSharePermissions, internalShareMaxPermissions,
recordingInternalShares, isLoadingInternalShares,
isSearchingUsers, allUsers, isLoadingAllUsers,
enableInternalSharing, showUsernamesInUI
} = state;
const { showToast, setGlobalError } = utils;
let userSearchTimeout = null;
// Helper function to format share dates
const formatShareDate = (dateString) => {
if (!dateString) return 'Unknown date';
try {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
// If today
if (diffDays === 0) {
return 'Today at ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
}
// If yesterday
else if (diffDays === 1) {
return 'Yesterday at ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
}
// If within last week
else if (diffDays < 7) {
return date.toLocaleDateString('en-US', { weekday: 'long', hour: 'numeric', minute: '2-digit', hour12: true });
}
// Otherwise show full date
else {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true });
}
} catch (e) {
console.error('Error formatting date:', e);
return dateString;
}
};
// Helper function to get color class for username (like speaker colors)
const getUserColorClass = (username) => {
if (!username) return 'speaker-color-1';
// Simple hash function to generate consistent color from username
let hash = 0;
for (let i = 0; i < username.length; i++) {
hash = ((hash << 5) - hash) + username.charCodeAt(i);
hash = hash & hash; // Convert to 32bit integer
}
// Map to color classes 1-16
const colorNum = (Math.abs(hash) % 16) + 1;
return `speaker-color-${colorNum}`;
};
// =========================================
// Public Sharing
// =========================================
const openShareModal = async (recording) => {
recordingToShare.value = recording;
shareOptions.share_summary = true;
shareOptions.share_notes = true;
generatedShareLink.value = '';
existingShareDetected.value = false;
recordingPublicShares.value = [];
showShareModal.value = true;
// Load all public shares for this recording
isLoadingPublicShares.value = true;
try {
const response = await fetch(`/api/shares`);
if (response.ok) {
const allShares = await response.json();
// Filter to only shares for this recording and add share_url
recordingPublicShares.value = allShares
.filter(share => share.recording_id === recording.id)
.map(share => ({
...share,
share_url: `${window.location.origin}/share/${share.public_id}`
}));
}
} catch (error) {
console.error('Error loading public shares:', error);
recordingPublicShares.value = [];
} finally {
isLoadingPublicShares.value = false;
}
};
const closeShareModal = () => {
showShareModal.value = false;
recordingToShare.value = null;
existingShareDetected.value = false;
recordingPublicShares.value = [];
};
const createShare = async (forceNew = false) => {
const recording = recordingToShare.value || internalShareRecording.value;
if (!recording) return;
try {
const response = await fetch(`/api/recording/${recording.id}/share`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...shareOptions,
force_new: forceNew
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to create share link');
generatedShareLink.value = data.share_url;
existingShareDetected.value = data.existing && !forceNew;
// Add to the shares list (works for both share modal and unified modal)
if (!data.existing) {
recordingPublicShares.value.push({
...data.share,
share_url: `${window.location.origin}/share/${data.share.public_id}`
});
// Update the recording's share count in the UI
await refreshRecordingShareCounts();
} else if (data.existing && !recordingPublicShares.value.find(s => s.id === data.share.id)) {
// If existing but not in list, add it
recordingPublicShares.value.push({
...data.share,
share_url: `${window.location.origin}/share/${data.share.public_id}`
});
}
if (data.existing && !forceNew) {
showToast('Using existing share link', 'fa-link');
} else {
showToast('Share link created successfully!', 'fa-check-circle');
}
} catch (error) {
setGlobalError(`Failed to create share link: ${error.message}`);
}
};
const confirmDeletePublicShare = (share) => {
shareToDelete.value = share;
showShareDeleteModal.value = true;
};
const deletePublicShare = async () => {
if (!shareToDelete.value) return;
const shareId = shareToDelete.value.id;
try {
const response = await fetch(`/api/share/${shareId}`, { method: 'DELETE' });
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to delete share');
// Remove from the shares list (both modals use different arrays)
recordingPublicShares.value = recordingPublicShares.value.filter(s => s.id !== shareId);
userShares.value = userShares.value.filter(s => s.id !== shareId);
// Update the recording's share count in the UI
await refreshRecordingShareCounts();
showToast('Share link deleted successfully.', 'fa-check-circle');
showShareDeleteModal.value = false;
shareToDelete.value = null;
} catch (error) {
setGlobalError(`Failed to delete share: ${error.message}`);
}
};
const copyPublicShareLink = (shareUrl) => {
navigator.clipboard.writeText(shareUrl).then(() => {
showToast('Share link copied to clipboard!', 'fa-check-circle');
}).catch(() => {
setGlobalError('Failed to copy link to clipboard');
});
};
const copyPublicShareLinkWithFeedback = (shareUrl, shareId) => {
navigator.clipboard.writeText(shareUrl).then(() => {
copiedShareId.value = shareId;
showToast('Share link copied to clipboard!', 'fa-check-circle');
// Reset after delay
setTimeout(() => {
copiedShareId.value = null;
}, 1500);
}).catch(() => {
setGlobalError('Failed to copy link to clipboard');
});
};
const refreshRecordingShareCounts = async () => {
// Refresh the current recording if one is selected
const recording = recordingToShare.value || internalShareRecording.value || selectedRecording.value;
if (!recording) return;
try {
const response = await fetch(`/api/recordings/${recording.id}`);
if (response.ok) {
const updatedRecording = await response.json();
// Update in recordings list
const index = recordings.value.findIndex(r => r.id === recording.id);
if (index !== -1) {
// Preserve reactivity by updating specific fields
recordings.value[index].public_share_count = updatedRecording.public_share_count || 0;
recordings.value[index].shared_with_count = updatedRecording.shared_with_count || 0;
}
// Update selected recording if it's the same one
if (selectedRecording.value && selectedRecording.value.id === recording.id) {
selectedRecording.value.public_share_count = updatedRecording.public_share_count || 0;
selectedRecording.value.shared_with_count = updatedRecording.shared_with_count || 0;
}
// Update internal share recording if it's the same one
if (internalShareRecording.value && internalShareRecording.value.id === recording.id) {
internalShareRecording.value.public_share_count = updatedRecording.public_share_count || 0;
internalShareRecording.value.shared_with_count = updatedRecording.shared_with_count || 0;
}
// Update recording to share if it's the same one
if (recordingToShare.value && recordingToShare.value.id === recording.id) {
recordingToShare.value.public_share_count = updatedRecording.public_share_count || 0;
recordingToShare.value.shared_with_count = updatedRecording.shared_with_count || 0;
}
}
} catch (error) {
console.error('Failed to refresh recording share counts:', error);
}
};
const copyShareLink = () => {
if (!generatedShareLink.value) return;
navigator.clipboard.writeText(generatedShareLink.value).then(() => {
showToast('Share link copied to clipboard!');
});
};
const copyIndividualShareLink = (shareId) => {
const input = document.getElementById(`share-link-${shareId}`);
if (!input) return;
const button = input.nextElementSibling;
if (!button) return;
navigator.clipboard.writeText(input.value).then(() => {
copiedShareId.value = shareId;
showToast('Share link copied to clipboard!', 'fa-check');
// Apply success state
button.style.transition = 'background-color 0.2s ease';
button.style.backgroundColor = 'var(--bg-success, #10b981)';
// Revert after delay
setTimeout(() => {
button.style.backgroundColor = '';
copiedShareId.value = null;
setTimeout(() => {
button.style.transition = '';
}, 200);
}, 1500);
}).catch(err => {
console.error('Failed to copy share link:', err);
});
};
// =========================================
// Shares List
// =========================================
const openSharesList = async () => {
isLoadingShares.value = true;
showSharesListModal.value = true;
try {
const response = await fetch('/api/shares');
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to load shared items');
userShares.value = data;
} catch (error) {
setGlobalError(`Failed to load shared items: ${error.message}`);
} finally {
isLoadingShares.value = false;
}
};
const closeSharesList = () => {
showSharesListModal.value = false;
userShares.value = [];
};
const updateShare = async (share) => {
try {
const response = await fetch(`/api/share/${share.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
share_summary: share.share_summary,
share_notes: share.share_notes
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to update share');
showToast('Share permissions updated.', 'fa-check-circle');
} catch (error) {
setGlobalError(`Failed to update share: ${error.message}`);
}
};
const confirmDeleteShare = (share) => {
shareToDelete.value = share;
showShareDeleteModal.value = true;
};
const cancelDeleteShare = () => {
shareToDelete.value = null;
showShareDeleteModal.value = false;
};
// =========================================
// Internal Sharing
// =========================================
const loadAllUsers = async () => {
if (!showUsernamesInUI.value) return;
isLoadingAllUsers.value = true;
try {
const response = await fetch('/api/users/search?q=');
if (!response.ok) {
if (response.status === 403) {
throw new Error('Internal sharing is not enabled');
}
throw new Error('Failed to load users');
}
const data = await response.json();
allUsers.value = data;
} catch (error) {
setGlobalError(`Failed to load users: ${error.message}`);
allUsers.value = [];
} finally {
isLoadingAllUsers.value = false;
}
};
const searchInternalShareUsers = async () => {
const query = internalShareUserSearch.value.trim();
// If SHOW_USERNAMES_IN_UI is enabled, filter allUsers locally
if (showUsernamesInUI.value) {
// Get list of user IDs that already have access
const sharedUserIds = new Set(recordingInternalShares.value.map(share => share.user_id));
// Filter out already-shared users
const availableUsers = allUsers.value.filter(user => !sharedUserIds.has(user.id));
if (query.length === 0) {
internalShareSearchResults.value = availableUsers;
} else {
internalShareSearchResults.value = availableUsers.filter(user =>
user.username.toLowerCase().includes(query.toLowerCase()) ||
(user.email && user.email.toLowerCase().includes(query.toLowerCase()))
);
}
return;
}
// Otherwise, use server-side search
if (query.length < 2) {
internalShareSearchResults.value = [];
return;
}
clearTimeout(userSearchTimeout);
userSearchTimeout = setTimeout(async () => {
isSearchingUsers.value = true;
try {
const response = await fetch(`/api/users/search?q=${encodeURIComponent(query)}`);
if (!response.ok) {
if (response.status === 403) {
throw new Error('Internal sharing is not enabled');
}
throw new Error('Failed to search users');
}
const data = await response.json();
internalShareSearchResults.value = data;
} catch (error) {
setGlobalError(`Failed to search users: ${error.message}`);
internalShareSearchResults.value = [];
} finally {
isSearchingUsers.value = false;
}
}, 300);
};
const openUnifiedShareModal = async (recording) => {
internalShareRecording.value = recording;
internalShareUserSearch.value = '';
internalShareSearchResults.value = [];
internalSharePermissions.value = { can_edit: false, can_reshare: false };
recordingPublicShares.value = [];
shareOptions.share_summary = true;
shareOptions.share_notes = true;
// PERMISSION CEILING: Calculate maximum permissions current user can grant
// If viewing a shared recording (not owner), constrain to their permissions
if (recording.is_shared && recording.share_info) {
internalShareMaxPermissions.value = {
can_edit: recording.share_info.can_edit || false,
can_reshare: recording.share_info.can_reshare || false
};
} else {
// Owner has unlimited permissions
internalShareMaxPermissions.value = {
can_edit: true,
can_reshare: true
};
}
showUnifiedShareModal.value = true;
// Load all public shares for this recording
isLoadingPublicShares.value = true;
try {
const response = await fetch(`/api/shares`);
if (response.ok) {
const allShares = await response.json();
// Filter to only shares for this recording and add share_url
recordingPublicShares.value = allShares
.filter(share => share.recording_id === recording.id)
.map(share => ({
...share,
share_url: `${window.location.origin}/share/${share.public_id}`
}));
}
} catch (error) {
console.error('Error loading public shares:', error);
recordingPublicShares.value = [];
} finally {
isLoadingPublicShares.value = false;
}
// Load existing internal shares
isLoadingInternalShares.value = true;
try {
const response = await fetch(`/api/recordings/${recording.id}/shares-internal`);
if (!response.ok) {
if (response.status === 403) {
throw new Error('Internal sharing is not enabled');
}
throw new Error('Failed to load shares');
}
const data = await response.json();
recordingInternalShares.value = data.shares || [];
} catch (error) {
setGlobalError(`Failed to load shares: ${error.message}`);
recordingInternalShares.value = [];
} finally {
isLoadingInternalShares.value = false;
}
// If SHOW_USERNAMES_IN_UI is enabled, load all users
if (showUsernamesInUI.value) {
await loadAllUsers();
internalShareSearchResults.value = allUsers.value;
}
};
const closeUnifiedShareModal = () => {
showUnifiedShareModal.value = false;
internalShareRecording.value = null;
internalShareUserSearch.value = '';
internalShareSearchResults.value = [];
recordingInternalShares.value = [];
recordingPublicShares.value = [];
allUsers.value = [];
};
// Legacy function names for backward compatibility
const openInternalShareModal = openUnifiedShareModal;
const openManageInternalSharesModal = openUnifiedShareModal;
const closeInternalShareModal = closeUnifiedShareModal;
const closeManageInternalSharesModal = closeUnifiedShareModal;
const reloadInternalShares = async () => {
if (!internalShareRecording.value) return;
isLoadingInternalShares.value = true;
try {
const response = await fetch(`/api/recordings/${internalShareRecording.value.id}/shares-internal`);
if (!response.ok) {
throw new Error('Failed to load shares');
}
const data = await response.json();
recordingInternalShares.value = data.shares || [];
} catch (error) {
setGlobalError(`Failed to reload shares: ${error.message}`);
} finally {
isLoadingInternalShares.value = false;
}
};
const shareWithUsername = async () => {
if (!internalShareRecording.value) return;
const username = internalShareUserSearch.value.trim();
if (!username) {
setGlobalError('Please enter a username');
return;
}
isSearchingUsers.value = true;
try {
// Search for the exact username
const searchResponse = await fetch(`/api/users/search?q=${encodeURIComponent(username)}`);
if (!searchResponse.ok) {
if (searchResponse.status === 403) {
throw new Error('Internal sharing is not enabled');
}
throw new Error('Failed to find user');
}
const users = await searchResponse.json();
if (users.length === 0) {
setGlobalError(`User "${username}" not found`);
return;
}
// Use the first matching user (should be exact match from backend)
const user = users[0];
await createInternalShare(user.id, user.username);
// Clear input on success
internalShareUserSearch.value = '';
} catch (error) {
setGlobalError(error.message || 'Failed to share with user');
} finally {
isSearchingUsers.value = false;
}
};
const createInternalShare = async (userId, username) => {
if (!internalShareRecording.value) return;
try {
const response = await fetch(`/api/recordings/${internalShareRecording.value.id}/share-internal`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: userId,
can_edit: internalSharePermissions.value.can_edit,
can_reshare: internalSharePermissions.value.can_reshare
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to share recording');
}
const displayName = showUsernamesInUI.value ? username : `User #${userId}`;
showToast(`Recording shared with ${displayName}`, 'fa-share-alt');
// Reset permissions for next share
internalSharePermissions.value = { can_edit: false, can_reshare: false };
// Reload shares to show the new share in the list
await reloadInternalShares();
// Update the recording's share count in the UI
await refreshRecordingShareCounts();
} catch (error) {
setGlobalError(`Failed to share recording: ${error.message}`);
}
};
const revokeInternalShare = async (shareId, username) => {
if (!internalShareRecording.value) return;
try {
const response = await fetch(`/api/internal-shares/${shareId}`, {
method: 'DELETE'
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to revoke share');
}
recordingInternalShares.value = recordingInternalShares.value.filter(s => s.id !== shareId);
const displayName = showUsernamesInUI.value ? username : 'User';
showToast(`Access revoked for ${displayName}`, 'fa-user-times');
// Update the recording's share count in the UI
await refreshRecordingShareCounts();
} catch (error) {
setGlobalError(`Failed to revoke share: ${error.message}`);
}
};
return {
// Utilities
formatShareDate,
getUserColorClass,
// Public sharing
openShareModal,
closeShareModal,
createShare,
copyShareLink,
copyPublicShareLink,
copyPublicShareLinkWithFeedback,
copyIndividualShareLink,
confirmDeletePublicShare,
deletePublicShare,
refreshRecordingShareCounts,
// Shares list
openSharesList,
closeSharesList,
updateShare,
confirmDeleteShare,
cancelDeleteShare,
deleteShare: deletePublicShare, // Alias for template compatibility
copiedShareId,
// Internal sharing
loadAllUsers,
searchInternalShareUsers,
openUnifiedShareModal,
closeUnifiedShareModal,
openInternalShareModal,
closeInternalShareModal,
openManageInternalSharesModal,
closeManageInternalSharesModal,
reloadInternalShares,
shareWithUsername,
createInternalShare,
revokeInternalShare
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,297 @@
/**
* Tags Management Composable
* Handles tag operations for recordings
*/
const { computed, ref } = Vue;
export function useTags({
recordings,
availableTags,
selectedRecording,
showEditTagsModal,
editingRecording,
tagSearchFilter,
showToast,
setGlobalError
}) {
// State (using passed refs from parent)
// --- Tag Drag-and-Drop State for Edit Modal ---
const modalDraggedTagIndex = ref(null);
const modalDragOverTagIndex = ref(null);
// Computed
const getRecordingTags = (recording) => {
if (!recording || !recording.tags) return [];
return recording.tags;
};
const getAvailableTagsForRecording = (recording) => {
if (!recording || !availableTags.value) return [];
const recordingTagIds = getRecordingTags(recording).map(tag => tag.id);
return availableTags.value.filter(tag => !recordingTagIds.includes(tag.id));
};
const filteredAvailableTagsForModal = computed(() => {
if (!editingRecording.value) return [];
const availableTagsForRec = getAvailableTagsForRecording(editingRecording.value);
if (!tagSearchFilter.value) return availableTagsForRec;
const filter = tagSearchFilter.value.toLowerCase();
return availableTagsForRec.filter(tag =>
tag.name.toLowerCase().includes(filter)
);
});
// Methods
const editRecordingTags = (recording) => {
editingRecording.value = recording;
tagSearchFilter.value = '';
showEditTagsModal.value = true;
};
const closeEditTagsModal = () => {
showEditTagsModal.value = false;
editingRecording.value = null;
tagSearchFilter.value = '';
};
const addTagToRecording = async (tagId) => {
if (!tagId || !editingRecording.value) return;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/recordings/${editingRecording.value.id}/tags`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ tag_id: tagId })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to add tag');
}
// Update local recording data
const tagToAdd = availableTags.value.find(tag => tag.id == tagId);
if (tagToAdd) {
// Check if tag already exists to prevent duplicates
const tagExists = editingRecording.value.tags?.some(t => t.id === tagToAdd.id);
if (!tagExists) {
if (!editingRecording.value.tags) {
editingRecording.value.tags = [];
}
editingRecording.value.tags.push(tagToAdd);
}
// Also update in recordings list (only if different object)
const recordingInList = recordings.value.find(r => r.id === editingRecording.value.id);
if (recordingInList && recordingInList !== editingRecording.value) {
const tagExistsInList = recordingInList.tags?.some(t => t.id === tagToAdd.id);
if (!tagExistsInList) {
if (!recordingInList.tags) {
recordingInList.tags = [];
}
recordingInList.tags.push(tagToAdd);
}
}
// Update selectedRecording if it matches (only if different object)
if (selectedRecording.value &&
selectedRecording.value.id === editingRecording.value.id &&
selectedRecording.value !== editingRecording.value) {
const tagExistsInSelected = selectedRecording.value.tags?.some(t => t.id === tagToAdd.id);
if (!tagExistsInSelected) {
if (!selectedRecording.value.tags) {
selectedRecording.value.tags = [];
}
selectedRecording.value.tags.push(tagToAdd);
}
}
}
showToast('Tag added successfully', 'fa-check-circle', 2000, 'success');
} catch (error) {
console.error('Error adding tag to recording:', error);
setGlobalError(`Failed to add tag: ${error.message}`);
}
};
const removeTagFromRecording = async (tagId) => {
if (!editingRecording.value) return;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/recordings/${editingRecording.value.id}/tags/${tagId}`, {
method: 'DELETE',
headers: {
'X-CSRFToken': csrfToken
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to remove tag');
}
// Update local recording data
editingRecording.value.tags = editingRecording.value.tags.filter(tag => tag.id !== tagId);
// Also update in recordings list
const recordingInList = recordings.value.find(r => r.id === editingRecording.value.id);
if (recordingInList && recordingInList !== editingRecording.value && recordingInList.tags) {
recordingInList.tags = recordingInList.tags.filter(tag => tag.id !== tagId);
}
// Update selectedRecording if it matches
if (selectedRecording.value && selectedRecording.value.id === editingRecording.value.id && selectedRecording.value.tags) {
selectedRecording.value.tags = selectedRecording.value.tags.filter(tag => tag.id !== tagId);
}
showToast('Tag removed successfully', 'fa-check-circle', 2000, 'success');
} catch (error) {
console.error('Error removing tag from recording:', error);
setGlobalError(`Failed to remove tag: ${error.message}`);
}
};
// --- Modal Tag Reordering ---
const reorderModalTags = async (fromIndex, toIndex) => {
if (!editingRecording.value || !editingRecording.value.tags) return;
// Reorder locally first for immediate visual feedback
const tags = [...editingRecording.value.tags];
const [removed] = tags.splice(fromIndex, 1);
tags.splice(toIndex, 0, removed);
editingRecording.value.tags = tags;
// Update in recordings list
const recordingInList = recordings.value.find(r => r.id === editingRecording.value.id);
if (recordingInList && recordingInList !== editingRecording.value) {
recordingInList.tags = [...tags];
}
// Update selectedRecording if it matches
if (selectedRecording.value && selectedRecording.value.id === editingRecording.value.id) {
selectedRecording.value.tags = [...tags];
}
// Persist to backend
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const tagIds = tags.map(t => t.id);
const response = await fetch(`/api/recordings/${editingRecording.value.id}/tags/reorder`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ tag_ids: tagIds })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to reorder tags');
}
showToast('Tags reordered', 'fa-arrows-alt', 1500, 'success');
} catch (error) {
console.error('Error reordering tags:', error);
setGlobalError(`Failed to save tag order: ${error.message}`);
}
};
// === MOUSE DRAG HANDLERS (Modal) ===
const handleModalTagDragStart = (index, event) => {
modalDraggedTagIndex.value = index;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', index.toString());
};
const handleModalTagDragOver = (index, event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
modalDragOverTagIndex.value = index;
};
const handleModalTagDrop = (targetIndex, event) => {
event.preventDefault();
if (modalDraggedTagIndex.value !== null && modalDraggedTagIndex.value !== targetIndex) {
reorderModalTags(modalDraggedTagIndex.value, targetIndex);
}
modalDraggedTagIndex.value = null;
modalDragOverTagIndex.value = null;
};
const handleModalTagDragEnd = () => {
modalDraggedTagIndex.value = null;
modalDragOverTagIndex.value = null;
};
// === TOUCH HANDLERS (Modal - Mobile) ===
let modalTouchStartIndex = null;
const handleModalTagTouchStart = (index, event) => {
modalTouchStartIndex = index;
modalDraggedTagIndex.value = index;
};
const handleModalTagTouchMove = (event) => {
if (modalTouchStartIndex === null) return;
event.preventDefault();
const touch = event.touches[0];
const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY);
const tagElement = elementBelow?.closest('[data-modal-tag-index]');
if (tagElement) {
const targetIndex = parseInt(tagElement.dataset.modalTagIndex);
modalDragOverTagIndex.value = targetIndex;
}
};
const handleModalTagTouchEnd = () => {
if (modalTouchStartIndex !== null && modalDragOverTagIndex.value !== null &&
modalTouchStartIndex !== modalDragOverTagIndex.value) {
reorderModalTags(modalTouchStartIndex, modalDragOverTagIndex.value);
}
modalTouchStartIndex = null;
modalDraggedTagIndex.value = null;
modalDragOverTagIndex.value = null;
};
return {
// Computed
filteredAvailableTagsForModal,
// Methods
getRecordingTags,
getAvailableTagsForRecording,
editRecordingTags,
closeEditTagsModal,
addTagToRecording,
removeTagFromRecording,
// Modal Tag Drag-and-Drop
modalDraggedTagIndex,
modalDragOverTagIndex,
handleModalTagDragStart,
handleModalTagDragOver,
handleModalTagDrop,
handleModalTagDragEnd,
handleModalTagTouchStart,
handleModalTagTouchMove,
handleModalTagTouchEnd
};
}

View File

@@ -0,0 +1,286 @@
/**
* API Tokens Management Composable
* Handles API token operations for user authentication
*/
const { ref, computed } = Vue;
export function useTokens({ showToast, setGlobalError }) {
// State
const tokens = ref([]);
const isLoadingTokens = ref(false);
const showCreateTokenModal = ref(false);
const showTokenSecretModal = ref(false);
const newTokenSecret = ref('');
const newTokenData = ref(null);
const tokenForm = ref({
name: '',
expires_in_days: 0 // 0 = no expiration
});
// Computed
const hasTokens = computed(() => tokens.value.length > 0);
const activeTokens = computed(() => {
return tokens.value.filter(token => !token.revoked && !isTokenExpired(token));
});
const expiredOrRevokedTokens = computed(() => {
return tokens.value.filter(token => token.revoked || isTokenExpired(token));
});
// Helper methods
const isTokenExpired = (token) => {
if (!token.expires_at) return false;
const expiryDate = new Date(token.expires_at);
return expiryDate < new Date();
};
const formatTokenDate = (dateString) => {
if (!dateString) return 'Never';
const date = new Date(dateString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
};
const getTokenStatus = (token) => {
if (token.revoked) return 'revoked';
if (isTokenExpired(token)) return 'expired';
return 'active';
};
const getTokenStatusClass = (token) => {
const status = getTokenStatus(token);
const baseClasses = 'px-2 py-1 text-xs font-semibold rounded';
switch (status) {
case 'active':
return `${baseClasses} bg-green-100 text-green-800`;
case 'expired':
return `${baseClasses} bg-yellow-100 text-yellow-800`;
case 'revoked':
return `${baseClasses} bg-red-100 text-red-800`;
default:
return `${baseClasses} bg-gray-100 text-gray-800`;
}
};
// API methods
const loadTokens = async () => {
isLoadingTokens.value = true;
try {
const response = await fetch('/api/tokens', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Failed to load tokens');
}
const data = await response.json();
tokens.value = data.tokens || [];
} catch (error) {
console.error('Error loading tokens:', error);
setGlobalError('Failed to load API tokens: ' + error.message);
} finally {
isLoadingTokens.value = false;
}
};
const createToken = async () => {
if (!tokenForm.value.name || tokenForm.value.name.trim() === '') {
showToast('Please enter a token name', 'error');
return;
}
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch('/api/tokens', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
name: tokenForm.value.name,
expires_in_days: parseInt(tokenForm.value.expires_in_days) || 0
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to create token');
}
const data = await response.json();
// Store the plaintext token to show to user (only shown once)
newTokenSecret.value = data.token;
newTokenData.value = {
id: data.id,
name: data.name,
created_at: data.created_at,
expires_at: data.expires_at
};
// Add to tokens list (without the plaintext token)
tokens.value.unshift({
id: data.id,
name: data.name,
created_at: data.created_at,
last_used_at: data.last_used_at,
expires_at: data.expires_at,
revoked: data.revoked
});
// Reset form
tokenForm.value = {
name: '',
expires_in_days: 0
};
// Close create modal and show secret modal
showCreateTokenModal.value = false;
showTokenSecretModal.value = true;
showToast('API token created successfully', 'success');
} catch (error) {
console.error('Error creating token:', error);
showToast('Failed to create token: ' + error.message, 'error');
}
};
const revokeToken = async (tokenId, tokenName) => {
if (!confirm(`Are you sure you want to revoke the token "${tokenName}"? This action cannot be undone and any applications using this token will lose access.`)) {
return;
}
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/tokens/${tokenId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to revoke token');
}
// Remove from local list
tokens.value = tokens.value.filter(t => t.id !== tokenId);
showToast('Token revoked successfully', 'success');
} catch (error) {
console.error('Error revoking token:', error);
showToast('Failed to revoke token: ' + error.message, 'error');
}
};
const updateTokenName = async (tokenId, newName) => {
if (!newName || newName.trim() === '') {
showToast('Token name cannot be empty', 'error');
return;
}
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/tokens/${tokenId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ name: newName })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update token');
}
const data = await response.json();
// Update local token
const token = tokens.value.find(t => t.id === tokenId);
if (token) {
token.name = data.name;
}
showToast('Token name updated', 'success');
} catch (error) {
console.error('Error updating token:', error);
showToast('Failed to update token: ' + error.message, 'error');
}
};
const copyTokenToClipboard = async (token) => {
try {
await navigator.clipboard.writeText(token);
showToast('Token copied to clipboard', 'success');
} catch (error) {
console.error('Error copying token:', error);
showToast('Failed to copy token to clipboard', 'error');
}
};
const openCreateTokenModal = () => {
tokenForm.value = {
name: '',
expires_in_days: 0
};
showCreateTokenModal.value = true;
};
const closeCreateTokenModal = () => {
showCreateTokenModal.value = false;
tokenForm.value = {
name: '',
expires_in_days: 0
};
};
const closeTokenSecretModal = () => {
showTokenSecretModal.value = false;
newTokenSecret.value = '';
newTokenData.value = null;
};
return {
// State
tokens,
isLoadingTokens,
showCreateTokenModal,
showTokenSecretModal,
newTokenSecret,
newTokenData,
tokenForm,
// Computed
hasTokens,
activeTokens,
expiredOrRevokedTokens,
// Methods
isTokenExpired,
formatTokenDate,
getTokenStatus,
getTokenStatusClass,
loadTokens,
createToken,
revokeToken,
updateTokenName,
copyTokenToClipboard,
openCreateTokenModal,
closeCreateTokenModal,
closeTokenSecretModal
};
}

View File

@@ -0,0 +1,484 @@
/**
* Transcription editing composable
* Handles ASR editor, text editor, and segment management
*/
export function useTranscription(state, utils) {
const {
showTextEditorModal, showAsrEditorModal, selectedRecording,
editingTranscriptionContent, editingSegments, availableSpeakers,
recordings, dropdownPositions, openAsrDropdownIndex
} = state;
const { showToast, setGlobalError, nextTick } = utils;
// =========================================
// Text Editor Modal
// =========================================
const openTranscriptionEditor = () => {
if (!selectedRecording.value || !selectedRecording.value.transcription) {
return;
}
// Check if transcription is JSON (ASR format)
try {
const parsed = JSON.parse(selectedRecording.value.transcription);
if (Array.isArray(parsed)) {
openAsrEditorModal();
} else {
openTextEditorModal();
}
} catch (e) {
// Not JSON, use text editor
openTextEditorModal();
}
};
const openTextEditorModal = () => {
if (!selectedRecording.value) return;
editingTranscriptionContent.value = selectedRecording.value.transcription || '';
showTextEditorModal.value = true;
};
const closeTextEditorModal = () => {
showTextEditorModal.value = false;
editingTranscriptionContent.value = '';
};
const saveTranscription = async () => {
if (!selectedRecording.value) return;
await saveTranscriptionContent(editingTranscriptionContent.value);
closeTextEditorModal();
};
// =========================================
// ASR Editor Modal
// =========================================
// Helper to pause outer audio player when opening modals with their own player
const pauseOuterAudioPlayer = () => {
const outerAudio = document.querySelector('#rightMainColumn audio') || document.querySelector('#rightMainColumn video') ||
document.querySelector('.detail-view audio:not(.fixed audio)') || document.querySelector('.detail-view video:not(.fixed video)');
if (outerAudio && !outerAudio.paused) {
outerAudio.pause();
}
};
const openAsrEditorModal = async () => {
if (!selectedRecording.value) return;
// Pause outer audio player to avoid conflicts with modal's player
pauseOuterAudioPlayer();
try {
const segments = JSON.parse(selectedRecording.value.transcription);
// Populate available speakers from THIS recording only
const speakersInTranscript = [...new Set(segments.map(s => s.speaker))].sort();
availableSpeakers.value = speakersInTranscript;
editingSegments.value = segments.map((s, i) => ({
...s,
id: i,
showSuggestions: false,
filteredSpeakers: [...speakersInTranscript]
}));
showAsrEditorModal.value = true;
// Reset virtual scroll state for fresh modal render
if (utils.resetAsrEditorScroll) {
utils.resetAsrEditorScroll();
}
} catch (e) {
console.error("Could not parse transcription as JSON for ASR editor:", e);
setGlobalError("This transcription is not in the correct format for the ASR editor.");
}
};
const closeAsrEditorModal = () => {
// Pause any playing modal audio before closing
const modalAudio = document.querySelector('.fixed.z-50 audio') || document.querySelector('.fixed.z-50 video');
if (modalAudio) {
modalAudio.pause();
}
// Reset modal audio state (keep main player independent)
if (utils.resetModalAudioState) {
utils.resetModalAudioState();
}
showAsrEditorModal.value = false;
editingSegments.value = [];
};
const saveAsrTranscription = async () => {
if (!selectedRecording.value) return;
// Remove extra UI fields and save the rest
const contentToSave = JSON.stringify(editingSegments.value.map(({ id, showSuggestions, filteredSpeakers, ...rest }) => rest));
await saveTranscriptionContent(contentToSave);
closeAsrEditorModal();
};
// =========================================
// Segment Management
// =========================================
const adjustTime = (index, field, amount) => {
if (editingSegments.value[index]) {
editingSegments.value[index][field] = Math.max(0,
editingSegments.value[index][field] + amount
);
}
};
const filterSpeakerSuggestions = (index) => {
const segment = editingSegments.value[index];
if (segment) {
const query = segment.speaker?.toLowerCase() || '';
if (query === '') {
segment.filteredSpeakers = [...availableSpeakers.value];
} else {
segment.filteredSpeakers = availableSpeakers.value.filter(
speaker => speaker.toLowerCase().includes(query)
);
}
}
};
// O(1) dropdown management using single ref instead of O(n) forEach
const openSpeakerSuggestions = (index) => {
if (editingSegments.value[index]) {
// Simply set the open index - O(1) instead of O(n) forEach
openAsrDropdownIndex.value = index;
filterSpeakerSuggestions(index);
updateDropdownPosition(index);
}
};
const closeSpeakerSuggestions = (index) => {
// Only close if this index is currently open
if (openAsrDropdownIndex.value === index) {
openAsrDropdownIndex.value = null;
}
};
const closeAllSpeakerSuggestions = () => {
// O(1) instead of O(n) - just set to null
openAsrDropdownIndex.value = null;
};
// Helper to check if a dropdown is open (for template v-if)
const isDropdownOpen = (index) => {
return openAsrDropdownIndex.value === index;
};
const getDropdownPosition = (index) => {
const pos = dropdownPositions.value[index];
if (pos) {
const style = {
left: pos.left + 'px',
width: pos.width + 'px'
};
// When opening upward, anchor from bottom so dropdown grows upward
if (pos.openUpward) {
style.bottom = pos.bottom + 'px';
style.top = 'auto';
} else {
style.top = pos.top + 'px';
style.bottom = 'auto';
}
// Apply calculated max height
if (pos.maxHeight) {
style.maxHeight = pos.maxHeight + 'px';
}
return style;
}
return { top: '0px', left: '0px' };
};
const updateDropdownPosition = (index) => {
nextTick(() => {
// Find row by data attribute to work correctly with virtual scrolling
const row = document.querySelector(`.asr-editor-table tbody tr[data-segment-index="${index}"]`);
if (row) {
const cell = row.querySelector('td:first-child');
if (cell) {
const rect = cell.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// Calculate available space above and below
const spaceBelow = viewportHeight - rect.bottom - 10;
const spaceAbove = rect.top - 10;
// Determine max height based on available space (cap at 192px which is max-h-48)
const maxDropdownHeight = 192;
let top, bottom, openUpward, maxHeight;
if (spaceBelow >= maxDropdownHeight || spaceBelow >= spaceAbove) {
// Open downward
top = rect.bottom + 2;
bottom = null;
openUpward = false;
maxHeight = Math.min(spaceBelow, maxDropdownHeight);
} else {
// Open upward - anchor from bottom so dropdown grows upward
openUpward = true;
maxHeight = Math.min(spaceAbove, maxDropdownHeight);
// Bottom is distance from viewport bottom to the top of the cell
bottom = viewportHeight - rect.top + 2;
top = null;
}
dropdownPositions.value[index] = {
top: top,
bottom: bottom,
left: rect.left,
width: rect.width,
openUpward: openUpward,
maxHeight: maxHeight
};
}
}
});
};
const selectSpeaker = (index, speaker) => {
if (editingSegments.value[index]) {
editingSegments.value[index].speaker = speaker;
closeSpeakerSuggestions(index);
}
};
const addSegment = () => {
const lastSegment = editingSegments.value[editingSegments.value.length - 1];
const newStart = lastSegment ? lastSegment.end_time : 0;
editingSegments.value.push({
speaker: availableSpeakers.value[0] || 'Speaker 1',
start_time: newStart,
end_time: newStart + 5,
sentence: '',
id: editingSegments.value.length,
showSuggestions: false,
filteredSpeakers: [...availableSpeakers.value]
});
};
const removeSegment = (index) => {
editingSegments.value.splice(index, 1);
// Re-index segments
editingSegments.value.forEach((seg, i) => {
seg.id = i;
});
};
const addSegmentBelow = (index) => {
const currentSegment = editingSegments.value[index];
const nextSegment = editingSegments.value[index + 1];
const newStart = currentSegment.end_time;
const newEnd = nextSegment ? nextSegment.start_time : newStart + 5;
editingSegments.value.splice(index + 1, 0, {
speaker: currentSegment.speaker,
start_time: newStart,
end_time: newEnd,
sentence: '',
id: index + 1,
showSuggestions: false,
filteredSpeakers: [...availableSpeakers.value]
});
// Re-index segments
editingSegments.value.forEach((seg, i) => {
seg.id = i;
});
};
const seekToSegmentTime = (time) => {
// Find audio elements and use the one in a visible modal (z-50)
const mediaElements = document.querySelectorAll('.fixed.z-50 audio, .fixed.z-50 video');
const audioElement = mediaElements.length > 0 ? mediaElements[mediaElements.length - 1] : null;
if (audioElement) {
audioElement.currentTime = time;
audioElement.play();
}
};
const autoResizeTextarea = (event) => {
const textarea = event.target;
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
};
// =========================================
// Save Transcription Content
// =========================================
const saveTranscriptionContent = async (content) => {
if (!selectedRecording.value) return;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/recording/${selectedRecording.value.id}/update_transcription`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ transcription: content })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to update transcription');
// Update recording
selectedRecording.value.transcription = content;
const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id);
if (index !== -1) {
recordings.value[index].transcription = content;
}
showToast('Transcription updated successfully!', 'fa-check-circle');
} catch (error) {
setGlobalError(`Failed to save transcription: ${error.message}`);
}
};
// =========================================
// Save Summary
// =========================================
const saveSummary = async (summary) => {
if (!selectedRecording.value) return;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const payload = {
id: selectedRecording.value.id,
title: selectedRecording.value.title,
participants: selectedRecording.value.participants,
notes: selectedRecording.value.notes,
summary: summary,
meeting_date: selectedRecording.value.meeting_date
};
const response = await fetch('/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(payload)
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to update summary');
// Update recording
selectedRecording.value.summary = summary;
const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id);
if (index !== -1) {
recordings.value[index].summary = summary;
}
showToast('Summary saved!', 'fa-check-circle');
} catch (error) {
setGlobalError(`Failed to save summary: ${error.message}`);
}
};
// =========================================
// Save Notes
// =========================================
const saveNotes = async (notes) => {
if (!selectedRecording.value) return;
// Handle incognito recordings - save to sessionStorage only
if (selectedRecording.value.incognito) {
selectedRecording.value.notes = notes;
// Update sessionStorage
try {
const stored = sessionStorage.getItem('speakr_incognito_recording');
if (stored) {
const data = JSON.parse(stored);
data.notes = notes;
sessionStorage.setItem('speakr_incognito_recording', JSON.stringify(data));
}
} catch (e) {
console.error('[Incognito] Failed to save notes to sessionStorage:', e);
}
showToast('Notes saved (in browser only)', 'fa-check-circle');
return;
}
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/recordings/${selectedRecording.value.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ notes })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to update notes');
// Update recording
selectedRecording.value.notes = notes;
const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id);
if (index !== -1) {
recordings.value[index].notes = notes;
}
showToast('Notes saved!', 'fa-check-circle');
} catch (error) {
setGlobalError(`Failed to save notes: ${error.message}`);
}
};
return {
// Text editor
openTranscriptionEditor,
openTextEditorModal,
closeTextEditorModal,
saveTranscription,
// ASR editor
openAsrEditorModal,
closeAsrEditorModal,
saveAsrTranscription,
// Segment management
adjustTime,
filterSpeakerSuggestions,
openSpeakerSuggestions,
closeSpeakerSuggestions,
closeAllSpeakerSuggestions,
isDropdownOpen,
getDropdownPosition,
updateDropdownPosition,
selectSpeaker,
addSegment,
removeSegment,
addSegmentBelow,
seekToSegmentTime,
autoResizeTextarea,
// Save
saveTranscriptionContent,
saveSummary,
saveNotes
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,824 @@
/**
* Upload management composable
* Handles file uploads, queue processing, and progress tracking
*/
import * as FailedUploads from '../db/failed-uploads.js';
import * as IncognitoStorage from '../db/incognito-storage.js';
// Parse error message and return friendly error info
function getFriendlyError(errorMessage, t) {
const _t = t || ((key) => key);
if (!errorMessage) return { title: _t('errors.processingError'), message: _t('errors.processingErrorMessage') };
const lowerText = errorMessage.toLowerCase();
const patterns = [
{ patterns: ['maximum content size limit', 'file too large', '413', 'payload too large', 'exceeded'], title: _t('errors.fileTooLargeTitle'), guidance: _t('errors.enableChunkingGuidance') },
{ patterns: ['timed out', 'timeout', 'deadline exceeded'], title: _t('errors.processingTimeout'), guidance: _t('errors.splitAudioGuidance') },
{ patterns: ['401', 'unauthorized', 'invalid api key', 'authentication failed', 'incorrect api key'], title: _t('errors.authenticationError'), guidance: _t('errors.checkApiKeyGuidance') },
{ patterns: ['rate limit', 'too many requests', '429', 'quota exceeded'], title: _t('errors.rateLimitExceeded'), guidance: _t('errors.waitAndRetryGuidance') },
{ patterns: ['connection refused', 'connection reset', 'could not connect', 'network unreachable'], title: _t('errors.connectionError'), guidance: _t('errors.checkNetworkGuidance') },
{ patterns: ['503', '502', '500', 'service unavailable', 'server error', 'internal server error'], title: _t('errors.serviceUnavailable'), guidance: _t('errors.tryAgainLaterGuidance') },
{ patterns: ['invalid file format', 'unsupported format', 'could not decode', 'corrupt'], title: _t('errors.invalidAudioFormat'), guidance: _t('errors.convertFormatGuidance') },
{ patterns: ['audio extraction failed', 'ffmpeg failed', 'no audio stream'], title: _t('errors.audioExtractionFailed'), guidance: _t('errors.convertStandardGuidance') },
];
for (const pattern of patterns) {
for (const p of pattern.patterns) {
if (lowerText.includes(p)) return { title: pattern.title, guidance: pattern.guidance };
}
}
return { title: _t('errors.processingError'), guidance: _t('errors.processingErrorFallbackGuidance') };
}
export function useUpload(state, utils) {
const {
uploadQueue, currentlyProcessingFile, processingProgress, processingMessage,
isProcessingActive, pollInterval, progressPopupMinimized, progressPopupClosed,
maxFileSizeMB, chunkingEnabled, chunkingMode, chunkingLimit, maxConcurrentUploads,
recordings, selectedRecording, totalRecordings, globalError,
selectedTagIds, uploadLanguage, uploadMinSpeakers, uploadMaxSpeakers, uploadHotwords, uploadInitialPrompt,
useAsrEndpoint, connectorSupportsDiarization, asrLanguage, asrMinSpeakers, asrMaxSpeakers,
dragover, availableTags, uploadTagSearchFilter,
// Folder state
availableFolders, selectedFolderId,
// Incognito mode state
incognitoMode, incognitoRecording, incognitoProcessing,
// View state
currentView,
// Upload disclaimer state
uploadDisclaimer, showUploadDisclaimerModal
} = state;
const { computed, nextTick, ref } = Vue;
const { setGlobalError, showToast, formatFileSize, onChatComplete, t } = utils;
// Compute selected tags from IDs
const selectedTags = computed(() => {
return selectedTagIds.value.map(id =>
availableTags.value.find(t => t.id === id)
).filter(Boolean);
});
// --- Tag Drag-and-Drop State ---
const draggedTagIndex = ref(null);
const dragOverTagIndex = ref(null);
// Reorder selectedTagIds array
const reorderSelectedTags = (fromIndex, toIndex) => {
const tagIds = [...selectedTagIds.value];
const [removed] = tagIds.splice(fromIndex, 1);
tagIds.splice(toIndex, 0, removed);
selectedTagIds.value = tagIds;
applyTagDefaults(); // Re-apply defaults since first tag may have changed
};
// === MOUSE DRAG HANDLERS ===
const handleTagDragStart = (index, event) => {
draggedTagIndex.value = index;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', index.toString());
};
const handleTagDragOver = (index, event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
dragOverTagIndex.value = index;
};
const handleTagDrop = (targetIndex, event) => {
event.preventDefault();
if (draggedTagIndex.value !== null && draggedTagIndex.value !== targetIndex) {
reorderSelectedTags(draggedTagIndex.value, targetIndex);
}
draggedTagIndex.value = null;
dragOverTagIndex.value = null;
};
const handleTagDragEnd = () => {
draggedTagIndex.value = null;
dragOverTagIndex.value = null;
};
// === TOUCH HANDLERS (Mobile) ===
let touchStartIndex = null;
const handleTagTouchStart = (index, event) => {
touchStartIndex = index;
draggedTagIndex.value = index;
};
const handleTagTouchMove = (event) => {
if (touchStartIndex === null) return;
event.preventDefault();
const touch = event.touches[0];
const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY);
const tagElement = elementBelow?.closest('[data-tag-index]');
if (tagElement) {
const targetIndex = parseInt(tagElement.dataset.tagIndex);
dragOverTagIndex.value = targetIndex;
}
};
const handleTagTouchEnd = () => {
if (touchStartIndex !== null && dragOverTagIndex.value !== null &&
touchStartIndex !== dragOverTagIndex.value) {
reorderSelectedTags(touchStartIndex, dragOverTagIndex.value);
}
touchStartIndex = null;
draggedTagIndex.value = null;
dragOverTagIndex.value = null;
};
// Handle drag events
const handleDragOver = (e) => {
e.preventDefault();
dragover.value = true;
};
const handleDragLeave = (e) => {
if (e.relatedTarget && e.currentTarget.contains(e.relatedTarget)) {
return;
}
dragover.value = false;
};
const handleDrop = (e) => {
e.preventDefault();
dragover.value = false;
addFilesToQueue(e.dataTransfer.files);
};
const handleFileSelect = (e) => {
addFilesToQueue(e.target.files);
e.target.value = null;
};
// Add files to the upload queue
const addFilesToQueue = (files) => {
let filesAdded = 0;
for (const file of files) {
const fileObject = file.file ? file.file : file;
const notes = file.notes || null;
const tags = file.tags || selectedTags.value || [];
const asrOptions = file.asrOptions || {
language: asrLanguage.value,
min_speakers: asrMinSpeakers.value,
max_speakers: asrMaxSpeakers.value
};
// Check if it's an audio file or video container with audio
const isAudioFile = fileObject && (
fileObject.type.startsWith('audio/') ||
fileObject.type === 'video/mp4' ||
fileObject.type === 'video/quicktime' ||
fileObject.type === 'video/x-msvideo' ||
fileObject.type === 'video/webm' ||
fileObject.name.toLowerCase().endsWith('.amr') ||
fileObject.name.toLowerCase().endsWith('.3gp') ||
fileObject.name.toLowerCase().endsWith('.3gpp') ||
fileObject.name.toLowerCase().endsWith('.mp4') ||
fileObject.name.toLowerCase().endsWith('.mov') ||
fileObject.name.toLowerCase().endsWith('.avi') ||
fileObject.name.toLowerCase().endsWith('.mkv') ||
fileObject.name.toLowerCase().endsWith('.webm') ||
fileObject.name.toLowerCase().endsWith('.weba')
);
if (isAudioFile) {
// Only check general file size limit
if (fileObject.size > maxFileSizeMB.value * 1024 * 1024) {
setGlobalError(t('upload.fileExceedsMaxSize', { name: fileObject.name, size: maxFileSizeMB.value }));
continue;
}
const clientId = `client-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
uploadQueue.value.push({
file: fileObject,
notes: notes,
tags: tags,
asrOptions: asrOptions,
status: 'queued',
recordingId: null,
clientId: clientId,
error: null,
willAutoSummarize: false // Server will tell us via SUMMARIZING status
});
filesAdded++;
} else if (fileObject) {
setGlobalError(t('upload.invalidFileType', { name: fileObject.name }));
}
}
if (filesAdded > 0) {
console.log(`Added ${filesAdded} file(s) to the queue.`);
}
};
// Remove a file from the queue before processing starts
const removeFromQueue = (clientId) => {
const index = uploadQueue.value.findIndex(item => item.clientId === clientId);
if (index !== -1 && (uploadQueue.value[index].status === 'queued' || uploadQueue.value[index].status === 'ready')) {
uploadQueue.value.splice(index, 1);
console.log(`Removed file from queue: ${clientId}`);
}
};
// Cancel a waiting file from the upload progress queue
const cancelWaitingFile = (clientId) => {
const index = uploadQueue.value.findIndex(item => item.clientId === clientId);
if (index !== -1 && uploadQueue.value[index].status === 'ready') {
uploadQueue.value.splice(index, 1);
console.log(`Cancelled waiting file: ${clientId}`);
showToast(t('upload.fileRemovedFromQueue'), 'fa-trash');
}
};
// Clear completed uploads from queue
const clearCompletedUploads = () => {
uploadQueue.value = uploadQueue.value.filter(item => !['completed', 'failed'].includes(item.status));
};
// Start processing all queued files
const startUpload = () => {
const pendingFiles = uploadQueue.value.filter(item => item.status === 'queued');
if (pendingFiles.length === 0) {
return;
}
// Show upload disclaimer if configured
if (uploadDisclaimer.value && uploadDisclaimer.value.trim() !== '') {
showUploadDisclaimerModal.value = true;
return;
}
// Update all queued files with current tags and ASR options
// AND change their status to 'ready' so they move to upload progress immediately
for (const item of uploadQueue.value) {
if (item.status === 'queued') {
if (!item.preserveOptions) {
// For file uploads: use current UI selection (user may have changed tags after dropping)
item.tags = [...selectedTags.value];
item.asrOptions = {
language: asrLanguage.value,
min_speakers: asrMinSpeakers.value,
max_speakers: asrMaxSpeakers.value,
hotwords: uploadHotwords.value,
initial_prompt: uploadInitialPrompt.value,
};
item.folder_id = selectedFolderId.value;
}
// Change status to 'ready' to remove from upload view but keep in queue
item.status = 'ready';
}
}
progressPopupMinimized.value = false;
progressPopupClosed.value = false;
startProcessingQueue();
};
// --- Parallel Upload System ---
// Concurrency limiter: configurable via MAX_CONCURRENT_UPLOADS env var (default 3)
let activeUploadCount = 0;
const pendingUploadQueue = []; // Functions waiting for a slot
const acquireUploadSlot = () => {
return new Promise(resolve => {
if (activeUploadCount < (maxConcurrentUploads?.value || 3)) {
activeUploadCount++;
resolve();
} else {
pendingUploadQueue.push(resolve);
}
});
};
const releaseUploadSlot = () => {
activeUploadCount--;
if (pendingUploadQueue.length > 0) {
activeUploadCount++;
const next = pendingUploadQueue.shift();
next();
}
// When all uploads are done, clear processing active flag
const stillUploading = uploadQueue.value.some(item =>
['uploading', 'ready'].includes(item.status)
);
if (!stillUploading) {
isProcessingActive.value = false;
}
};
const resetCurrentFileProcessingState = () => {
if (pollInterval.value) clearInterval(pollInterval.value);
pollInterval.value = null;
currentlyProcessingFile.value = null;
processingProgress.value = 0;
processingMessage.value = '';
};
/**
* Upload a single file to the server.
* Acquires a concurrency slot, uploads, then releases.
* Status updates are per-item (no global processingProgress).
*/
const uploadSingleFile = async (fileItem) => {
await acquireUploadSlot();
fileItem.status = 'uploading';
fileItem.progress = 5;
try {
const formData = new FormData();
formData.append('file', fileItem.file);
// Send file's lastModified timestamp for meeting_date
if (fileItem.file.lastModified) {
const lastModified = fileItem.file.lastModified;
formData.append('file_last_modified', lastModified.toString());
}
if (fileItem.notes) {
formData.append('notes', fileItem.notes);
}
// Add tags if selected
const tagsToUse = fileItem.tags || selectedTags.value || [];
tagsToUse.forEach((tag, index) => {
const tagId = tag.id || tag;
formData.append(`tag_ids[${index}]`, tagId);
});
// Add folder if selected
const folderToUse = fileItem.folder_id || selectedFolderId.value;
if (folderToUse) {
formData.append('folder_id', folderToUse);
}
// Add ASR options
const asrOpts = fileItem.asrOptions || {};
const language = asrOpts.language || uploadLanguage.value;
if (language) {
formData.append('language', language);
}
if (connectorSupportsDiarization.value) {
const minSpeakers = asrOpts.min_speakers || uploadMinSpeakers.value;
const maxSpeakers = asrOpts.max_speakers || uploadMaxSpeakers.value;
if (minSpeakers && minSpeakers !== '') {
formData.append('min_speakers', minSpeakers.toString());
}
if (maxSpeakers && maxSpeakers !== '') {
formData.append('max_speakers', maxSpeakers.toString());
}
}
// Add hotwords and initial prompt
const hotwords = asrOpts.hotwords || uploadHotwords.value;
const initialPrompt = asrOpts.initial_prompt || uploadInitialPrompt.value;
if (hotwords && hotwords.trim()) {
formData.append('hotwords', hotwords.trim());
}
if (initialPrompt && initialPrompt.trim()) {
formData.append('initial_prompt', initialPrompt.trim());
}
// Refresh CSRF token before upload (prevents stale token after sleep/idle)
let csrfToken;
if (window.csrfManager) {
try {
csrfToken = await window.csrfManager.refreshToken();
} catch (e) {
csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
}
} else {
csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
}
// Use XMLHttpRequest for per-file upload progress
const data = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
// Map upload progress to 5-90% range
fileItem.progress = Math.round(5 + (e.loaded / e.total) * 85);
}
};
xhr.onload = () => {
const contentType = xhr.getResponseHeader('content-type') || '';
if (!contentType.includes('application/json')) {
const titleMatch = xhr.responseText.match(/<title>([^<]+)<\/title>/i);
const h1Match = xhr.responseText.match(/<h1>([^<]+)<\/h1>/i);
reject(new Error(titleMatch?.[1] || h1Match?.[1] ||
`Server error (${xhr.status}): Response was not JSON`));
return;
}
let parsed;
try {
parsed = JSON.parse(xhr.responseText);
} catch {
reject(new Error(`Invalid JSON response (${xhr.status})`));
return;
}
if (xhr.status === 202 && parsed.id) {
resolve(parsed);
} else if (!String(xhr.status).startsWith('2')) {
let errorMsg = parsed.error || `Upload failed with status ${xhr.status}`;
if (xhr.status === 413) errorMsg = parsed.error || `File too large. Max: ${parsed.max_size_mb?.toFixed(0) || maxFileSizeMB.value} MB.`;
reject(new Error(errorMsg));
} else {
reject(new Error('Unexpected success response from server after upload.'));
}
};
xhr.onerror = () => reject(new Error('Network error during upload'));
xhr.ontimeout = () => reject(new Error('Upload timed out'));
// Store abort controller on item for cancellation
fileItem._xhr = xhr;
xhr.open('POST', '/upload');
if (csrfToken) {
xhr.setRequestHeader('X-CSRFToken', csrfToken);
}
xhr.send(formData);
});
// Upload succeeded - recording is now on the server
console.log(`File ${fileItem.file.name} uploaded. Recording ID: ${data.id}. Server will process via job queue.`);
fileItem.status = 'pending';
fileItem.recordingId = data.id;
fileItem.progress = 100;
// Add to recordings list
recordings.value.unshift(data);
totalRecordings.value++;
// Clear recording session only after confirmed upload
if (fileItem.onUploadSuccess) {
await fileItem.onUploadSuccess();
}
// Handle duplicate warning
if (data.duplicate_warning) {
const warning = data.duplicate_warning;
const existingDate = warning.existing_created_at
? new Date(warning.existing_created_at).toLocaleDateString()
: '';
const existingName = warning.existing_title || 'Unknown';
showToast(
`⚠️ ${existingName} (${existingDate})`,
'fa-copy'
);
fileItem.duplicateWarning = warning;
}
} catch (error) {
console.error(`Upload Error for ${fileItem.file.name} (Client ID: ${fileItem.clientId}):`, error);
fileItem.status = 'failed';
fileItem.error = error.message;
fileItem.progress = 0;
// Show friendly error message
const friendlyErr = getFriendlyError(error.message, t);
setGlobalError(`${friendlyErr.title}: ${friendlyErr.guidance}`);
// Store failed upload in IndexedDB for background sync retry
try {
await FailedUploads.storeFailedUpload({
file: fileItem.file,
fileName: fileItem.file.name,
fileSize: fileItem.file.size,
clientId: fileItem.clientId,
notes: fileItem.notes,
tags: fileItem.tags,
asrOptions: fileItem.asrOptions,
error: error.message
});
if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-uploads');
console.log('[Upload] Registered background sync for failed upload');
}
} catch (syncError) {
console.warn('[Upload] Failed to register background sync:', syncError);
}
} finally {
fileItem._xhr = null;
releaseUploadSlot();
}
};
/**
* Start uploading all ready files in parallel (with concurrency limit).
* Processing status is tracked via allJobs polling in app.modular.js.
*/
const startProcessingQueue = async () => {
const readyItems = uploadQueue.value.filter(item => item.status === 'ready');
if (readyItems.length === 0) {
console.log("No files ready to upload.");
return;
}
isProcessingActive.value = true;
console.log(`Starting parallel upload of ${readyItems.length} file(s) (max ${maxConcurrentUploads?.value || 3} concurrent)...`);
// Fire off all uploads concurrently (semaphore handles limiting)
const uploadPromises = readyItems.map(item => uploadSingleFile(item));
// Don't await - let them run in background. isProcessingActive is cleared by releaseUploadSlot.
Promise.allSettled(uploadPromises).then(() => {
console.log('All uploads settled.');
});
};
// Keep backward-compat aliases
const startStatusPolling = (fileItem, recordingId) => {
// No longer needed - allJobs polling handles status tracking
fileItem.recordingId = recordingId;
};
const pollProcessingStatus = () => {
// No-op: status tracking is now handled by allJobs polling in app.modular.js
};
// Tag selection helpers
const addTagToSelection = (tagId) => {
if (!selectedTagIds.value.includes(tagId)) {
selectedTagIds.value.push(tagId);
applyTagDefaults();
}
};
const removeTagFromSelection = (tagId) => {
const index = selectedTagIds.value.indexOf(tagId);
if (index > -1) {
selectedTagIds.value.splice(index, 1);
applyTagDefaults();
}
};
const applyTagDefaults = () => {
const selectedTagsObjects = selectedTagIds.value.map(tagId =>
availableTags.value.find(tag => tag.id == tagId)
).filter(Boolean);
const firstTag = selectedTagsObjects[0];
if (firstTag && connectorSupportsDiarization.value) {
if (firstTag.default_language) {
uploadLanguage.value = firstTag.default_language;
}
if (firstTag.default_min_speakers) {
uploadMinSpeakers.value = firstTag.default_min_speakers;
}
if (firstTag.default_max_speakers) {
uploadMaxSpeakers.value = firstTag.default_max_speakers;
}
}
// Apply hotwords/initial_prompt from first tag (works for all connectors)
if (firstTag) {
if (firstTag.default_hotwords) {
uploadHotwords.value = firstTag.default_hotwords;
}
if (firstTag.default_initial_prompt) {
uploadInitialPrompt.value = firstTag.default_initial_prompt;
}
}
};
// Computed property for filtered available tags in upload view
const filteredAvailableTagsForUpload = computed(() => {
const availableForSelection = availableTags.value.filter(tag => !selectedTagIds.value.includes(tag.id));
if (!uploadTagSearchFilter.value) return availableForSelection;
const filter = uploadTagSearchFilter.value.toLowerCase();
return availableForSelection.filter(tag =>
tag.name.toLowerCase().includes(filter)
);
});
// === INCOGNITO MODE FUNCTIONS ===
/**
* Upload and process a file in incognito mode.
* The file is processed synchronously and no data is saved to the database.
* Results are stored only in sessionStorage.
*/
const startIncognitoUpload = async () => {
const pendingFiles = uploadQueue.value.filter(item => item.status === 'queued');
if (pendingFiles.length === 0) {
return;
}
// Only process the first file for incognito mode
const fileItem = pendingFiles[0];
// Check if incognito mode state is available
if (!incognitoMode || !incognitoProcessing || !incognitoRecording) {
console.warn('[Incognito] Incognito state not available, falling back to normal upload');
startUpload();
return;
}
incognitoProcessing.value = true;
processingMessage.value = t('incognito.processingInProgress');
processingProgress.value = 10;
progressPopupMinimized.value = false;
progressPopupClosed.value = false;
try {
const formData = new FormData();
formData.append('file', fileItem.file);
// Add ASR options
const asrOpts = fileItem.asrOptions || {};
const language = asrOpts.language || uploadLanguage.value;
const minSpeakers = asrOpts.min_speakers || uploadMinSpeakers.value;
const maxSpeakers = asrOpts.max_speakers || uploadMaxSpeakers.value;
if (language) {
formData.append('language', language);
}
if (minSpeakers && minSpeakers !== '') {
formData.append('min_speakers', minSpeakers.toString());
}
if (maxSpeakers && maxSpeakers !== '') {
formData.append('max_speakers', maxSpeakers.toString());
}
const hotwords = asrOpts.hotwords || uploadHotwords.value;
const initialPrompt = asrOpts.initial_prompt || uploadInitialPrompt.value;
if (hotwords && hotwords.trim()) {
formData.append('hotwords', hotwords.trim());
}
if (initialPrompt && initialPrompt.trim()) {
formData.append('initial_prompt', initialPrompt.trim());
}
// Request auto-summarization
formData.append('auto_summarize', 'true');
processingMessage.value = t('incognito.uploadingFile');
processingProgress.value = 20;
console.log('[Incognito] Uploading file:', fileItem.file.name);
const response = await fetch('/api/recordings/incognito', {
method: 'POST',
body: formData
});
processingProgress.value = 50;
// Parse response
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
const text = await response.text();
const titleMatch = text.match(/<title>([^<]+)<\/title>/i);
throw new Error(titleMatch?.[1] || `Server error (${response.status})`);
}
const data = await response.json();
if (!response.ok || data.error) {
throw new Error(data.error || `Processing failed with status ${response.status}`);
}
processingProgress.value = 80;
processingMessage.value = t('incognito.processingComplete');
// Store result in sessionStorage
const incognitoData = {
id: 'incognito',
incognito: true,
title: data.title || t('incognito.recordingTitle'),
transcription: data.transcription,
summary: data.summary,
summary_html: data.summary_html,
created_at: data.created_at,
original_filename: data.original_filename,
file_size: data.file_size,
audio_duration_seconds: data.audio_duration_seconds,
processing_time_seconds: data.processing_time_seconds,
status: 'COMPLETED'
};
IncognitoStorage.saveIncognitoRecording(incognitoData);
incognitoRecording.value = incognitoData;
// Remove the processed file from queue
const index = uploadQueue.value.findIndex(item => item.clientId === fileItem.clientId);
if (index !== -1) {
uploadQueue.value.splice(index, 1);
}
processingProgress.value = 100;
processingMessage.value = t('incognito.recordingReady');
// Auto-select the incognito recording and switch to detail view
selectedRecording.value = incognitoData;
currentView.value = 'detail';
// Show toast
showToast(t('incognito.recordingProcessed'), 'fa-user-secret');
console.log('[Incognito] Processing complete');
} catch (error) {
console.error('[Incognito] Processing failed:', error);
const friendlyErr = getFriendlyError(error.message, t);
setGlobalError(`${friendlyErr.title}: ${friendlyErr.guidance}`);
fileItem.status = 'failed';
fileItem.error = error.message;
} finally {
incognitoProcessing.value = false;
processingProgress.value = 0;
processingMessage.value = '';
}
};
/**
* Clear the incognito recording with confirmation
*/
const clearIncognitoRecordingWithConfirm = () => {
if (incognitoRecording && incognitoRecording.value) {
if (confirm(t('incognito.discardConfirm'))) {
IncognitoStorage.clearIncognitoRecording();
incognitoRecording.value = null;
// If the incognito recording was selected, clear selection
if (selectedRecording.value?.id === 'incognito') {
selectedRecording.value = null;
}
showToast(t('incognito.recordingDiscarded'), 'fa-trash');
}
}
};
/**
* Select the incognito recording for viewing
*/
const selectIncognitoRecording = () => {
if (incognitoRecording && incognitoRecording.value) {
selectedRecording.value = incognitoRecording.value;
currentView.value = 'detail';
}
};
/**
* Load incognito recording from sessionStorage on app init
*/
const loadIncognitoRecording = () => {
const stored = IncognitoStorage.getIncognitoRecording();
if (stored && incognitoRecording) {
incognitoRecording.value = stored;
console.log('[Incognito] Loaded recording from sessionStorage');
}
};
/**
* Check if there's an incognito recording (for navigation guards)
*/
const hasIncognitoRecording = () => {
return IncognitoStorage.hasIncognitoRecording();
};
return {
handleDragOver,
handleDragLeave,
handleDrop,
handleFileSelect,
addFilesToQueue,
removeFromQueue,
cancelWaitingFile,
clearCompletedUploads,
startUpload,
startProcessingQueue,
resetCurrentFileProcessingState,
startStatusPolling,
pollProcessingStatus,
addTagToSelection,
removeTagFromSelection,
applyTagDefaults,
filteredAvailableTagsForUpload,
// Tag drag-and-drop
draggedTagIndex,
dragOverTagIndex,
handleTagDragStart,
handleTagDragOver,
handleTagDrop,
handleTagDragEnd,
handleTagTouchStart,
handleTagTouchMove,
handleTagTouchEnd,
// Incognito mode
startIncognitoUpload,
clearIncognitoRecordingWithConfirm,
selectIncognitoRecording,
loadIncognitoRecording,
hasIncognitoRecording
};
}

View File

@@ -0,0 +1,204 @@
/**
* Virtual Scrolling Composable
*
* Renders only visible items plus a buffer for smooth scrolling.
* Critical for handling long transcriptions (4500+ segments) without UI lag.
*
* Usage:
* const { visibleItems, spacerBefore, spacerAfter, onScroll, scrollToIndex } = useVirtualScroll({
* items: segmentsRef,
* itemHeight: 48,
* containerRef: scrollContainerRef,
* overscan: 5
* });
*/
export function useVirtualScroll(options) {
const { ref, computed, watch, onMounted, onUnmounted } = Vue;
const {
items, // Ref to the full array of items
itemHeight = 48, // Height of each item in pixels (fixed height mode)
containerRef, // Ref to the scrollable container element
overscan = 5, // Number of items to render outside viewport
keyField = null // Optional field to use for unique keys
} = options;
// Internal state
const scrollTop = ref(0);
const containerHeight = ref(0);
const isInitialized = ref(false);
// Calculate visible range based on scroll position
const visibleRange = computed(() => {
if (!isInitialized.value || !items.value) {
return { start: 0, end: Math.min(20, items.value?.length || 0) };
}
const totalItems = items.value.length;
if (totalItems === 0) {
return { start: 0, end: 0 };
}
// Calculate first visible item
const firstVisible = Math.floor(scrollTop.value / itemHeight);
// Calculate number of items that fit in viewport
const visibleCount = Math.ceil(containerHeight.value / itemHeight);
// Add overscan for smooth scrolling
const start = Math.max(0, firstVisible - overscan);
const end = Math.min(totalItems, firstVisible + visibleCount + overscan);
return { start, end };
});
// Slice of items to actually render
const visibleItems = computed(() => {
if (!items.value || items.value.length === 0) {
return [];
}
const { start, end } = visibleRange.value;
// Map items with their original indices for proper data binding
return items.value.slice(start, end).map((item, localIndex) => ({
...item,
_virtualIndex: start + localIndex,
_originalIndex: start + localIndex
}));
});
// Spacer height before visible items (for scroll position)
const spacerBefore = computed(() => {
return visibleRange.value.start * itemHeight;
});
// Spacer height after visible items
const spacerAfter = computed(() => {
if (!items.value) return 0;
const remainingItems = items.value.length - visibleRange.value.end;
return Math.max(0, remainingItems * itemHeight);
});
// Total height of all items (for scroll container)
const totalHeight = computed(() => {
if (!items.value) return 0;
return items.value.length * itemHeight;
});
// Handle scroll events
const onScroll = (event) => {
scrollTop.value = event.target.scrollTop;
};
// Initialize container height observer
let resizeObserver = null;
const initializeContainer = () => {
if (!containerRef.value) return;
// Get initial height
containerHeight.value = containerRef.value.clientHeight;
isInitialized.value = true;
// Watch for container size changes
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
containerHeight.value = entry.contentRect.height;
}
});
resizeObserver.observe(containerRef.value);
};
// Scroll to a specific index
const scrollToIndex = (index, behavior = 'smooth') => {
if (!containerRef.value || !items.value) return;
const targetIndex = Math.max(0, Math.min(index, items.value.length - 1));
const targetScrollTop = targetIndex * itemHeight;
containerRef.value.scrollTo({
top: targetScrollTop,
behavior
});
};
// Scroll to make an index visible (centered if possible)
const scrollToIndexIfNeeded = (index) => {
if (!containerRef.value || !items.value) return;
const { start, end } = visibleRange.value;
// Check if index is already visible (with some margin)
if (index >= start + overscan && index < end - overscan) {
return; // Already visible
}
// Center the index in the viewport
const targetIndex = Math.max(0, index - Math.floor(containerHeight.value / itemHeight / 2));
scrollToIndex(targetIndex, 'smooth');
};
// Reset scroll state (call when modal opens or items change completely)
const reset = () => {
scrollTop.value = 0;
isInitialized.value = false;
// Re-initialize after a tick to allow DOM to render
Vue.nextTick(() => {
if (containerRef.value) {
containerRef.value.scrollTop = 0;
initializeContainer();
}
});
};
// Watch for containerRef changes and initialize
watch(containerRef, (newRef) => {
if (newRef) {
initializeContainer();
}
}, { immediate: true });
// Cleanup on unmount
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect();
}
});
return {
// Data
visibleItems,
visibleRange,
// Spacer heights for virtual scroll container
spacerBefore,
spacerAfter,
totalHeight,
// Event handlers
onScroll,
// Navigation
scrollToIndex,
scrollToIndexIfNeeded,
// Control
reset,
// State (for debugging/testing)
scrollTop,
containerHeight,
isInitialized
};
}
/**
* Helper to generate a unique key for virtual scroll items
*/
export function getVirtualItemKey(item, prefix = 'vs') {
const index = item._originalIndex ?? item._virtualIndex ?? 0;
const time = item.startTime ?? item.start_time ?? '';
return `${prefix}-${index}-${time}`;
}

View File

@@ -0,0 +1,26 @@
/**
* Computed properties module exports
*
* Note: These computed properties are defined inline in the main app.js
* due to their tight coupling with reactive state. This module serves as
* a placeholder for future extraction if needed.
*/
// Computed properties that could be extracted:
// - filteredRecordings
// - groupedRecordings
// - highlightedTranscript
// - activeRecordingMetadata
// - identifiedSpeakers
// - processedTranscription
// - totalInQueue
// - completedInQueue
// - finishedFilesInQueue
// - isMobileScreen
// - datePresetOptions
// - languageOptions
// - tagsWithCustomPrompts
// - filteredAvailableTagsForModal
// - isMobileDevice
export default {};

View File

@@ -0,0 +1,272 @@
/**
* IndexedDB Failed Uploads Storage
* Handles storing and retrying failed uploads with background sync
*/
const DB_NAME = 'SpeakrFailedUploads';
const DB_VERSION = 1;
const STORE_NAME = 'failedUploads';
let dbInstance = null;
/**
* Initialize IndexedDB
*/
export const initDB = () => {
return new Promise((resolve, reject) => {
if (dbInstance) {
resolve(dbInstance);
return;
}
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => {
console.error('[FailedUploadsDB] Failed to open database:', request.error);
reject(request.error);
};
request.onsuccess = () => {
dbInstance = request.result;
console.log('[FailedUploadsDB] Database opened successfully');
resolve(dbInstance);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create object store for failed uploads
if (!db.objectStoreNames.contains(STORE_NAME)) {
const objectStore = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
objectStore.createIndex('timestamp', 'timestamp', { unique: false });
objectStore.createIndex('clientId', 'clientId', { unique: false });
console.log('[FailedUploadsDB] Object store created');
}
};
});
};
/**
* Store a failed upload for later retry
*/
export const storeFailedUpload = async (uploadData) => {
try {
const db = await initDB();
// Convert File to ArrayBuffer BEFORE opening transaction.
// IDB transactions auto-close when inactive — the async arrayBuffer()
// call would cause the transaction to expire before add().
let fileData = uploadData.fileData || null;
if (uploadData.file && !fileData) {
fileData = await uploadData.file.arrayBuffer();
}
const transaction = db.transaction([STORE_NAME], 'readwrite');
const objectStore = transaction.objectStore(STORE_NAME);
const failedUpload = {
timestamp: Date.now(),
clientId: uploadData.clientId || `client-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
fileName: uploadData.file?.name || uploadData.fileName || 'unknown',
fileSize: uploadData.file?.size || uploadData.fileSize || 0,
notes: uploadData.notes || '',
tags: uploadData.tags || [],
asrOptions: uploadData.asrOptions || {},
retryCount: uploadData.retryCount || 0,
lastError: uploadData.error || '',
fileData: fileData,
mimeType: uploadData.file?.type || uploadData.mimeType || 'audio/webm'
};
const request = objectStore.add(failedUpload);
return new Promise((resolve, reject) => {
request.onsuccess = () => {
console.log('[FailedUploadsDB] Upload stored for retry:', failedUpload.fileName);
resolve(request.result); // Returns the ID
};
request.onerror = () => {
console.error('[FailedUploadsDB] Failed to store upload:', request.error);
reject(request.error);
};
});
} catch (error) {
console.error('[FailedUploadsDB] Error storing failed upload:', error);
throw error;
}
};
/**
* Get all failed uploads waiting to retry
*/
export const getFailedUploads = async () => {
try {
const db = await initDB();
const transaction = db.transaction([STORE_NAME], 'readonly');
const objectStore = transaction.objectStore(STORE_NAME);
return new Promise((resolve, reject) => {
const request = objectStore.getAll();
request.onsuccess = () => {
console.log(`[FailedUploadsDB] Retrieved ${request.result.length} failed uploads`);
resolve(request.result);
};
request.onerror = () => {
console.error('[FailedUploadsDB] Failed to retrieve uploads:', request.error);
reject(request.error);
};
});
} catch (error) {
console.error('[FailedUploadsDB] Error getting failed uploads:', error);
return [];
}
};
/**
* Get a specific failed upload by ID
*/
export const getFailedUpload = async (id) => {
try {
const db = await initDB();
const transaction = db.transaction([STORE_NAME], 'readonly');
const objectStore = transaction.objectStore(STORE_NAME);
return new Promise((resolve, reject) => {
const request = objectStore.get(id);
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
console.error('[FailedUploadsDB] Failed to get upload:', request.error);
reject(request.error);
};
});
} catch (error) {
console.error('[FailedUploadsDB] Error getting failed upload:', error);
return null;
}
};
/**
* Update retry count for a failed upload
*/
export const updateRetryCount = async (id, retryCount, error = null) => {
try {
const db = await initDB();
const transaction = db.transaction([STORE_NAME], 'readwrite');
const objectStore = transaction.objectStore(STORE_NAME);
const upload = await getFailedUpload(id);
if (!upload) {
console.warn('[FailedUploadsDB] Upload not found for retry count update');
return;
}
upload.retryCount = retryCount;
upload.lastRetry = Date.now();
if (error) {
upload.lastError = error;
}
return new Promise((resolve, reject) => {
const request = objectStore.put(upload);
request.onsuccess = () => {
console.log(`[FailedUploadsDB] Updated retry count for upload ${id}: ${retryCount}`);
resolve();
};
request.onerror = () => {
console.error('[FailedUploadsDB] Failed to update retry count:', request.error);
reject(request.error);
};
});
} catch (error) {
console.error('[FailedUploadsDB] Error updating retry count:', error);
}
};
/**
* Delete a failed upload (after successful retry)
*/
export const deleteFailedUpload = async (id) => {
try {
const db = await initDB();
const transaction = db.transaction([STORE_NAME], 'readwrite');
const objectStore = transaction.objectStore(STORE_NAME);
return new Promise((resolve, reject) => {
const request = objectStore.delete(id);
request.onsuccess = () => {
console.log('[FailedUploadsDB] Deleted successful upload:', id);
resolve();
};
request.onerror = () => {
console.error('[FailedUploadsDB] Failed to delete upload:', request.error);
reject(request.error);
};
});
} catch (error) {
console.error('[FailedUploadsDB] Error deleting failed upload:', error);
}
};
/**
* Clear all failed uploads
*/
export const clearAllFailedUploads = async () => {
try {
const db = await initDB();
const transaction = db.transaction([STORE_NAME], 'readwrite');
const objectStore = transaction.objectStore(STORE_NAME);
return new Promise((resolve, reject) => {
const request = objectStore.clear();
request.onsuccess = () => {
console.log('[FailedUploadsDB] Cleared all failed uploads');
resolve();
};
request.onerror = () => {
console.error('[FailedUploadsDB] Failed to clear uploads:', request.error);
reject(request.error);
};
});
} catch (error) {
console.error('[FailedUploadsDB] Error clearing failed uploads:', error);
}
};
/**
* Get count of failed uploads
*/
export const getFailedUploadCount = async () => {
try {
const db = await initDB();
const transaction = db.transaction([STORE_NAME], 'readonly');
const objectStore = transaction.objectStore(STORE_NAME);
return new Promise((resolve, reject) => {
const request = objectStore.count();
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
console.error('[FailedUploadsDB] Failed to count uploads:', request.error);
reject(request.error);
};
});
} catch (error) {
console.error('[FailedUploadsDB] Error counting failed uploads:', error);
return 0;
}
};

View File

@@ -0,0 +1,76 @@
/**
* Incognito Mode storage utilities
* Uses sessionStorage for temporary storage that auto-clears when tab closes
*/
const INCOGNITO_KEY = 'speakr_incognito_recording';
/**
* Save incognito recording data to sessionStorage
* @param {Object} data - Recording data including transcription, summary, title
*/
export function saveIncognitoRecording(data) {
try {
sessionStorage.setItem(INCOGNITO_KEY, JSON.stringify(data));
console.log('[Incognito] Recording saved to sessionStorage');
} catch (e) {
console.error('[Incognito] Failed to save recording:', e);
}
}
/**
* Get incognito recording data from sessionStorage
* @returns {Object|null} Recording data or null if not found
*/
export function getIncognitoRecording() {
try {
const data = sessionStorage.getItem(INCOGNITO_KEY);
return data ? JSON.parse(data) : null;
} catch (e) {
console.error('[Incognito] Failed to retrieve recording:', e);
return null;
}
}
/**
* Clear incognito recording from sessionStorage
*/
export function clearIncognitoRecording() {
try {
sessionStorage.removeItem(INCOGNITO_KEY);
console.log('[Incognito] Recording cleared from sessionStorage');
} catch (e) {
console.error('[Incognito] Failed to clear recording:', e);
}
}
/**
* Check if an incognito recording exists
* @returns {boolean}
*/
export function hasIncognitoRecording() {
try {
return sessionStorage.getItem(INCOGNITO_KEY) !== null;
} catch (e) {
return false;
}
}
/**
* Update specific fields of the incognito recording
* @param {Object} updates - Fields to update
*/
export function updateIncognitoRecording(updates) {
try {
const existing = getIncognitoRecording();
if (existing) {
const updated = { ...existing, ...updates };
saveIncognitoRecording(updated);
return updated;
}
return null;
} catch (e) {
console.error('[Incognito] Failed to update recording:', e);
return null;
}
}

View File

@@ -0,0 +1,267 @@
/**
* IndexedDB Recording Persistence
* Handles saving recording chunks to IndexedDB for crash recovery
*/
const DB_NAME = 'SpeakrRecordings';
const DB_VERSION = 1;
const STORE_NAME = 'activeRecording';
let dbInstance = null;
/**
* Helper to promisify IDBRequest
*/
const promisifyRequest = (request) => {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
};
/**
* Initialize IndexedDB
*/
export const initDB = () => {
return new Promise((resolve, reject) => {
if (dbInstance) {
resolve(dbInstance);
return;
}
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => {
console.error('[RecordingDB] Failed to open database:', request.error);
reject(request.error);
};
request.onsuccess = () => {
dbInstance = request.result;
console.log('[RecordingDB] Database opened successfully');
resolve(dbInstance);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create object store for active recording
if (!db.objectStoreNames.contains(STORE_NAME)) {
const objectStore = db.createObjectStore(STORE_NAME, { keyPath: 'id' });
objectStore.createIndex('timestamp', 'timestamp', { unique: false });
console.log('[RecordingDB] Object store created');
}
};
});
};
/**
* Save recording metadata and initialize session
*/
export const startRecordingSession = async (recordingData) => {
try {
const db = await initDB();
const transaction = db.transaction([STORE_NAME], 'readwrite');
const objectStore = transaction.objectStore(STORE_NAME);
const session = {
id: 'current',
timestamp: Date.now(),
startTime: new Date().toISOString(),
mode: recordingData.mode,
notes: recordingData.notes || '',
tags: recordingData.tags || [],
asrOptions: recordingData.asrOptions || {},
chunks: [],
mimeType: recordingData.mimeType || 'audio/webm',
duration: 0
};
await promisifyRequest(objectStore.put(session));
console.log('[RecordingDB] Recording session started:', session.id);
return session;
} catch (error) {
console.error('[RecordingDB] Failed to start session:', error);
throw error;
}
};
/**
* Save a recording chunk to IndexedDB
*/
export const saveChunk = async (chunkBlob, chunkIndex) => {
try {
// Do async prep work BEFORE creating transaction to avoid auto-close
const db = await initDB();
const arrayBuffer = await chunkBlob.arrayBuffer();
// Now create transaction and do all DB operations quickly
const transaction = db.transaction([STORE_NAME], 'readwrite');
const objectStore = transaction.objectStore(STORE_NAME);
// Get current session
const session = await promisifyRequest(objectStore.get('current'));
if (!session) {
console.warn('[RecordingDB] No active session found');
return;
}
// Add chunk to session
session.chunks.push({
index: chunkIndex,
data: arrayBuffer,
size: chunkBlob.size,
timestamp: Date.now()
});
// Update session - must happen before transaction auto-closes
await promisifyRequest(objectStore.put(session));
// Chunk saved silently to avoid spam (happens every 5 seconds)
} catch (error) {
console.error('[RecordingDB] Failed to save chunk:', error);
// Don't throw - recording should continue even if persistence fails
}
};
/**
* Update recording metadata (notes, duration, etc.)
*/
export const updateRecordingMetadata = async (updates) => {
try {
const db = await initDB();
const transaction = db.transaction([STORE_NAME], 'readwrite');
const objectStore = transaction.objectStore(STORE_NAME);
const session = await promisifyRequest(objectStore.get('current'));
if (!session) {
console.warn('[RecordingDB] No active session to update');
return;
}
// Merge updates
Object.assign(session, updates);
await promisifyRequest(objectStore.put(session));
// Metadata updated silently to avoid spam (happens every 5 seconds)
} catch (error) {
console.error('[RecordingDB] Failed to update metadata:', error);
}
};
/**
* Check if there's a recoverable recording
*/
export const checkForRecoverableRecording = async () => {
try {
const db = await initDB();
const transaction = db.transaction([STORE_NAME], 'readonly');
const objectStore = transaction.objectStore(STORE_NAME);
const session = await promisifyRequest(objectStore.get('current'));
if (!session || !session.chunks || session.chunks.length === 0) {
return null;
}
// Calculate total size
const totalSize = session.chunks.reduce((sum, chunk) => sum + chunk.size, 0);
// Calculate approximate duration (1 second chunks)
const duration = session.chunks.length;
console.log('[RecordingDB] Found recoverable recording:', {
chunks: session.chunks.length,
size: totalSize,
duration: duration,
startTime: session.startTime
});
return {
...session,
totalSize,
duration: duration
};
} catch (error) {
console.error('[RecordingDB] Failed to check for recoverable recording:', error);
return null;
}
};
/**
* Recover recording from IndexedDB
*/
export const recoverRecording = async () => {
try {
const db = await initDB();
const transaction = db.transaction([STORE_NAME], 'readonly');
const objectStore = transaction.objectStore(STORE_NAME);
const session = await promisifyRequest(objectStore.get('current'));
if (!session || !session.chunks || session.chunks.length === 0) {
console.warn('[RecordingDB] No recording to recover');
return null;
}
// Convert chunks back to Blobs
const chunks = session.chunks.map(chunk => {
return new Blob([chunk.data], { type: session.mimeType });
});
console.log(`[RecordingDB] Recovered ${chunks.length} chunks`);
return {
chunks,
metadata: {
mode: session.mode,
notes: session.notes,
tags: session.tags,
asrOptions: session.asrOptions,
mimeType: session.mimeType,
duration: session.chunks.length,
startTime: session.startTime
}
};
} catch (error) {
console.error('[RecordingDB] Failed to recover recording:', error);
return null;
}
};
/**
* Clear recording session (after successful upload or discard)
*/
export const clearRecordingSession = async () => {
try {
const db = await initDB();
const transaction = db.transaction([STORE_NAME], 'readwrite');
const objectStore = transaction.objectStore(STORE_NAME);
await promisifyRequest(objectStore.delete('current'));
console.log('[RecordingDB] Recording session cleared');
} catch (error) {
console.error('[RecordingDB] Failed to clear session:', error);
}
};
/**
* Get database size information
*/
export const getDatabaseSize = async () => {
try {
if (!navigator.storage || !navigator.storage.estimate) {
return null;
}
const estimate = await navigator.storage.estimate();
return {
usage: estimate.usage,
quota: estimate.quota,
percentage: ((estimate.usage / estimate.quota) * 100).toFixed(2)
};
} catch (error) {
console.error('[RecordingDB] Failed to get database size:', error);
return null;
}
};

View File

@@ -0,0 +1,95 @@
/**
* Audio recording state management
*/
export function createAudioState(ref, computed) {
// --- Audio Recording State ---
const isRecording = ref(false);
const mediaRecorder = ref(null);
const audioChunks = ref([]);
const audioBlobURL = ref(null);
const recordingTime = ref(0);
const recordingInterval = ref(null);
const canRecordAudio = ref(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
const canRecordSystemAudio = computed(() => navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia);
const systemAudioSupported = ref(false);
const systemAudioError = ref('');
const recordingNotes = ref('');
const showSystemAudioHelp = ref(false);
// ASR options for recording view
const asrLanguage = ref('');
const asrMinSpeakers = ref('');
const asrMaxSpeakers = ref('');
// Audio context and analyzers
const audioContext = ref(null);
const analyser = ref(null);
const micAnalyser = ref(null);
const systemAnalyser = ref(null);
const visualizer = ref(null);
const micVisualizer = ref(null);
const systemVisualizer = ref(null);
const animationFrameId = ref(null);
const recordingMode = ref('microphone');
const activeStreams = ref([]);
// --- Wake Lock and Background Recording ---
const wakeLock = ref(null);
const recordingNotification = ref(null);
const isPageVisible = ref(true);
// --- Recording Size Monitoring ---
const estimatedFileSize = ref(0);
const fileSizeWarningShown = ref(false);
const recordingQuality = ref('optimized');
const actualBitrate = ref(0);
const maxRecordingMB = ref(200);
const sizeCheckInterval = ref(null);
return {
// Recording state
isRecording,
mediaRecorder,
audioChunks,
audioBlobURL,
recordingTime,
recordingInterval,
canRecordAudio,
canRecordSystemAudio,
systemAudioSupported,
systemAudioError,
recordingNotes,
showSystemAudioHelp,
// ASR options
asrLanguage,
asrMinSpeakers,
asrMaxSpeakers,
// Audio context
audioContext,
analyser,
micAnalyser,
systemAnalyser,
visualizer,
micVisualizer,
systemVisualizer,
animationFrameId,
recordingMode,
activeStreams,
// Wake lock
wakeLock,
recordingNotification,
isPageVisible,
// Size monitoring
estimatedFileSize,
fileSizeWarningShown,
recordingQuality,
actualBitrate,
maxRecordingMB,
sizeCheckInterval
};
}

View File

@@ -0,0 +1,23 @@
/**
* Chat state management
*/
export function createChatState(ref) {
const showChat = ref(false);
const isChatMaximized = ref(false);
const chatMessages = ref([]);
const chatInput = ref('');
const isChatLoading = ref(false);
const chatMessagesRef = ref(null);
const chatInputRef = ref(null);
return {
showChat,
isChatMaximized,
chatMessages,
chatInput,
isChatLoading,
chatMessagesRef,
chatInputRef
};
}

View File

@@ -0,0 +1,164 @@
/**
* Core application state
*/
export function createCoreState(ref, computed) {
// --- Core State ---
const currentView = ref('upload');
const dragover = ref(false);
const recordings = ref([]);
const selectedRecording = ref(null);
const selectedTab = ref('summary');
const searchQuery = ref('');
const isLoadingRecordings = ref(true);
const globalError = ref(null);
// --- Pagination State ---
const currentPage = ref(1);
const perPage = ref(25);
const totalRecordings = ref(0);
const totalPages = ref(0);
const hasNextPage = ref(false);
const hasPrevPage = ref(false);
const isLoadingMore = ref(false);
const searchDebounceTimer = ref(null);
// --- Enhanced Search & Organization State ---
const sortBy = ref('created_at');
const selectedTagFilter = ref(null);
// Advanced filter state
const showAdvancedFilters = ref(false);
const filterTags = ref([]);
const filterSpeakers = ref([]); // Array of speaker names for filtering
const filterDateRange = ref({ start: '', end: '' });
const filterDatePreset = ref('');
const filterTextQuery = ref('');
const showArchivedRecordings = ref(false);
const showSharedWithMe = ref(false);
// --- App Configuration ---
const useAsrEndpoint = ref(false);
const connectorSupportsDiarization = ref(false); // Connector capability for diarization UI
const connectorSupportsSpeakerCount = ref(false); // Connector capability for min/max speakers
const currentUserName = ref('');
const canDeleteRecordings = ref(true);
const enableInternalSharing = ref(false);
const enableArchiveToggle = ref(false);
const showUsernamesInUI = ref(false);
// --- Incognito Mode State ---
const enableIncognitoMode = ref(false); // Server config - whether feature is available
const incognitoMode = ref(false); // User toggle - whether to use incognito for current upload
const incognitoRecording = ref(null);
const incognitoProcessing = ref(false);
// Tag Selection
const availableTags = ref([]);
const selectedTagIds = ref([]);
const uploadTagSearchFilter = ref('');
// Folder Selection
const availableFolders = ref([]);
const selectedFolderId = ref(null);
const foldersEnabled = ref(false);
const filterFolder = ref(''); // '' = all, 'none' = no folder, or folder id
// Speaker Selection
const availableSpeakers = ref([]);
const selectedTags = computed(() => {
return selectedTagIds.value.map(tagId =>
availableTags.value.find(tag => tag.id == tagId)
).filter(Boolean);
});
const filteredAvailableTagsForUpload = computed(() => {
const availableForSelection = availableTags.value.filter(tag => !selectedTagIds.value.includes(tag.id));
if (!uploadTagSearchFilter.value) return availableForSelection;
const filter = uploadTagSearchFilter.value.toLowerCase();
return availableForSelection.filter(tag =>
tag.name.toLowerCase().includes(filter)
);
});
const filteredRecordings = computed(() => {
return recordings.value;
});
const setGlobalError = (message, duration = 7000) => {
globalError.value = message;
if (duration > 0) {
setTimeout(() => { if (globalError.value === message) globalError.value = null; }, duration);
}
};
return {
// Core
currentView,
dragover,
recordings,
selectedRecording,
selectedTab,
searchQuery,
isLoadingRecordings,
globalError,
setGlobalError,
// Pagination
currentPage,
perPage,
totalRecordings,
totalPages,
hasNextPage,
hasPrevPage,
isLoadingMore,
searchDebounceTimer,
// Search & Organization
sortBy,
selectedTagFilter,
showAdvancedFilters,
filterTags,
filterSpeakers,
filterDateRange,
filterDatePreset,
filterTextQuery,
showArchivedRecordings,
showSharedWithMe,
// App Configuration
useAsrEndpoint,
connectorSupportsDiarization,
connectorSupportsSpeakerCount,
currentUserName,
canDeleteRecordings,
enableInternalSharing,
enableArchiveToggle,
showUsernamesInUI,
// Tags
availableTags,
selectedTagIds,
uploadTagSearchFilter,
selectedTags,
filteredAvailableTagsForUpload,
filteredRecordings,
// Folders
availableFolders,
selectedFolderId,
foldersEnabled,
filterFolder,
// Speakers
availableSpeakers,
// Incognito Mode
enableIncognitoMode,
incognitoMode,
incognitoRecording,
incognitoProcessing
};
}

View File

@@ -0,0 +1,11 @@
/**
* State module exports
*/
export { createCoreState } from './core.js';
export { createUIState } from './ui.js';
export { createUploadState } from './upload.js';
export { createAudioState } from './audio.js';
export { createModalState } from './modals.js';
export { createChatState } from './chat.js';
export { createSharingState } from './sharing.js';

View File

@@ -0,0 +1,193 @@
/**
* Modal state management
*/
export function createModalState(ref, reactive) {
// --- Modal Visibility State ---
const showEditModal = ref(false);
const showDeleteModal = ref(false);
const showEditTagsModal = ref(false);
const showReprocessModal = ref(false);
const showResetModal = ref(false);
const showSpeakerModal = ref(false);
const speakerModalTab = ref('speakers'); // 'speakers' or 'transcript' for mobile view
const showShareModal = ref(false);
const showSharesListModal = ref(false);
const showTextEditorModal = ref(false);
const showAsrEditorModal = ref(false);
const showEditSpeakersModal = ref(false);
const showEditTextModal = ref(false);
const showAddSpeakerModal = ref(false);
const showShareDeleteModal = ref(false);
const showUnifiedShareModal = ref(false);
const showDateTimePicker = ref(false);
// --- DateTime Picker State ---
const pickerMonth = ref(new Date().getMonth());
const pickerYear = ref(new Date().getFullYear());
const pickerHour = ref(12);
const pickerMinute = ref(0);
const pickerAmPm = ref('PM');
const pickerSelectedDate = ref(null);
const dateTimePickerTarget = ref(null); // 'meeting_date' or other field name
const dateTimePickerCallback = ref(null); // callback function after applying
// --- Modal Data State ---
const selectedNewTagId = ref('');
const tagSearchFilter = ref('');
const editingRecording = ref(null);
const editingTranscriptionContent = ref('');
const editingSegments = ref([]);
const availableSpeakers = ref([]);
const editingSpeakersList = ref([]);
const databaseSpeakers = ref([]);
const editingSpeakerSuggestions = ref({});
const recordingToDelete = ref(null);
const recordingToReset = ref(null);
const reprocessType = ref(null);
const reprocessRecording = ref(null);
const isAutoIdentifying = ref(false);
const asrReprocessOptions = reactive({
language: '',
min_speakers: null,
max_speakers: null
});
const summaryReprocessPromptSource = ref('default');
const summaryReprocessSelectedTagId = ref('');
const summaryReprocessCustomPrompt = ref('');
const speakerMap = ref({});
const regenerateSummaryAfterSpeakerUpdate = ref(true);
const speakerSuggestions = ref({});
const loadingSuggestions = ref({});
const activeSpeakerInput = ref(null);
const voiceSuggestions = ref({});
const loadingVoiceSuggestions = ref(false);
// --- Transcript Editing State ---
const editingSegmentIndex = ref(null);
const editingSpeakerIndex = ref(null);
const editedText = ref('');
const newSpeakerName = ref('');
const newSpeakerIsMe = ref(false);
const editedTranscriptData = ref(null);
// --- Inline Editing State ---
const editingParticipants = ref(false);
const editingMeetingDate = ref(false);
const editingSummary = ref(false);
const editingNotes = ref(false);
const tempNotesContent = ref('');
const tempSummaryContent = ref('');
const autoSaveTimer = ref(null);
const autoSaveDelay = 2000;
// --- Markdown Editor State ---
const notesMarkdownEditor = ref(null);
const markdownEditorInstance = ref(null);
const summaryMarkdownEditor = ref(null);
const summaryMarkdownEditorInstance = ref(null);
const recordingNotesEditor = ref(null);
const recordingMarkdownEditorInstance = ref(null);
// --- Dropdown Positions ---
const dropdownPositions = ref({});
const editSpeakerDropdownPositions = ref({});
// --- Single-ref dropdown tracking (performance optimization) ---
// Instead of each segment having showSuggestions property (O(n) to close all),
// track which dropdown is open with a single ref (O(1) operations)
const openAsrDropdownIndex = ref(null);
return {
// Modal visibility
showEditModal,
showDeleteModal,
showEditTagsModal,
showReprocessModal,
showResetModal,
showSpeakerModal,
speakerModalTab,
showShareModal,
showSharesListModal,
showTextEditorModal,
showAsrEditorModal,
showEditSpeakersModal,
showEditTextModal,
showAddSpeakerModal,
showShareDeleteModal,
showUnifiedShareModal,
showDateTimePicker,
// DateTime picker
pickerMonth,
pickerYear,
pickerHour,
pickerMinute,
pickerAmPm,
pickerSelectedDate,
dateTimePickerTarget,
dateTimePickerCallback,
// Modal data
selectedNewTagId,
tagSearchFilter,
editingRecording,
editingTranscriptionContent,
editingSegments,
availableSpeakers,
editingSpeakersList,
databaseSpeakers,
editingSpeakerSuggestions,
recordingToDelete,
recordingToReset,
reprocessType,
reprocessRecording,
isAutoIdentifying,
asrReprocessOptions,
summaryReprocessPromptSource,
summaryReprocessSelectedTagId,
summaryReprocessCustomPrompt,
speakerMap,
regenerateSummaryAfterSpeakerUpdate,
speakerSuggestions,
loadingSuggestions,
activeSpeakerInput,
voiceSuggestions,
loadingVoiceSuggestions,
// Transcript editing
editingSegmentIndex,
editingSpeakerIndex,
editedText,
newSpeakerName,
newSpeakerIsMe,
editedTranscriptData,
// Inline editing
editingParticipants,
editingMeetingDate,
editingSummary,
editingNotes,
tempNotesContent,
tempSummaryContent,
autoSaveTimer,
autoSaveDelay,
// Markdown editors
notesMarkdownEditor,
markdownEditorInstance,
summaryMarkdownEditor,
summaryMarkdownEditorInstance,
recordingNotesEditor,
recordingMarkdownEditorInstance,
// Dropdown positions
dropdownPositions,
editSpeakerDropdownPositions,
// Single-ref dropdown tracking
openAsrDropdownIndex
};
}

View File

@@ -0,0 +1,39 @@
/**
* PWA state management
*/
export function createPWAState(ref) {
// --- Install Prompt ---
const deferredInstallPrompt = ref(null);
const showInstallButton = ref(false);
const isPWAInstalled = ref(false);
// --- Notifications ---
const notificationPermission = ref('default');
const pushSubscription = ref(null);
// --- Badging ---
const appBadgeCount = ref(0);
// --- Media Session ---
const currentMediaMetadata = ref(null);
const isMediaSessionActive = ref(false);
return {
// Install prompt
deferredInstallPrompt,
showInstallButton,
isPWAInstalled,
// Notifications
notificationPermission,
pushSubscription,
// Badging
appBadgeCount,
// Media session
currentMediaMetadata,
isMediaSessionActive
};
}

View File

@@ -0,0 +1,66 @@
/**
* Sharing state management
*/
export function createSharingState(ref, reactive) {
// --- Public Sharing State ---
const recordingToShare = ref(null);
const shareOptions = reactive({
share_summary: true,
share_notes: true,
});
const generatedShareLink = ref('');
const existingShareDetected = ref(false);
const userShares = ref([]);
const isLoadingShares = ref(false);
const shareToDelete = ref(null);
// --- Internal Sharing State ---
const internalShareUserSearch = ref('');
const internalShareSearchResults = ref([]);
const internalShareRecording = ref(null);
const internalSharePermissions = ref({ can_edit: false, can_reshare: false });
const recordingInternalShares = ref([]);
const isLoadingInternalShares = ref(false);
const isSearchingUsers = ref(false);
const allUsers = ref([]);
const isLoadingAllUsers = ref(false);
// --- Audio Player State ---
const playerVolume = ref(1.0);
const audioIsPlaying = ref(false);
const audioCurrentTime = ref(0);
const audioDuration = ref(0);
const audioIsMuted = ref(false);
const audioIsLoading = ref(false);
return {
// Public sharing
recordingToShare,
shareOptions,
generatedShareLink,
existingShareDetected,
userShares,
isLoadingShares,
shareToDelete,
// Internal sharing
internalShareUserSearch,
internalShareSearchResults,
internalShareRecording,
internalSharePermissions,
recordingInternalShares,
isLoadingInternalShares,
isSearchingUsers,
allUsers,
isLoadingAllUsers,
// Audio player
playerVolume,
audioIsPlaying,
audioCurrentTime,
audioDuration,
audioIsMuted,
audioIsLoading
};
}

View File

@@ -0,0 +1,109 @@
/**
* UI state management
*/
export function createUIState(ref, computed) {
// --- UI State ---
const browser = ref('unknown');
const isSidebarCollapsed = ref(false);
const searchTipsExpanded = ref(false);
const isUserMenuOpen = ref(false);
const isDarkMode = ref(false);
const currentColorScheme = ref('blue');
const showColorSchemeModal = ref(false);
const windowWidth = ref(window.innerWidth);
const mobileTab = ref('transcript');
const isMetadataExpanded = ref(false);
const expandedSection = ref('settings'); // 'notes' or 'settings'
// --- i18n State ---
const currentLanguage = ref('en');
const currentLanguageName = ref('English');
const availableLanguages = ref([]);
const showLanguageMenu = ref(false);
// --- Column Resizing State ---
const leftColumnWidth = ref(60);
const rightColumnWidth = ref(40);
const isResizing = ref(false);
// --- Transcription State ---
const transcriptionViewMode = ref('simple');
const legendExpanded = ref(false);
const highlightedSpeaker = ref(null);
const processingIndicatorMinimized = ref(false);
// --- Virtual Scroll State ---
// For transcript panel virtual scrolling (performance optimization for long transcriptions)
const transcriptScrollTop = ref(0);
const transcriptContainerHeight = ref(0);
const transcriptItemHeight = 48; // Estimated height per segment in pixels
// --- Computed Properties ---
const isMobileScreen = computed(() => {
return windowWidth.value < 1024;
});
// --- Color Scheme Definitions ---
const colorSchemes = {
light: [
{ id: 'blue', name: 'Ocean Blue', description: 'Classic blue theme with professional appeal', class: '' },
{ id: 'emerald', name: 'Forest Emerald', description: 'Fresh green theme for a natural feel', class: 'theme-light-emerald' },
{ id: 'purple', name: 'Royal Purple', description: 'Elegant purple theme with sophistication', class: 'theme-light-purple' },
{ id: 'rose', name: 'Sunset Rose', description: 'Warm pink theme with gentle energy', class: 'theme-light-rose' },
{ id: 'amber', name: 'Golden Amber', description: 'Warm yellow theme for brightness', class: 'theme-light-amber' },
{ id: 'teal', name: 'Ocean Teal', description: 'Cool teal theme for tranquility', class: 'theme-light-teal' }
],
dark: [
{ id: 'blue', name: 'Midnight Blue', description: 'Deep blue theme for focused work', class: '' },
{ id: 'emerald', name: 'Dark Forest', description: 'Rich green theme for comfortable viewing', class: 'theme-dark-emerald' },
{ id: 'purple', name: 'Deep Purple', description: 'Mysterious purple theme for creativity', class: 'theme-dark-purple' },
{ id: 'rose', name: 'Dark Rose', description: 'Muted pink theme with subtle warmth', class: 'theme-dark-rose' },
{ id: 'amber', name: 'Dark Amber', description: 'Warm brown theme for cozy sessions', class: 'theme-dark-amber' },
{ id: 'teal', name: 'Deep Teal', description: 'Dark teal theme for calm focus', class: 'theme-dark-teal' }
]
};
return {
// UI
browser,
isSidebarCollapsed,
searchTipsExpanded,
isUserMenuOpen,
isDarkMode,
currentColorScheme,
showColorSchemeModal,
windowWidth,
mobileTab,
isMetadataExpanded,
expandedSection,
// i18n
currentLanguage,
currentLanguageName,
availableLanguages,
showLanguageMenu,
// Column Resizing
leftColumnWidth,
rightColumnWidth,
isResizing,
// Transcription
transcriptionViewMode,
legendExpanded,
highlightedSpeaker,
processingIndicatorMinimized,
// Virtual Scroll
transcriptScrollTop,
transcriptContainerHeight,
transcriptItemHeight,
// Computed
isMobileScreen,
// Constants
colorSchemes
};
}

View File

@@ -0,0 +1,77 @@
/**
* Upload state management
*/
export function createUploadState(ref, computed) {
// --- Upload State ---
const uploadQueue = ref([]);
const currentlyProcessingFile = ref(null);
const processingProgress = ref(0);
const processingMessage = ref('');
const isProcessingActive = ref(false);
const pollInterval = ref(null);
const progressPopupMinimized = ref(false);
const progressPopupClosed = ref(false);
const maxFileSizeMB = ref(250);
const chunkingEnabled = ref(true);
const chunkingMode = ref('size');
const chunkingLimit = ref(20);
const chunkingLimitDisplay = ref('20MB');
const maxConcurrentUploads = ref(3);
const recordingDisclaimer = ref('');
const showRecordingDisclaimerModal = ref(false);
const pendingRecordingMode = ref(null);
// Advanced Options for ASR
const showAdvancedOptions = ref(false);
const uploadLanguage = ref('');
const uploadMinSpeakers = ref('');
const uploadMaxSpeakers = ref('');
const uploadHotwords = ref('');
const uploadInitialPrompt = ref('');
// --- Computed Properties ---
const totalInQueue = computed(() => uploadQueue.value.length);
const completedInQueue = computed(() => uploadQueue.value.filter(item => item.status === 'completed' || item.status === 'failed').length);
const finishedFilesInQueue = computed(() => uploadQueue.value.filter(item => ['completed', 'failed'].includes(item.status)));
const clearCompletedUploads = () => {
uploadQueue.value = uploadQueue.value.filter(item => !['completed', 'failed'].includes(item.status));
};
return {
uploadQueue,
currentlyProcessingFile,
processingProgress,
processingMessage,
isProcessingActive,
pollInterval,
progressPopupMinimized,
progressPopupClosed,
maxFileSizeMB,
chunkingEnabled,
chunkingMode,
chunkingLimit,
chunkingLimitDisplay,
maxConcurrentUploads,
recordingDisclaimer,
showRecordingDisclaimerModal,
pendingRecordingMode,
// Advanced Options
showAdvancedOptions,
uploadLanguage,
uploadMinSpeakers,
uploadMaxSpeakers,
uploadHotwords,
uploadInitialPrompt,
// Computed
totalInQueue,
completedInQueue,
finishedFilesInQueue,
// Methods
clearCompletedUploads
};
}

View File

@@ -0,0 +1,61 @@
/**
* API utility functions with CSRF token handling
*/
export const createApiClient = (csrfToken) => {
const getHeaders = (contentType = 'application/json') => {
const headers = {
'X-CSRFToken': csrfToken.value
};
if (contentType) {
headers['Content-Type'] = contentType;
}
return headers;
};
return {
get: async (url) => {
const response = await fetch(url, {
headers: getHeaders()
});
return response;
},
post: async (url, data = {}) => {
const response = await fetch(url, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(data)
});
return response;
},
postFormData: async (url, formData) => {
const response = await fetch(url, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken.value
},
body: formData
});
return response;
},
delete: async (url) => {
const response = await fetch(url, {
method: 'DELETE',
headers: getHeaders()
});
return response;
},
put: async (url, data = {}) => {
const response = await fetch(url, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(data)
});
return response;
}
};
};

View File

@@ -0,0 +1,50 @@
/**
* Color utility functions
*/
/**
* Calculate the relative luminance of a color
* Based on WCAG contrast ratio formula
* @param {string} hexColor - Hex color code (e.g., "#RRGGBB" or "#RGB")
* @returns {number} Luminance value between 0 and 1
*/
function calculateLuminance(hexColor) {
// Remove # if present
const hex = hexColor.replace('#', '');
// Convert 3-digit hex to 6-digit
const fullHex = hex.length === 3
? hex.split('').map(char => char + char).join('')
: hex;
// Parse RGB values
const r = parseInt(fullHex.substr(0, 2), 16) / 255;
const g = parseInt(fullHex.substr(2, 2), 16) / 255;
const b = parseInt(fullHex.substr(4, 2), 16) / 255;
// Calculate relative luminance using simplified formula
// (More accurate would use gamma correction, but this is sufficient)
return 0.299 * r + 0.587 * g + 0.114 * b;
}
/**
* Get the appropriate text color (black or white) for a given background color
* Ensures readable contrast based on background luminance
* @param {string} bgColor - Background color in hex format
* @returns {string} Either 'white' or 'black'
*/
export function getContrastTextColor(bgColor) {
if (!bgColor) {
return 'white'; // Default to white for undefined colors
}
try {
const luminance = calculateLuminance(bgColor);
// Threshold of 0.65: only very light backgrounds get black text
// This ensures medium/dark colors like greens, blues still get white text
return luminance > 0.65 ? 'black' : 'white';
} catch (e) {
console.warn('Failed to calculate contrast color for:', bgColor, e);
return 'white'; // Fallback to white
}
}

View File

@@ -0,0 +1,67 @@
/**
* Date comparison utility functions
*/
export const isSameDay = (date1, date2) => {
return date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate();
};
export const isToday = (date) => {
const today = new Date();
return isSameDay(date, today);
};
export const isYesterday = (date) => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return isSameDay(date, yesterday);
};
export const isThisWeek = (date) => {
const now = new Date();
const startOfWeek = new Date(now);
const day = now.getDay();
const diff = now.getDate() - day + (day === 0 ? -6 : 1); // Monday as start of week
startOfWeek.setDate(diff);
startOfWeek.setHours(0, 0, 0, 0);
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(startOfWeek.getDate() + 6);
endOfWeek.setHours(23, 59, 59, 999);
return date >= startOfWeek && date <= endOfWeek;
};
export const isLastWeek = (date) => {
const now = new Date();
const startOfLastWeek = new Date(now);
const day = now.getDay();
const diff = now.getDate() - day + (day === 0 ? -6 : 1) - 7; // Previous Monday
startOfLastWeek.setDate(diff);
startOfLastWeek.setHours(0, 0, 0, 0);
const endOfLastWeek = new Date(startOfLastWeek);
endOfLastWeek.setDate(startOfLastWeek.getDate() + 6);
endOfLastWeek.setHours(23, 59, 59, 999);
return date >= startOfLastWeek && date <= endOfLastWeek;
};
export const isThisMonth = (date) => {
const now = new Date();
return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth();
};
export const isLastMonth = (date) => {
const now = new Date();
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
return date.getFullYear() === lastMonth.getFullYear() && date.getMonth() === lastMonth.getMonth();
};
export const getDateForSorting = (recording, sortBy) => {
const dateStr = sortBy === 'meeting_date' ? recording.meeting_date : recording.created_at;
if (!dateStr) return null;
return new Date(dateStr);
};

View File

@@ -0,0 +1,271 @@
/**
* Error Display Utility
*
* Parses and displays user-friendly error messages from the backend.
* Handles both JSON-formatted errors (ERROR_JSON:...) and plain text errors.
*/
/**
* Parse a stored error message from the backend.
* @param {string} text - The stored transcription/error text
* @returns {Object|null} - Parsed error object or null if not an error
*/
export function parseStoredError(text) {
if (!text) return null;
// Check for JSON-formatted error
if (text.startsWith('ERROR_JSON:')) {
try {
const jsonStr = text.substring(11); // Remove 'ERROR_JSON:' prefix
const data = JSON.parse(jsonStr);
return {
title: data.t || 'Error',
message: data.m || 'An error occurred',
guidance: data.g || '',
icon: data.i || 'fa-exclamation-circle',
type: data.y || 'unknown',
isKnown: data.k || false,
technical: data.d || '',
isFormattedError: true
};
} catch (e) {
console.error('Failed to parse error JSON:', e);
}
}
// Check for legacy error format (starts with common error prefixes)
const errorPrefixes = [
'Transcription failed:',
'Processing failed:',
'ASR processing failed:',
'Audio extraction failed:',
'Error:'
];
for (const prefix of errorPrefixes) {
if (text.startsWith(prefix)) {
// Parse the error using pattern matching
return parseUnformattedError(text);
}
}
return null;
}
/**
* Parse an unformatted error message and try to make it user-friendly.
* @param {string} text - The raw error text
* @returns {Object} - Parsed error object
*/
function parseUnformattedError(text) {
const lowerText = text.toLowerCase();
// Known error patterns
const patterns = [
{
patterns: ['maximum content size limit', 'file too large', '413', 'payload too large', 'exceeded'],
title: 'File Too Large',
message: 'The audio file exceeds the maximum size allowed by the transcription service.',
guidance: 'Try enabling audio chunking in your settings, or compress the audio file before uploading.',
icon: 'fa-file-audio',
type: 'size_limit'
},
{
patterns: ['timed out', 'timeout', 'deadline exceeded'],
title: 'Processing Timeout',
message: 'The transcription took too long to complete.',
guidance: 'This can happen with very long recordings. Try splitting the audio into smaller parts.',
icon: 'fa-clock',
type: 'timeout'
},
{
patterns: ['401', 'unauthorized', 'invalid api key', 'authentication failed', 'incorrect api key'],
title: 'Authentication Error',
message: 'The transcription service rejected the API credentials.',
guidance: 'Please check that the API key is correct and has not expired.',
icon: 'fa-key',
type: 'auth'
},
{
patterns: ['rate limit', 'too many requests', '429', 'quota exceeded'],
title: 'Rate Limit Exceeded',
message: 'Too many requests were sent to the transcription service.',
guidance: 'Please wait a few minutes and try reprocessing.',
icon: 'fa-hourglass-half',
type: 'rate_limit'
},
{
patterns: ['connection refused', 'connection reset', 'could not connect', 'network unreachable'],
title: 'Connection Error',
message: 'Could not connect to the transcription service.',
guidance: 'Check your internet connection and ensure the service is available.',
icon: 'fa-wifi',
type: 'connection'
},
{
patterns: ['503', '502', '500', 'service unavailable', 'server error', 'internal server error'],
title: 'Service Unavailable',
message: 'The transcription service is temporarily unavailable.',
guidance: 'This is usually temporary. Please try again in a few minutes.',
icon: 'fa-server',
type: 'service_error'
},
{
patterns: ['invalid file format', 'unsupported format', 'could not decode', 'corrupt', 'not valid audio'],
title: 'Invalid Audio Format',
message: 'The audio file format is not supported or the file may be corrupted.',
guidance: 'Try converting the audio to MP3 or WAV format before uploading.',
icon: 'fa-file-audio',
type: 'format'
},
{
patterns: ['audio extraction failed', 'ffmpeg failed', 'no audio stream'],
title: 'Audio Extraction Failed',
message: 'Could not extract audio from the uploaded file.',
guidance: 'Try converting the file to a standard audio format (MP3, WAV) before uploading.',
icon: 'fa-file-video',
type: 'extraction'
}
];
// Check patterns
for (const pattern of patterns) {
for (const p of pattern.patterns) {
if (lowerText.includes(p)) {
return {
title: pattern.title,
message: pattern.message,
guidance: pattern.guidance,
icon: pattern.icon,
type: pattern.type,
isKnown: true,
technical: text,
isFormattedError: true
};
}
}
}
// Unknown error - clean it up as best we can
let cleanMessage = text;
for (const prefix of ['Transcription failed:', 'Processing failed:', 'Error:', 'ASR processing failed:']) {
if (cleanMessage.startsWith(prefix)) {
cleanMessage = cleanMessage.substring(prefix.length).trim();
}
}
// Truncate if too long
if (cleanMessage.length > 200) {
cleanMessage = cleanMessage.substring(0, 200) + '...';
}
return {
title: 'Processing Error',
message: cleanMessage,
guidance: 'If this error persists, try reprocessing the recording.',
icon: 'fa-exclamation-circle',
type: 'unknown',
isKnown: false,
technical: text,
isFormattedError: true
};
}
/**
* Check if a transcription text is actually an error message.
* @param {string} text - The transcription text
* @returns {boolean}
*/
export function isErrorMessage(text) {
if (!text) return false;
if (text.startsWith('ERROR_JSON:')) return true;
const errorPrefixes = [
'Transcription failed:',
'Processing failed:',
'ASR processing failed:',
'Audio extraction failed:'
];
return errorPrefixes.some(prefix => text.startsWith(prefix));
}
/**
* Generate HTML for displaying an error nicely.
* @param {Object} error - Parsed error object from parseStoredError
* @param {boolean} showTechnical - Whether to show technical details
* @returns {string} - HTML string
*/
export function generateErrorHTML(error, showTechnical = false) {
if (!error) return '';
const typeColors = {
size_limit: 'amber',
timeout: 'orange',
auth: 'red',
rate_limit: 'yellow',
connection: 'blue',
service_error: 'purple',
format: 'pink',
extraction: 'indigo',
billing: 'red',
model: 'gray',
unknown: 'gray'
};
const color = typeColors[error.type] || 'gray';
let html = `
<div class="error-display bg-${color}-500/10 border border-${color}-500/30 rounded-lg p-4">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-${color}-500/20 flex items-center justify-center">
<i class="fas ${error.icon} text-${color}-500"></i>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-${color}-600 dark:text-${color}-400 mb-1">
${escapeHtml(error.title)}
</h3>
<p class="text-[var(--text-primary)] mb-2">
${escapeHtml(error.message)}
</p>
${error.guidance ? `
<div class="flex items-start gap-2 text-sm text-[var(--text-secondary)] bg-[var(--bg-tertiary)]/50 rounded p-2">
<i class="fas fa-lightbulb text-yellow-500 mt-0.5"></i>
<span>${escapeHtml(error.guidance)}</span>
</div>
` : ''}
</div>
</div>
${showTechnical && error.technical ? `
<details class="mt-3 text-xs">
<summary class="cursor-pointer text-[var(--text-muted)] hover:text-[var(--text-secondary)]">
Technical details
</summary>
<pre class="mt-2 p-2 bg-[var(--bg-tertiary)] rounded overflow-x-auto text-[var(--text-muted)]">${escapeHtml(error.technical)}</pre>
</details>
` : ''}
</div>
`;
return html;
}
/**
* Escape HTML special characters.
* @param {string} text
* @returns {string}
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Export for use in Vue components
export default {
parseStoredError,
isErrorMessage,
generateErrorHTML
};

View File

@@ -0,0 +1,139 @@
/**
* Formatting utility functions
*/
export const formatFileSize = (bytes) => {
if (bytes == null || bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
if (bytes < 0) bytes = 0;
const i = bytes === 0 ? 0 : Math.max(0, Math.floor(Math.log(bytes) / Math.log(k)));
const size = i === 0 ? bytes : parseFloat((bytes / Math.pow(k, i)).toFixed(2));
return size + ' ' + sizes[i];
};
export const formatDisplayDate = (dateString) => {
if (!dateString) return '';
try {
let date = new Date(dateString);
if (isNaN(date.getTime())) {
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
date = new Date(dateString + 'T00:00:00');
} else {
return dateString;
}
}
if (isNaN(date.getTime())) {
return dateString;
}
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
} catch (e) {
console.error("Error formatting date:", e);
return dateString;
}
};
export const formatShortDate = (dateString) => {
if (!dateString) return '';
try {
let date = new Date(dateString);
if (isNaN(date.getTime())) {
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
date = new Date(dateString + 'T00:00:00');
} else {
return dateString;
}
}
if (isNaN(date.getTime())) {
return dateString;
}
const now = new Date();
const isCurrentYear = date.getFullYear() === now.getFullYear();
if (isCurrentYear) {
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
} else {
return date.toLocaleDateString(undefined, { year: '2-digit', month: 'short', day: 'numeric' });
}
} catch (e) {
console.error("Error formatting short date:", e);
return dateString;
}
};
export const formatStatus = (status, t) => {
if (!status || status === 'COMPLETED') return '';
const statusMap = {
'PENDING': t('status.queued'),
'QUEUED': t('status.queued'),
'PROCESSING': t('status.processing'),
'TRANSCRIBING': t('status.transcribing'),
'SUMMARIZING': t('status.summarizing'),
'FAILED': t('status.failed'),
'UPLOADING': t('status.uploading')
};
return statusMap[status] || status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
};
export const getStatusClass = (status) => {
switch(status) {
case 'PENDING': return 'status-pending';
case 'QUEUED': return 'status-pending';
case 'PROCESSING': return 'status-processing';
case 'SUMMARIZING': return 'status-summarizing';
case 'COMPLETED': return '';
case 'FAILED': return 'status-failed';
default: return 'status-pending';
}
};
export const formatTime = (seconds) => {
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
export const formatDuration = (totalSeconds) => {
if (totalSeconds == null || totalSeconds < 0) return 'N/A';
if (totalSeconds < 1) {
return `${totalSeconds.toFixed(2)} seconds`;
}
totalSeconds = Math.round(totalSeconds);
if (totalSeconds < 60) {
return `${totalSeconds} sec`;
}
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
let parts = [];
if (hours > 0) {
parts.push(`${hours} hr`);
}
if (minutes > 0) {
parts.push(`${minutes} min`);
}
if (hours === 0 && seconds > 0) {
parts.push(`${seconds} sec`);
}
return parts.join(' ');
};
export const formatProcessingDuration = (seconds) => {
if (!seconds && seconds !== 0) return null;
if (seconds < 60) return `${seconds}s`;
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
};

View File

@@ -0,0 +1,9 @@
/**
* Utils module exports
*/
export * from './formatters.js';
export * from './dates.js';
export { createApiClient } from './api.js';
export { showToast } from './toast.js';
export { getContrastTextColor } from './colors.js';

View File

@@ -0,0 +1,91 @@
/**
* Toast notification utility
*/
export const showToast = (message, iconClass = 'fa-info-circle', duration = 3000, type = 'info') => {
const container = document.getElementById('toastContainer');
if (!container) {
console.warn('Toast container not found');
return;
}
// Determine colors and styles based on type
let bgColor, textColor, iconColor, borderColor;
switch (type) {
case 'success':
bgColor = '#10b981'; // green-500
textColor = '#ffffff';
iconColor = '#ffffff';
borderColor = '#059669'; // green-600
break;
case 'error':
bgColor = '#ef4444'; // red-500
textColor = '#ffffff';
iconColor = '#ffffff';
borderColor = '#dc2626'; // red-600
break;
case 'warning':
bgColor = '#f59e0b'; // amber-500
textColor = '#ffffff';
iconColor = '#ffffff';
borderColor = '#d97706'; // amber-600
break;
case 'info':
default:
bgColor = '#3b82f6'; // blue-500
textColor = '#ffffff';
iconColor = '#ffffff';
borderColor = '#2563eb'; // blue-600
break;
}
const toast = document.createElement('div');
toast.className = 'toast-message px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 opacity-0 min-w-[300px]';
toast.style.backgroundColor = bgColor;
toast.style.color = textColor;
toast.style.border = `1px solid ${borderColor}`;
// Handle icon class - support both old format (just icon name) and new format (full class)
let fullIconClass = iconClass;
if (!iconClass.includes(' ')) {
// Old format: just the icon name like 'fa-check-circle'
fullIconClass = `fas ${iconClass}`;
}
toast.innerHTML = `
<i class="${fullIconClass}" style="color: ${iconColor}"></i>
<span class="flex-1">${message}</span>
`;
// Make toast clickable to dismiss
toast.style.cursor = 'pointer';
container.appendChild(toast);
// Trigger fly-in animation
requestAnimationFrame(() => {
toast.classList.remove('opacity-0');
toast.classList.add('opacity-100', 'toast-show');
});
// Function to dismiss the toast
const dismissToast = () => {
toast.classList.remove('opacity-100', 'toast-show');
toast.classList.add('opacity-0');
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 300);
};
// Add click handler to dismiss toast
toast.addEventListener('click', () => {
clearTimeout(timeoutId);
dismissToast();
});
// Auto-dismiss after duration
const timeoutId = setTimeout(dismissToast, duration);
};

View File

@@ -0,0 +1,262 @@
// Shared UI Components and Functionality
// This file contains reusable Vue composition functions and utilities
// that can be used across multiple pages (index, inquire, admin, etc.)
// Dark Mode Composition
function useDarkMode() {
const isDarkMode = Vue.ref(false);
const toggleDarkMode = () => {
isDarkMode.value = !isDarkMode.value;
if (isDarkMode.value) {
document.documentElement.classList.add('dark');
localStorage.setItem('darkMode', 'true');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('darkMode', 'false');
}
};
const initializeDarkMode = () => {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const savedMode = localStorage.getItem('darkMode');
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
isDarkMode.value = true;
document.documentElement.classList.add('dark');
} else {
isDarkMode.value = false;
document.documentElement.classList.remove('dark');
}
};
return {
isDarkMode,
toggleDarkMode,
initializeDarkMode
};
}
// Color Scheme Composition
function useColorScheme() {
const showColorSchemeModal = Vue.ref(false);
const currentColorScheme = Vue.ref('blue');
const isDarkMode = Vue.ref(false);
const colorSchemes = {
light: [
{ id: 'blue', name: 'Ocean Blue', description: 'Classic blue theme with professional appeal', accent: '#3b82f6', hover: '#2563eb' },
{ id: 'emerald', name: 'Forest Emerald', description: 'Fresh green theme for a natural feel', accent: '#10b981', hover: '#059669' },
{ id: 'purple', name: 'Royal Purple', description: 'Elegant purple theme with sophistication', accent: '#8b5cf6', hover: '#7c3aed' },
{ id: 'rose', name: 'Sunset Rose', description: 'Warm pink theme with gentle energy', accent: '#f43f5e', hover: '#e11d48' },
{ id: 'amber', name: 'Golden Amber', description: 'Warm yellow theme for brightness', accent: '#f59e0b', hover: '#d97706' },
{ id: 'teal', name: 'Ocean Teal', description: 'Cool teal theme for tranquility', accent: '#06b6d4', hover: '#0891b2' }
],
dark: [
{ id: 'blue', name: 'Midnight Blue', description: 'Deep blue for focused night work', accent: '#60a5fa', hover: '#3b82f6' },
{ id: 'emerald', name: 'Emerald Night', description: 'Rich green for comfortable viewing', accent: '#34d399', hover: '#10b981' },
{ id: 'purple', name: 'Deep Purple', description: 'Luxurious purple for creative sessions', accent: '#a78bfa', hover: '#8b5cf6' },
{ id: 'rose', name: 'Crimson', description: 'Bold red-pink for energetic work', accent: '#fb7185', hover: '#f43f5e' },
{ id: 'amber', name: 'Golden Hour', description: 'Warm amber for reduced eye strain', accent: '#fbbf24', hover: '#f59e0b' },
{ id: 'teal', name: 'Electric Cyan', description: 'Vibrant cyan for modern aesthetics', accent: '#22d3ee', hover: '#06b6d4' }
]
};
const applyColorScheme = (schemeId) => {
const schemes = isDarkMode.value ? colorSchemes.dark : colorSchemes.light;
const scheme = schemes.find(s => s.id === schemeId);
if (scheme) {
// Remove all theme classes
const allThemeClasses = [
...colorSchemes.light.map(s => `theme-light-${s.id}`),
...colorSchemes.dark.map(s => `theme-dark-${s.id}`)
].filter(c => !c.includes('blue')); // blue is the default, no class needed
document.documentElement.classList.remove(...allThemeClasses);
// Apply new theme class if not blue (default)
if (schemeId !== 'blue') {
const themeClass = `theme-${isDarkMode.value ? 'dark' : 'light'}-${schemeId}`;
document.documentElement.classList.add(themeClass);
}
// Don't set CSS variables - let the theme classes handle all colors
localStorage.setItem('colorScheme', schemeId);
currentColorScheme.value = schemeId;
}
};
const initializeColorScheme = (darkMode) => {
isDarkMode.value = darkMode;
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
currentColorScheme.value = savedScheme;
applyColorScheme(savedScheme);
};
// Watch for dark mode changes and reapply color scheme
Vue.watch(() => isDarkMode.value, (newValue) => {
applyColorScheme(currentColorScheme.value);
});
const openColorSchemeModal = () => {
showColorSchemeModal.value = true;
};
const closeColorSchemeModal = () => {
showColorSchemeModal.value = false;
};
const selectColorScheme = (schemeId) => {
applyColorScheme(schemeId);
const scheme = colorSchemes[isDarkMode.value ? 'dark' : 'light'].find(s => s.id === schemeId);
if (window.showToast && scheme) {
window.showToast(`Applied ${scheme.name} theme`, 'fa-palette');
}
};
const resetColorScheme = () => {
applyColorScheme('blue');
if (window.showToast) {
const defaultScheme = colorSchemes[isDarkMode.value ? 'dark' : 'light'].find(s => s.id === 'blue');
window.showToast(`Reset to default ${defaultScheme?.name || 'Ocean Blue'} theme`, 'fa-undo');
}
};
return {
showColorSchemeModal,
currentColorScheme,
colorSchemes,
openColorSchemeModal,
closeColorSchemeModal,
selectColorScheme,
resetColorScheme,
applyColorScheme,
initializeColorScheme
};
}
// Shared Transcripts Modal Composition
function useSharesModal() {
const showSharesListModal = Vue.ref(false);
const userShares = Vue.ref([]);
const isLoadingShares = Vue.ref(false);
const openSharesList = async () => {
isLoadingShares.value = true;
showSharesListModal.value = true;
try {
const response = await fetch('/api/shares');
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to load shared items');
userShares.value = data;
} catch (error) {
if (window.setGlobalError) {
window.setGlobalError(`Failed to load shared items: ${error.message}`);
} else {
console.error('Failed to load shared items:', error);
}
} finally {
isLoadingShares.value = false;
}
};
const closeSharesList = () => {
showSharesListModal.value = false;
};
const copyShareLink = async (shareId) => {
const url = `${window.location.origin}/share/${shareId}`;
try {
await navigator.clipboard.writeText(url);
if (window.showToast) {
window.showToast('Share link copied to clipboard', 'fa-link');
}
} catch (err) {
if (window.setGlobalError) {
window.setGlobalError('Failed to copy link to clipboard');
}
}
};
const deleteShare = async (shareId) => {
if (!confirm('Are you sure you want to delete this share?')) return;
try {
const response = await fetch(`/api/shares/${shareId}`, {
method: 'DELETE',
headers: {
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
}
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to delete share');
}
userShares.value = userShares.value.filter(share => share.id !== shareId);
if (window.showToast) {
window.showToast('Share deleted successfully', 'fa-trash');
}
} catch (error) {
if (window.setGlobalError) {
window.setGlobalError(`Failed to delete share: ${error.message}`);
}
}
};
return {
showSharesListModal,
userShares,
isLoadingShares,
openSharesList,
closeSharesList,
copyShareLink,
deleteShare
};
}
// User Menu Composition
function useUserMenu() {
const isUserMenuOpen = Vue.ref(false);
const toggleUserMenu = () => {
isUserMenuOpen.value = !isUserMenuOpen.value;
};
const closeUserMenu = () => {
isUserMenuOpen.value = false;
};
// Close menu when clicking outside
Vue.onMounted(() => {
const handleClickOutside = (e) => {
const userMenuButton = e.target.closest('button[class*="flex items-center gap"]');
const userMenuDropdown = e.target.closest('div[class*="absolute right-0"]');
const isUserMenuButtonClick = userMenuButton && userMenuButton.querySelector('i.fa-user-circle');
if (!isUserMenuButtonClick && !userMenuDropdown) {
isUserMenuOpen.value = false;
}
};
document.addEventListener('click', handleClickOutside);
Vue.onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
});
return {
isUserMenuOpen,
toggleUserMenu,
closeUserMenu
};
}
// Export for use in Vue components
window.SharedComponents = {
useDarkMode,
useColorScheme,
useSharesModal,
useUserMenu
};

View File

@@ -0,0 +1,119 @@
/**
* API client utilities for making HTTP requests
*/
class APIError extends Error {
constructor(message, status, data) {
super(message);
this.name = 'APIError';
this.status = status;
this.data = data;
}
}
/**
* Safely parse JSON response, handling HTML error pages gracefully
*/
async function safeJsonParse(response) {
const contentType = response.headers.get('content-type') || '';
// If response is not JSON, extract useful error from HTML
if (!contentType.includes('application/json')) {
const text = await response.text();
// Try to extract error message from HTML title or h1
const titleMatch = text.match(/<title>([^<]+)<\/title>/i);
const h1Match = text.match(/<h1>([^<]+)<\/h1>/i);
const errorMsg = titleMatch?.[1] || h1Match?.[1] ||
`Server returned non-JSON response (status ${response.status})`;
throw new APIError(errorMsg, response.status, { htmlResponse: true });
}
return response.json();
}
export async function apiRequest(url, options = {}) {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
...(csrfToken && { 'X-CSRFToken': csrfToken })
}
};
const mergedOptions = {
...defaultOptions,
...options,
headers: {
...defaultOptions.headers,
...options.headers
}
};
try {
const response = await fetch(url, mergedOptions);
const data = await safeJsonParse(response);
if (!response.ok) {
throw new APIError(
data.error || 'Request failed',
response.status,
data
);
}
return data;
} catch (error) {
if (error instanceof APIError) {
throw error;
}
throw new APIError(error.message, 0, null);
}
}
export async function uploadFile(url, file, onProgress = null) {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const formData = new FormData();
formData.append('audio_file', file);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
if (onProgress) {
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
onProgress(percentComplete);
}
});
}
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText);
resolve(data);
} catch (e) {
reject(new Error('Invalid response format'));
}
} else {
try {
const error = JSON.parse(xhr.responseText);
reject(new APIError(error.error || 'Upload failed', xhr.status, error));
} catch (e) {
reject(new APIError('Upload failed', xhr.status, null));
}
}
});
xhr.addEventListener('error', () => {
reject(new Error('Network error'));
});
xhr.open('POST', url);
if (csrfToken) {
xhr.setRequestHeader('X-CSRFToken', csrfToken);
}
xhr.send(formData);
});
}

View File

@@ -0,0 +1,61 @@
/**
* Audio processing and visualization utilities
*/
export function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
export function createAudioVisualizer(canvas, stream, color = '#3b82f6') {
if (!canvas || !stream) return null;
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const analyser = audioContext.createAnalyser();
const microphone = audioContext.createMediaStreamSource(stream);
analyser.fftSize = 256;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
microphone.connect(analyser);
const canvasCtx = canvas.getContext('2d');
const WIDTH = canvas.width;
const HEIGHT = canvas.height;
function draw() {
requestAnimationFrame(draw);
analyser.getByteFrequencyData(dataArray);
canvasCtx.fillStyle = 'rgb(17, 24, 39)';
canvasCtx.fillRect(0, 0, WIDTH, HEIGHT);
const barWidth = (WIDTH / bufferLength) * 2.5;
let barHeight;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
barHeight = (dataArray[i] / 255) * HEIGHT;
canvasCtx.fillStyle = color;
canvasCtx.fillRect(x, HEIGHT - barHeight, barWidth, barHeight);
x += barWidth + 1;
}
}
draw();
return { audioContext, analyser, stop: () => audioContext.close() };
}
export function detectBrowser() {
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.indexOf('firefox') > -1) return 'firefox';
if (userAgent.indexOf('chrome') > -1 && userAgent.indexOf('edge') === -1) return 'chrome';
if (userAgent.indexOf('safari') > -1 && userAgent.indexOf('chrome') === -1) return 'safari';
if (userAgent.indexOf('edge') > -1) return 'edge';
return 'unknown';
}

View File

@@ -0,0 +1,83 @@
/**
* Date utility functions for formatting and parsing dates
*/
export function formatDateTime(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return 'Today at ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
} else if (diffDays === 1) {
return 'Yesterday at ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
} else if (diffDays < 7) {
const dayName = date.toLocaleDateString('en-US', { weekday: 'long' });
return dayName + ' at ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
} else {
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
}
export function formatTimeAgo(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
if (diffDays < 30) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
const diffMonths = Math.floor(diffDays / 30);
if (diffMonths < 12) return `${diffMonths} month${diffMonths > 1 ? 's' : ''} ago`;
const diffYears = Math.floor(diffDays / 365);
return `${diffYears} year${diffYears > 1 ? 's' : ''} ago`;
}
export function formatDuration(seconds) {
if (!seconds || seconds < 0) return '0:00';
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hrs > 0) {
return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
export function parseDateRange(preset) {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
switch (preset) {
case 'today':
return { start: today, end: new Date() };
case 'yesterday':
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
return { start: yesterday, end: today };
case 'week':
const weekStart = new Date(today);
weekStart.setDate(weekStart.getDate() - 7);
return { start: weekStart, end: new Date() };
case 'month':
const monthStart = new Date(today);
monthStart.setMonth(monthStart.getMonth() - 1);
return { start: monthStart, end: new Date() };
case 'year':
const yearStart = new Date(today);
yearStart.setFullYear(yearStart.getFullYear() - 1);
return { start: yearStart, end: new Date() };
default:
return { start: null, end: null };
}
}

1505
static/locales/de.json Normal file

File diff suppressed because it is too large Load Diff

1506
static/locales/en.json Normal file

File diff suppressed because it is too large Load Diff

1505
static/locales/es.json Normal file

File diff suppressed because it is too large Load Diff

1532
static/locales/fr.json Normal file

File diff suppressed because it is too large Load Diff

1505
static/locales/ru.json Normal file

File diff suppressed because it is too large Load Diff

1656
static/locales/zh.json Normal file

File diff suppressed because it is too large Load Diff

122
static/manifest.json Normal file
View File

@@ -0,0 +1,122 @@
{
"id": "ca.innova-ai.dictia",
"name": "DictIA",
"short_name": "DictIA",
"description": "DictIA - Transcription audio par IA",
"start_url": "/",
"scope": "/",
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone"],
"background_color": "#000000",
"theme_color": "#000000",
"orientation": "any",
"prefer_related_applications": false,
"categories": ["productivity", "utilities", "business"],
"lang": "en",
"dir": "ltr",
"icons": [
{
"src": "/static/img/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/img/icon-maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/static/img/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/img/icon-180x180.png",
"sizes": "180x180",
"type": "image/png",
"purpose": "any"
}
],
"screenshots": [
{
"src": "/static/img/screenshots/main-view.png",
"sizes": "1920x1080",
"type": "image/png",
"form_factor": "wide",
"label": "Main recording gallery view"
},
{
"src": "/static/img/screenshots/recording-interface.png",
"sizes": "1920x1080",
"type": "image/png",
"form_factor": "wide",
"label": "Live recording interface with notes"
},
{
"src": "/static/img/screenshots/transcript-view.png",
"sizes": "1920x1080",
"type": "image/png",
"form_factor": "wide",
"label": "Transcript view with speaker identification"
},
{
"src": "/static/img/screenshots/mobile-view.png",
"sizes": "390x844",
"type": "image/png",
"form_factor": "narrow",
"label": "Mobile recording view"
}
],
"shortcuts": [
{
"name": "New Recording",
"short_name": "New",
"description": "Upload or record new audio",
"url": "/#upload",
"icons": [{ "src": "/static/img/icon-192x192.png", "sizes": "192x192" }]
},
{
"name": "View Gallery",
"short_name": "Gallery",
"description": "Access your recordings gallery",
"url": "/#gallery",
"icons": [{ "src": "/static/img/icon-192x192.png", "sizes": "192x192" }]
}
],
"share_target": {
"action": "/#upload",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "shared_audio",
"accept": ["audio/*"]
}
]
}
},
"edge_side_panel": {
"preferred_width": 480
},
"file_handlers": [
{
"action": "/",
"accept": {
"audio/*": [".mp3", ".wav", ".m4a", ".ogg", ".webm", ".flac", ".aac", ".wma", ".opus"]
}
}
],
"protocol_handlers": [
{
"protocol": "web+dictia",
"url": "/?audio=%s"
}
]
}

64
static/offline.html Normal file
View File

@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline - DictIA</title>
<link rel="icon" href="/static/img/favicon.ico" type="image/svg+xml">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #f0f2f5;
color: #1c1e21;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
text-align: center;
padding: 20px;
box-sizing: border-box;
}
.container {
background-color: #ffffff;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), 0 8px 16px rgba(0, 0, 0, 0.1);
}
img {
width: 60px;
height: 60px;
margin-bottom: 20px;
}
h1 {
font-size: 24px;
margin-bottom: 10px;
}
p {
font-size: 16px;
color: #606770;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #1c1e21;
color: #e4e6eb;
}
.container {
background-color: #242526;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2), 0 8px 16px rgba(0, 0, 0, 0.2);
}
p {
color: #b0b3b8;
}
}
</style>
</head>
<body>
<div class="container">
<img src="/static/img/favicon.ico" alt="DictIA">
<h1>You're Offline</h1>
<p>It looks like you're not connected to the internet. Please check your connection and try again.</p>
<p>Some content may be unavailable until you're back online.</p>
</div>
</body>
</html>

65
static/robots.txt Normal file
View File

@@ -0,0 +1,65 @@
# DictIA - Block all web crawlers and search engines
# This application contains private user data and should not be indexed
User-agent: *
Disallow: /
# Specific directives for major search engines
User-agent: Googlebot
Disallow: /
User-agent: Googlebot-Image
Disallow: /
User-agent: Bingbot
Disallow: /
User-agent: Slurp
Disallow: /
User-agent: DuckDuckBot
Disallow: /
User-agent: Baiduspider
Disallow: /
User-agent: YandexBot
Disallow: /
User-agent: ia_archiver
Disallow: /
# AI Crawlers
User-agent: GPTBot
Disallow: /
User-agent: ChatGPT-User
Disallow: /
User-agent: CCBot
Disallow: /
User-agent: anthropic-ai
Disallow: /
User-agent: Claude-Web
Disallow: /
User-agent: cohere-ai
Disallow: /
# Social Media Crawlers
User-agent: facebookexternalhit
Disallow: /
User-agent: Twitterbot
Disallow: /
User-agent: LinkedInBot
Disallow: /
User-agent: Slackbot
Disallow: /
User-agent: Discordbot
Disallow: /

541
static/sw.js Normal file
View File

@@ -0,0 +1,541 @@
const CACHE_NAME = 'DictIA-cache-v4';
const ASSETS_TO_CACHE = [
'/',
'/static/offline.html',
'/static/manifest.json',
'/static/css/styles.css',
'/static/js/app.modular.js',
'/static/js/i18n.js',
'/static/js/csrf-refresh.js',
'/static/img/icon-192x192.png',
'/static/img/icon-512x512.png',
'/static/img/favicon.ico',
// Local vendor assets (no external CDN dependencies)
'/static/vendor/js/tailwind.min.js',
'/static/vendor/js/vue.global.js',
'/static/vendor/js/marked.min.js',
'/static/vendor/js/easymde.min.js',
'/static/vendor/css/fontawesome.min.css',
'/static/vendor/css/easymde.min.css'
];
// Function to update shortcuts (structure from your example)
// The actual `lists` data would need to be sent from your client-side app.js
const updateShortcuts = async (lists) => {
if (!self.registration || !('shortcuts' in self.registration)) {
console.log('Shortcuts API not supported or registration not available.');
return;
}
try {
let shortcuts = [
{
name: "New Recording",
short_name: "New",
description: "Upload or record new audio",
url: "/#upload", // Or your direct upload page route
icons: [{ src: "/static/img/icon-192x192.png", sizes: "192x192" }]
},
{
name: "View Gallery",
short_name: "Gallery",
description: "Access your recordings gallery",
url: "/#gallery", // Or your direct gallery page route
icons: [{ src: "/static/img/icon-192x192.png", sizes: "192x192" }]
}
];
// Example: If you had dynamic lists to add as shortcuts
if (Array.isArray(lists) && lists.length > 0) {
const dynamicShortcuts = lists.slice(0, 2).map(list => { // Max 2 dynamic, total 4
if (list && list.id && list.title) {
return {
name: list.title,
short_name: list.title.length > 10 ? list.title.substring(0, 9) + '…' : list.title,
description: `View ${list.title}`,
url: `/list/${list.id}`, // Example dynamic URL
icons: [{ src: "/static/img/icon-192x192.png", sizes: "192x192" }]
};
}
return null;
}).filter(Boolean);
shortcuts = [...shortcuts, ...dynamicShortcuts];
}
await self.registration.shortcuts.set(shortcuts);
console.log('PWA shortcuts updated successfully:', shortcuts);
} catch (error) {
console.error('Error updating PWA shortcuts:', error);
}
};
// Cache first strategy: Respond from cache if available, otherwise fetch from network and cache.
const cacheFirst = async (request) => {
const responseFromCache = await caches.match(request);
if (responseFromCache) {
return responseFromCache;
}
try {
const responseFromNetwork = await fetch(request);
// Check if the response is valid before caching
if (responseFromNetwork && responseFromNetwork.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, responseFromNetwork.clone());
}
return responseFromNetwork;
} catch (error) {
console.error('CacheFirst: Network request failed for:', request.url, error);
// For assets, returning a generic error or specific offline asset might be better than network error.
// However, if it's a critical asset not found, this indicates an issue.
return new Response('Network error trying to fetch asset.', {
status: 408,
headers: { 'Content-Type': 'text/plain' },
});
}
};
// Stale-while-revalidate strategy: Respond from cache immediately if available,
// then update the cache with a fresh response from the network.
const staleWhileRevalidate = async (request) => {
const cache = await caches.open(CACHE_NAME);
const cachedResponsePromise = cache.match(request);
const networkResponsePromise = fetch(request).then(networkResponse => {
if (networkResponse && networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
}).catch(error => {
console.error('StaleWhileRevalidate: Network request failed for:', request.url, error);
// If network fails, we still might have a cached response.
// If not, this error will propagate.
return new Response('API request failed and no cache available.', {
status: 503, // Service Unavailable
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'Service temporarily unavailable. Please try again later.' })
});
});
return (await cachedResponsePromise) || networkResponsePromise;
};
// Network first strategy: Try to fetch from network first.
// If network fails, fall back to cache. If cache also fails, serve offline page for navigation.
const networkFirst = async (request) => {
try {
const networkResponse = await fetch(request);
if (networkResponse && networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.warn('NetworkFirst: Network request failed for:', request.url, error);
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
// For navigation requests, fall back to the offline page if both network and cache fail.
if (request.mode === 'navigate') {
const offlinePage = await caches.match('/static/offline.html');
if (offlinePage) return offlinePage;
}
// For other types of requests, or if offline page isn't cached, re-throw or return error.
return new Response('Network error and no cache available.', {
status: 408,
headers: { 'Content-Type': 'text/plain' },
});
}
};
self.addEventListener('install', (event) => {
self.skipWaiting(); // Activate new service worker immediately
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('Service Worker: Caching app shell');
return cache.addAll(ASSETS_TO_CACHE.map(url => new Request(url, { cache: 'reload' }))) // Force reload from network for app shell
.catch(error => {
console.error('Failed to cache app shell during install:', error);
// You might want to log which specific asset failed
ASSETS_TO_CACHE.forEach(url => {
cache.add(new Request(url, { cache: 'reload' })).catch(err => console.warn(`Failed to cache: ${url}`, err));
});
});
})
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => {
console.log('Service Worker: Deleting old cache', name);
return caches.delete(name);
})
);
}).then(() => {
console.log('Service Worker: Activated and old caches cleared.');
return self.clients.claim(); // Take control of all open clients
})
);
});
self.addEventListener('fetch', (event) => {
const request = event.request;
const url = new URL(request.url);
// Skip non-GET requests from caching strategies (they should pass through)
if (request.method !== 'GET') {
// event.respondWith(fetch(request)); // Let non-GET requests pass through to the network
return; // Or simply return to let the browser handle it
}
// Serve API calls from /api/ with stale-while-revalidate
// (excluding auth-related endpoints)
if (url.pathname.startsWith('/api/')) {
if (url.pathname.includes('/login') || url.pathname.includes('/logout') || url.pathname.includes('/auth')) {
// For auth, always go to network, don't cache
event.respondWith(fetch(request));
return;
}
event.respondWith(staleWhileRevalidate(request));
return;
}
// Serve /audio/<id> requests with cache-first, then network.
// These are media files and can be large, so cache-first is good.
if (url.pathname.startsWith('/audio/')) {
event.respondWith(cacheFirst(request));
return;
}
// Handle navigation requests (HTML pages) with network-first, then cache, then offline page.
if (request.mode === 'navigate') {
event.respondWith(networkFirst(request));
return;
}
// For static assets listed in ASSETS_TO_CACHE, use cache-first.
// This ensures that if an asset path is directly requested, it's served from cache if possible.
// We need to match against the origin + pathname for ASSETS_TO_CACHE.
const requestPath = url.origin === self.origin ? url.pathname : request.url;
if (ASSETS_TO_CACHE.includes(requestPath)) {
event.respondWith(cacheFirst(request));
return;
}
// Default strategy for other GET requests: try cache, then network.
// This is a good general fallback for other static assets not explicitly listed
// or for assets from other origins if not handled by ASSETS_TO_CACHE.
event.respondWith(
caches.match(request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(request).then(networkResponse => {
// Optionally cache other successful GET responses here if desired
// if (networkResponse && networkResponse.ok) {
// const cache = await caches.open(CACHE_NAME);
// cache.put(request, networkResponse.clone());
// }
return networkResponse;
}).catch(() => {
// If network fails for a non-navigation, non-API, non-explicitly-cached asset
// there isn't much we can do other than return an error or nothing.
// For simplicity, let the browser handle the error.
});
})
);
});
// Listen for messages from the client (e.g., to update shortcuts)
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'UPDATE_SHORTCUTS') {
console.log('Service Worker: Received UPDATE_SHORTCUTS message:', event.data.lists);
// updateShortcuts(event.data.lists); // Call if you implement dynamic shortcuts based on client data
}
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Background sync for failed uploads
self.addEventListener('sync', (event) => {
console.log('[Service Worker] Background sync triggered:', event.tag);
if (event.tag === 'sync-uploads') {
event.waitUntil(syncFailedUploads());
}
});
// IndexedDB helper for failed uploads
async function openFailedUploadsDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('SpeakrFailedUploads', 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('failedUploads')) {
const objectStore = db.createObjectStore('failedUploads', { keyPath: 'id', autoIncrement: true });
objectStore.createIndex('timestamp', 'timestamp', { unique: false });
objectStore.createIndex('clientId', 'clientId', { unique: false });
}
};
});
}
// Get all failed uploads from IndexedDB
async function getFailedUploads(db) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['failedUploads'], 'readonly');
const objectStore = transaction.objectStore('failedUploads');
const request = objectStore.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Delete a failed upload after successful retry
async function deleteFailedUpload(db, id) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['failedUploads'], 'readwrite');
const objectStore = transaction.objectStore('failedUploads');
const request = objectStore.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// Update retry count for a failed upload
async function updateRetryCount(db, id, retryCount, error) {
return new Promise(async (resolve, reject) => {
try {
const transaction = db.transaction(['failedUploads'], 'readwrite');
const objectStore = transaction.objectStore('failedUploads');
const getRequest = objectStore.get(id);
getRequest.onsuccess = () => {
const upload = getRequest.result;
if (!upload) {
reject(new Error('Upload not found'));
return;
}
upload.retryCount = retryCount;
upload.lastRetry = Date.now();
if (error) {
upload.lastError = error;
}
const putRequest = objectStore.put(upload);
putRequest.onsuccess = () => resolve();
putRequest.onerror = () => reject(putRequest.error);
};
getRequest.onerror = () => reject(getRequest.error);
} catch (error) {
reject(error);
}
});
}
// Retry uploading a failed upload
async function retryUpload(upload) {
const formData = new FormData();
// Reconstruct File from ArrayBuffer
const file = new File([upload.fileData], upload.fileName, { type: upload.mimeType });
formData.append('file', file);
if (upload.notes) {
formData.append('notes', upload.notes);
}
if (upload.tags && upload.tags.length > 0) {
upload.tags.forEach(tag => {
formData.append('tags[]', JSON.stringify(tag));
});
}
if (upload.asrOptions) {
if (upload.asrOptions.language) {
formData.append('asr_language', upload.asrOptions.language);
}
if (upload.asrOptions.min_speakers) {
formData.append('asr_min_speakers', upload.asrOptions.min_speakers);
}
if (upload.asrOptions.max_speakers) {
formData.append('asr_max_speakers', upload.asrOptions.max_speakers);
}
}
// Get CSRF token from cookies
const csrfToken = getCookie('csrf_access_token');
const headers = csrfToken ? { 'X-CSRF-TOKEN': csrfToken } : {};
const response = await fetch('/upload', {
method: 'POST',
headers: headers,
body: formData,
credentials: 'same-origin'
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
}
return response.json();
}
// Get cookie value
function getCookie(name) {
const value = `; ${self.cookies || ''}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
// Sync failed uploads from IndexedDB
async function syncFailedUploads() {
console.log('[Service Worker] Syncing failed uploads');
try {
const db = await openFailedUploadsDB();
const failedUploads = await getFailedUploads(db);
if (failedUploads.length === 0) {
console.log('[Service Worker] No failed uploads to retry');
return Promise.resolve();
}
console.log(`[Service Worker] Found ${failedUploads.length} failed uploads to retry`);
// Notify that sync started
await self.registration.showNotification('DictIA Upload Sync', {
body: `Retrying ${failedUploads.length} failed upload(s)...`,
icon: '/static/img/icon-192x192.png',
badge: '/static/img/icon-192x192.png',
tag: 'upload-sync',
requireInteraction: false
});
let successCount = 0;
let failCount = 0;
for (const upload of failedUploads) {
try {
// Limit retries to 3 attempts
if (upload.retryCount >= 3) {
console.log(`[Service Worker] Upload ${upload.id} exceeded retry limit (${upload.retryCount})`);
failCount++;
continue;
}
console.log(`[Service Worker] Retrying upload ${upload.id} (attempt ${upload.retryCount + 1})`);
await retryUpload(upload);
// Success - delete from IndexedDB
await deleteFailedUpload(db, upload.id);
successCount++;
console.log(`[Service Worker] Successfully retried upload ${upload.id}`);
} catch (error) {
// Update retry count
await updateRetryCount(db, upload.id, upload.retryCount + 1, error.message);
failCount++;
console.error(`[Service Worker] Failed to retry upload ${upload.id}:`, error);
}
}
// Show final notification
await self.registration.showNotification('DictIA Upload Sync Complete', {
body: `${successCount} succeeded, ${failCount} failed`,
icon: '/static/img/icon-192x192.png',
badge: '/static/img/icon-192x192.png',
tag: 'upload-sync-complete',
requireInteraction: false
});
return Promise.resolve();
} catch (error) {
console.error('[Service Worker] Failed to sync uploads:', error);
await self.registration.showNotification('DictIA Upload Sync Failed', {
body: 'Could not sync failed uploads. Will retry later.',
icon: '/static/img/icon-192x192.png',
badge: '/static/img/icon-192x192.png',
tag: 'upload-sync-error',
requireInteraction: false
});
return Promise.reject(error);
}
}
// Push notification handler
self.addEventListener('push', (event) => {
console.log('[Service Worker] Push notification received');
const options = {
icon: '/static/img/icon-192x192.png',
badge: '/static/img/icon-192x192.png',
vibrate: [200, 100, 200],
data: {
dateOfArrival: Date.now(),
primaryKey: 1
}
};
if (event.data) {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title || 'DictIA Notification', {
body: data.body || 'You have a new notification',
...options,
data: data
})
);
} else {
event.waitUntil(
self.registration.showNotification('DictIA Notification', {
body: 'You have a new notification',
...options
})
);
}
});
// Notification click handler
self.addEventListener('notificationclick', (event) => {
console.log('[Service Worker] Notification clicked:', event.notification.tag);
event.notification.close();
// Handle different notification types
const urlToOpen = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
// Check if there's already a window open
for (const client of clientList) {
if (client.url === urlToOpen && 'focus' in client) {
return client.focus();
}
}
// If no window is open, open a new one
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
});