-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
Listening...
+
+
+
+
+
-
-
No audio recorded.
-
-
Mic Test Results:
-
Peak Level: 0%
-
Average Level: 0%
-
Status: Testing...
+
+
-
-
-
-
-
-
-
-
Note:
-
- Show raw note content
-
-
-
-
-
@@ -120,6 +203,7 @@
console.log(`App version: ${APP_VERSION}`);
// Initialize PocketBase client
const pb = new PocketBase('https://pocketbase.ccllc.pro');
+ pb.autoCancellation(false);
// Collections (from .env in scaffold)
const NOTES_COLLECTION = 'Notes';
@@ -129,145 +213,207 @@
const NOTE_BODY_HTML_FIELD = 'body_html';
const NOTE_BODY_PLAIN_FIELD = 'body_plain';
const NOTE_USERNAME_FIELD = 'Username';
- let displayNameCache = 'Unknown User';
- let userEmailCache = '';
let mediaRecorder = null;
let recordedChunks = [];
let recordedBlob = null;
- let recognition = null;
- let isRecognizing = false;
+ let titleRecognition = null;
+ let noteRecognition = null;
+ let isTitleRecognizing = false;
+ let isNoteRecognizing = false;
+ let silenceTimer = null;
+ let searchSilenceTimer = null;
- // --- Voice Recognition Setup ---
- let voiceStartPosition = 0; // Track where voice input started
- let lastInterimLength = 0; // Track interim text length
-
- function setupVoiceRecognition() {
- // Check for Web Speech API support
+ // --- Voice Recognition Setup with auto-stop ---
+ function setupTitleVoiceRecognition() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
- console.warn('Web Speech API not supported in this browser');
- document.getElementById('voiceBtn').disabled = true;
- document.getElementById('voiceBtn').title = 'Speech recognition not supported';
+ console.warn('Web Speech API not supported');
+ document.getElementById('titleVoiceBtn').disabled = true;
return null;
}
- recognition = new SpeechRecognition();
+ const recognition = new SpeechRecognition();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'en-US';
recognition.onstart = () => {
- isRecognizing = true;
- const noteTextArea = document.getElementById('noteText');
- voiceStartPosition = noteTextArea.value.length; // Remember where we started
- lastInterimLength = 0;
- document.getElementById('voiceStatus').classList.remove('hidden');
- document.getElementById('voiceBtn').classList.add('bg-red-600', 'hover:bg-red-700');
- document.getElementById('voiceBtn').classList.remove('bg-purple-600', 'hover:bg-purple-700');
- document.getElementById('voiceBtnText').textContent = 'Stop';
+ isTitleRecognizing = true;
+ document.getElementById('titleVoiceBtn').classList.add('text-red-600');
+ document.getElementById('titleVoiceBtn').classList.remove('text-purple-600');
};
recognition.onresult = (event) => {
- const noteTextArea = document.getElementById('noteText');
let finalTranscript = '';
- let interimTranscript = '';
-
- // Build transcript from all results
for (let i = 0; i < event.results.length; i++) {
- const transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) {
- finalTranscript += transcript + ' ';
- } else {
- interimTranscript += transcript;
+ finalTranscript += event.results[i][0].transcript + ' ';
}
}
- // Remove the previous interim text (if any) and add new text
- const baseText = noteTextArea.value.substring(0, voiceStartPosition);
- noteTextArea.value = baseText + finalTranscript + interimTranscript;
-
- // Update status
- document.getElementById('voiceStatusText').textContent =
- interimTranscript ? `Listening: "${interimTranscript}"` : 'Listening...';
-
- // Move cursor to end
- noteTextArea.scrollTop = noteTextArea.scrollHeight;
+ if (finalTranscript) {
+ const titleInput = document.getElementById('noteTitle');
+ titleInput.value = (titleInput.value + finalTranscript).trim();
+
+ // Reset silence timer
+ clearTimeout(silenceTimer);
+ silenceTimer = setTimeout(() => {
+ stopTitleVoiceRecognition();
+ }, 2000);
+ }
};
recognition.onerror = (event) => {
console.error('Speech recognition error:', event.error);
- let errorMsg = 'Error: ';
- switch(event.error) {
- case 'no-speech':
- errorMsg += 'No speech detected. Try again.';
- break;
- case 'audio-capture':
- errorMsg += 'No microphone found.';
- break;
- case 'not-allowed':
- errorMsg += 'Microphone permission denied.';
- break;
- default:
- errorMsg += event.error;
- }
- document.getElementById('voiceStatusText').textContent = errorMsg;
- setTimeout(() => stopVoiceRecognition(), 2000);
+ stopTitleVoiceRecognition();
};
recognition.onend = () => {
- if (isRecognizing) {
- // If we're still supposed to be recognizing, restart
- try {
- recognition.start();
- } catch (e) {
- console.log('Recognition ended:', e);
- stopVoiceRecognition();
- }
+ if (isTitleRecognizing) {
+ isTitleRecognizing = false;
+ document.getElementById('titleVoiceBtn').classList.remove('text-red-600');
+ document.getElementById('titleVoiceBtn').classList.add('text-purple-600');
}
};
return recognition;
}
- function startVoiceRecognition() {
- if (!recognition) {
- recognition = setupVoiceRecognition();
- if (!recognition) return;
+ function setupNoteVoiceRecognition() {
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
+ if (!SpeechRecognition) {
+ console.warn('Web Speech API not supported');
+ document.getElementById('noteVoiceBtn').disabled = true;
+ return null;
}
- // Capture the current text length as the starting position
- const noteTextArea = document.getElementById('noteText');
- voiceStartPosition = noteTextArea.value.length;
- lastInterimLength = 0;
+ const recognition = new SpeechRecognition();
+ recognition.continuous = true;
+ recognition.interimResults = true;
+ recognition.lang = 'en-US';
+ recognition.onstart = () => {
+ isNoteRecognizing = true;
+ document.getElementById('noteVoiceBtn').classList.add('text-red-600');
+ document.getElementById('noteVoiceBtn').classList.remove('text-purple-600');
+ };
+
+ recognition.onresult = (event) => {
+ let finalTranscript = '';
+ for (let i = 0; i < event.results.length; i++) {
+ if (event.results[i].isFinal) {
+ finalTranscript += event.results[i][0].transcript + ' ';
+ }
+ }
+
+ if (finalTranscript) {
+ const noteTextArea = document.getElementById('noteText');
+ noteTextArea.value = (noteTextArea.value + finalTranscript).trim() + ' ';
+ noteTextArea.scrollTop = noteTextArea.scrollHeight;
+
+ // Reset silence timer
+ clearTimeout(silenceTimer);
+ silenceTimer = setTimeout(() => {
+ stopNoteVoiceRecognition();
+ }, 2000);
+ }
+ };
+
+ recognition.onerror = (event) => {
+ console.error('Speech recognition error:', event.error);
+ stopNoteVoiceRecognition();
+ };
+
+ recognition.onend = () => {
+ if (isNoteRecognizing) {
+ isNoteRecognizing = false;
+ document.getElementById('noteVoiceBtn').classList.remove('text-red-600');
+ document.getElementById('noteVoiceBtn').classList.add('text-purple-600');
+ }
+ };
+
+ return recognition;
+ }
+
+ function startTitleVoiceRecognition() {
+ if (!titleRecognition) {
+ titleRecognition = setupTitleVoiceRecognition();
+ if (!titleRecognition) return;
+ }
try {
- recognition.start();
+ clearTimeout(silenceTimer);
+ titleRecognition.start();
} catch (e) {
- console.log('Recognition already started or error:', e);
+ console.log('Recognition error:', e);
}
}
- function stopVoiceRecognition() {
- isRecognizing = false;
- if (recognition) {
+ function stopTitleVoiceRecognition() {
+ isTitleRecognizing = false;
+ clearTimeout(silenceTimer);
+ if (titleRecognition) {
try {
- recognition.stop();
+ titleRecognition.stop();
} catch (e) {
console.log('Error stopping recognition:', e);
}
}
- document.getElementById('voiceStatus').classList.add('hidden');
- document.getElementById('voiceBtn').classList.remove('bg-red-600', 'hover:bg-red-700');
- document.getElementById('voiceBtn').classList.add('bg-purple-600', 'hover:bg-purple-700');
- document.getElementById('voiceBtnText').textContent = 'Speak';
+ document.getElementById('titleVoiceBtn').classList.remove('text-red-600');
+ document.getElementById('titleVoiceBtn').classList.add('text-purple-600');
}
- // Voice button click handler
- document.getElementById('voiceBtn').addEventListener('click', () => {
- if (isRecognizing) {
- stopVoiceRecognition();
+ function startNoteVoiceRecognition() {
+ if (!noteRecognition) {
+ noteRecognition = setupNoteVoiceRecognition();
+ if (!noteRecognition) return;
+ }
+ try {
+ clearTimeout(silenceTimer);
+ noteRecognition.start();
+ } catch (e) {
+ console.log('Recognition error:', e);
+ }
+ }
+
+ function stopNoteVoiceRecognition() {
+ isNoteRecognizing = false;
+ clearTimeout(silenceTimer);
+ if (noteRecognition) {
+ try {
+ noteRecognition.stop();
+ } catch (e) {
+ console.log('Error stopping recognition:', e);
+ }
+ }
+ document.getElementById('noteVoiceBtn').classList.remove('text-red-600');
+ document.getElementById('noteVoiceBtn').classList.add('text-purple-600');
+ }
+
+ // Voice button click handlers
+ document.getElementById('titleVoiceBtn').addEventListener('click', () => {
+ if (isTitleRecognizing) {
+ stopTitleVoiceRecognition();
} else {
- startVoiceRecognition();
+ startTitleVoiceRecognition();
+ }
+ });
+
+ document.getElementById('noteVoiceBtn').addEventListener('click', () => {
+ if (isNoteRecognizing) {
+ stopNoteVoiceRecognition();
+ } else {
+ startNoteVoiceRecognition();
+ }
+ });
+
+ // Job Note selector change handler
+ document.getElementById('jobNote').addEventListener('change', (e) => {
+ const jobNumberContainer = document.getElementById('jobNumberContainer');
+ if (e.target.value === 'yes') {
+ jobNumberContainer.classList.remove('hidden');
+ } else {
+ jobNumberContainer.classList.add('hidden');
+ document.getElementById('jobNumber').value = '';
}
});
@@ -350,17 +496,25 @@
const loginContainer = document.getElementById('loginContainer');
const userDisplay = document.getElementById('userDisplay');
const userEmail = document.getElementById('userEmail');
+ const notesListContainer = document.getElementById('notesListContainer');
+ const noteDetailContainer = document.getElementById('noteDetailContainer');
+ const noteContainer = document.getElementById('noteContainer');
if (pb.authStore.isValid) {
loginContainer.classList.add('hidden');
userDisplay.classList.remove('hidden');
userEmail.classList.remove('hidden');
- document.getElementById('noteContainer').classList.remove('hidden');
+ notesListContainer.classList.remove('hidden');
+ noteDetailContainer.classList.add('hidden');
+ noteContainer.classList.add('hidden');
+ loadNotesList();
} else {
loginContainer.classList.remove('hidden');
userDisplay.classList.add('hidden');
userEmail.classList.add('hidden');
document.getElementById('loginError').classList.add('hidden');
- document.getElementById('noteContainer').classList.add('hidden');
+ notesListContainer.classList.add('hidden');
+ noteDetailContainer.classList.add('hidden');
+ noteContainer.classList.add('hidden');
}
}
@@ -490,108 +644,509 @@
ensureUserLogged();
}
- // --- Audio recording setup ---
+ // --- Audio recording setup with auto-stop on silence ---
+ let audioContext = null;
+ let analyser = null;
+ let silenceStart = null;
+ let silenceCheckInterval = null;
+ const SILENCE_THRESHOLD = 0.01; // Volume threshold for silence
+ const SILENCE_DURATION = 3000; // 3 seconds
+
+ function checkSilence() {
+ if (!analyser) return;
+
+ const bufferLength = analyser.frequencyBinCount;
+ const dataArray = new Uint8Array(bufferLength);
+ analyser.getByteTimeDomainData(dataArray);
+
+ // Calculate average volume
+ let sum = 0;
+ for (let i = 0; i < bufferLength; i++) {
+ const normalized = (dataArray[i] - 128) / 128;
+ sum += normalized * normalized;
+ }
+ const rms = Math.sqrt(sum / bufferLength);
+
+ if (rms < SILENCE_THRESHOLD) {
+ if (!silenceStart) {
+ silenceStart = Date.now();
+ } else if (Date.now() - silenceStart >= SILENCE_DURATION) {
+ stopRecording();
+ }
+ } else {
+ silenceStart = null;
+ }
+ }
+
async function setupRecorder() {
- const stream = await navigator.mediaDevices.getUserMedia({
- audio: {
- autoGainControl: true
- }
- });
+ let stream;
+ try {
+ stream = await navigator.mediaDevices.getUserMedia({
+ audio: {
+ autoGainControl: true
+ }
+ });
+ } catch (err) {
+ console.error('Microphone access failed:', err);
+ document.getElementById('recordStatus').textContent = 'Microphone access denied or unavailable.';
+ throw err;
+ }
recordedChunks = [];
recordedBlob = null;
+
+ // Setup audio context for silence detection
+ audioContext = new (window.AudioContext || window.webkitAudioContext)();
+ analyser = audioContext.createAnalyser();
+ const source = audioContext.createMediaStreamSource(stream);
+ source.connect(analyser);
+ analyser.fftSize = 2048;
+
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) recordedChunks.push(e.data);
};
mediaRecorder.onstop = () => {
+ if (silenceCheckInterval) {
+ clearInterval(silenceCheckInterval);
+ silenceCheckInterval = null;
+ }
+ silenceStart = null;
+ if (audioContext) {
+ audioContext.close();
+ audioContext = null;
+ }
+ stream.getTracks().forEach(track => track.stop());
recordedBlob = new Blob(recordedChunks, { type: mediaRecorder.mimeType });
const audioEl = document.getElementById('audioPreview');
audioEl.src = URL.createObjectURL(recordedBlob);
audioEl.volume = 1.0;
audioEl.classList.remove('hidden');
- document.getElementById('clearAudioLink').classList.remove('hidden');
- document.getElementById('recordStatus').textContent = `Audio recorded (${mediaRecorder.mimeType}).`;
+ document.getElementById('audioPlayer').classList.remove('hidden');
+ document.getElementById('recordStatus').textContent = '';
+ mediaRecorder = null; // allow fresh recorder next start
};
}
async function startRecording() {
- if (!mediaRecorder) await setupRecorder();
- recordedChunks = [];
- recordedBlob = null;
- mediaRecorder.start();
- document.getElementById('recordStatus').textContent = 'Recording…';
- document.getElementById('recordBtn').disabled = true;
- document.getElementById('stopBtn').disabled = false;
+ const recordBtn = document.getElementById('recordBtn');
+ const stopBtn = document.getElementById('stopBtn');
+ try {
+ if (!mediaRecorder) await setupRecorder();
+ silenceStart = null;
+ recordedChunks = [];
+ recordedBlob = null;
+ mediaRecorder.start();
+ document.getElementById('recordStatus').textContent = 'Recording…';
+ recordBtn.disabled = true;
+ stopBtn.disabled = false;
+ stopBtn.classList.remove('hidden');
+
+ // Start silence detection
+ silenceCheckInterval = setInterval(checkSilence, 100);
+ } catch (err) {
+ console.error('Failed to start recording:', err);
+ document.getElementById('recordStatus').textContent = 'Recording not available.';
+ recordBtn.disabled = false;
+ stopBtn.disabled = true;
+ stopBtn.classList.add('hidden');
+ }
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state === 'recording') {
mediaRecorder.stop();
+ if (silenceCheckInterval) {
+ clearInterval(silenceCheckInterval);
+ silenceCheckInterval = null;
+ }
document.getElementById('recordBtn').disabled = false;
- document.getElementById('stopBtn').disabled = true;
+ const stopBtn = document.getElementById('stopBtn');
+ stopBtn.disabled = true;
+ stopBtn.classList.add('hidden');
}
}
function clearAudio() {
+ if (silenceCheckInterval) {
+ clearInterval(silenceCheckInterval);
+ silenceCheckInterval = null;
+ }
+ if (mediaRecorder && mediaRecorder.state === 'recording') {
+ mediaRecorder.stop();
+ }
recordedChunks = [];
recordedBlob = null;
const audioEl = document.getElementById('audioPreview');
audioEl.src = '';
audioEl.classList.add('hidden');
- document.getElementById('clearAudioLink').classList.add('hidden');
- document.getElementById('recordStatus').textContent = 'No audio recorded.';
+ document.getElementById('audioPlayer').classList.add('hidden');
+ document.getElementById('audioMenu').classList.add('hidden');
+ document.getElementById('progressFill').style.width = '0%';
+ document.getElementById('currentTime').textContent = '0:00';
+ document.getElementById('durationTime').textContent = '0:00';
+ document.getElementById('playPauseBtn').textContent = '▶';
+ document.getElementById('recordBtn').disabled = false;
+ const stopBtn = document.getElementById('stopBtn');
+ stopBtn.disabled = true;
+ stopBtn.classList.add('hidden');
+ document.getElementById('recordStatus').textContent = '';
}
document.getElementById('recordBtn').addEventListener('click', startRecording);
document.getElementById('stopBtn').addEventListener('click', stopRecording);
- document.getElementById('clearAudioLink').addEventListener('click', (e) => { e.preventDefault(); clearAudio(); });
+ // Audio options menu
+ const audioMenuContainer = document.getElementById('audioMenuContainer');
+ const audioMenuBtn = document.getElementById('audioMenuBtn');
+ const audioMenu = document.getElementById('audioMenu');
+ const removeAudioBtn = document.getElementById('removeAudioBtn');
+ const audioEl = document.getElementById('audioPreview');
+ const playPauseBtn = document.getElementById('playPauseBtn');
+ const progressTrack = document.getElementById('progressTrack');
+ const progressFill = document.getElementById('progressFill');
+ const currentTimeLabel = document.getElementById('currentTime');
+ const durationTimeLabel = document.getElementById('durationTime');
- // --- Test mic input levels ---
- async function testMicrophone() {
- const resultsDiv = document.getElementById('micTestResults');
- resultsDiv.classList.remove('hidden');
- document.getElementById('micStatus').textContent = 'Testing... speak normally';
+ function formatTime(seconds) {
+ if (isNaN(seconds)) return '0:00';
+ const m = Math.floor(seconds / 60);
+ const s = Math.floor(seconds % 60).toString().padStart(2, '0');
+ return `${m}:${s}`;
+ }
+ function updateProgress() {
+ if (!audioEl.duration || isNaN(audioEl.duration)) return;
+ const pct = (audioEl.currentTime / audioEl.duration) * 100;
+ progressFill.style.width = `${pct}%`;
+ currentTimeLabel.textContent = formatTime(audioEl.currentTime);
+ durationTimeLabel.textContent = formatTime(audioEl.duration);
+ }
+
+ playPauseBtn.addEventListener('click', () => {
+ if (audioEl.paused) {
+ audioEl.play();
+ playPauseBtn.textContent = '⏸';
+ } else {
+ audioEl.pause();
+ playPauseBtn.textContent = '▶';
+ }
+ });
+
+ audioEl.addEventListener('timeupdate', updateProgress);
+ audioEl.addEventListener('loadedmetadata', updateProgress);
+ audioEl.addEventListener('ended', () => {
+ playPauseBtn.textContent = '▶';
+ updateProgress();
+ });
+
+ progressTrack.addEventListener('click', (e) => {
+ if (!audioEl.duration || isNaN(audioEl.duration)) return;
+ const rect = progressTrack.getBoundingClientRect();
+ const ratio = (e.clientX - rect.left) / rect.width;
+ audioEl.currentTime = Math.max(0, Math.min(audioEl.duration * ratio, audioEl.duration));
+ updateProgress();
+ });
+ if (audioMenuBtn && audioMenu && removeAudioBtn && audioMenuContainer) {
+ audioMenuBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ audioMenu.classList.toggle('hidden');
+ });
+ removeAudioBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ audioMenu.classList.add('hidden');
+ clearAudio();
+ });
+ document.addEventListener('click', (e) => {
+ if (!audioMenuContainer.contains(e.target)) {
+ audioMenu.classList.add('hidden');
+ }
+ });
+ }
+
+ // --- Notes list + detail views ---
+ let notesCache = {};
+ let notesList = [];
+ let searchQuery = '';
+ let searchRecognition = null;
+ let isSearchRecognizing = false;
+
+ function formatDateShort(dt) {
try {
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
- const recorder = new MediaRecorder(stream);
- let testChunks = [];
+ const d = new Date(dt);
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
+ const dd = String(d.getDate()).padStart(2, '0');
+ const yyyy = d.getFullYear();
+ let hrs = d.getHours();
+ const mins = String(d.getMinutes()).padStart(2, '0');
+ const ampm = hrs >= 12 ? 'PM' : 'AM';
+ hrs = hrs % 12;
+ if (hrs === 0) hrs = 12;
+ const hh = String(hrs).padStart(2, '0');
+ return `${mm}/${dd}/${yyyy} ${hh}:${mins} ${ampm}`;
+ } catch { return ''; }
+ }
- recorder.ondataavailable = (e) => {
- if (e.data.size > 0) testChunks.push(e.data);
- };
+ function showNotesList() {
+ document.getElementById('notesListContainer').classList.remove('hidden');
+ document.getElementById('noteDetailContainer').classList.add('hidden');
+ document.getElementById('noteContainer').classList.add('hidden');
+ }
- recorder.onstop = () => {
- stream.getTracks().forEach(track => track.stop());
-
- const totalSize = testChunks.reduce((sum, chunk) => sum + chunk.size, 0);
- // Rough estimation: 16kHz, 16-bit, mono = 32KB per second of audio
- // Expected for good audio is ~30-60KB for 2 seconds
- const estimatedLevel = Math.min(100, Math.round((totalSize / 32000) * 100));
+ function showNoteForm() {
+ document.getElementById('notesListContainer').classList.add('hidden');
+ document.getElementById('noteDetailContainer').classList.add('hidden');
+ document.getElementById('noteContainer').classList.remove('hidden');
+ }
- document.getElementById('micPeakLevel').textContent = estimatedLevel;
- document.getElementById('micAvgLevel').textContent = totalSize + ' bytes';
+ function resetNoteForm() {
+ document.getElementById('noteTitle').value = '';
+ document.getElementById('noteText').value = '';
+ document.getElementById('jobNote').value = 'no';
+ document.getElementById('noteType').value = 'personal';
+ document.getElementById('jobNumber').value = '';
+ document.getElementById('jobNumberContainer').classList.add('hidden');
+ clearAudio();
+ }
- if (totalSize < 10000) {
- document.getElementById('micStatus').textContent = '⚠️ Very low - check mic or volume';
- } else if (totalSize < 25000) {
- document.getElementById('micStatus').textContent = '⚠️ Low - increase mic gain or speak louder';
- } else if (totalSize > 80000) {
- document.getElementById('micStatus').textContent = '⚠️ Too loud - may distort';
- } else {
- document.getElementById('micStatus').textContent = '✓ Good level';
+ function renderNotesList(items) {
+ const grid = document.getElementById('notesGrid');
+ const empty = document.getElementById('notesEmpty');
+ grid.innerHTML = '';
+ if (!items.length) {
+ empty.classList.remove('hidden');
+ return;
+ }
+ empty.classList.add('hidden');
+ const frag = document.createDocumentFragment();
+ items.forEach((note) => {
+ const card = document.createElement('button');
+ card.type = 'button';
+ card.dataset.noteId = note.id;
+ const type = (note.type || 'personal').toLowerCase();
+ const typeClasses = {
+ personal: 'bg-amber-100 border border-amber-300 hover:border-amber-400',
+ manager: 'bg-orange-100 border border-orange-300 hover:border-orange-400',
+ job: 'bg-pink-100 border border-pink-300 hover:border-pink-400',
+ }[type] || 'bg-gray-100 border border-gray-300 hover:border-primary/60';
+ card.className = `text-left ${typeClasses} hover:shadow-md transition rounded-xl p-5 sm:p-6 space-y-3 h-full flex flex-col`;
+ const title = note.title || 'Untitled';
+ const plain = note[NOTE_BODY_PLAIN_FIELD] || (note[NOTE_BODY_HTML_FIELD] ? htmlToPlainText(note[NOTE_BODY_HTML_FIELD]) : '');
+ const snippet = (plain || '').slice(0, 160) + ((plain && plain.length > 160) ? '…' : '');
+ const meta = `${note.type || 'personal'} • ${note.job_note ? 'Job' : 'General'}${note.Job_Number ? ' • #' + note.Job_Number : ''}`;
+ const attachmentCount = (note._attachments || []).length;
+ const attachmentBadge = attachmentCount ? `
+
+
+
${attachmentCount}
+
+ ` : '';
+ card.innerHTML = `
+
+
${escapeHtml(title)}
+
${formatDateShort(note.created)}
+
+
${escapeHtml(snippet || '(empty)')}
+
+ ${escapeHtml(meta)}
+ ${attachmentBadge}
+
+ `;
+ frag.appendChild(card);
+ });
+ grid.appendChild(frag);
+ }
+
+ async function loadNotesList() {
+ const status = document.getElementById('notesListStatus');
+ if (!status) return;
+ status.textContent = 'Loading notes...';
+ try {
+ const res = await pb.collection(NOTES_COLLECTION).getList(1, 50, { sort: '-created' });
+ const items = res?.items || [];
+
+ // Fetch all attachments per note so we can display accurate indicators
+ const attachmentsByNote = {};
+ await Promise.all(items.map(async (n) => {
+ try {
+ const atts = await pb.collection(ATTACHMENTS_COLLECTION).getFullList({
+ sort: '-created',
+ filter: `${ATTACHMENTS_PBID_FIELD} = "${n.id}"`,
+ batch: 50,
+ });
+ attachmentsByNote[n.id] = atts || [];
+ } catch {
+ attachmentsByNote[n.id] = [];
}
- };
+ }));
- recorder.start();
- setTimeout(() => recorder.stop(), 2000);
+ notesCache = {};
+ notesList = items.map((n) => ({
+ ...n,
+ _attachments: attachmentsByNote[n.id] || [],
+ _hasAttachment: (attachmentsByNote[n.id] || []).length > 0,
+ }));
+ notesList.forEach((n) => { notesCache[n.id] = n; });
+ applySearch();
+ status.textContent = '';
} catch (e) {
- document.getElementById('micStatus').textContent = `Error: ${e.message}`;
- console.error('Mic test error:', e);
+ console.error('Failed to load notes:', e);
+ status.textContent = `Error loading notes: ${e.message}`;
}
}
- document.getElementById('testMicBtn').addEventListener('click', testMicrophone);
+ function applySearch() {
+ const q = (searchQuery || '').toLowerCase();
+ if (!q) {
+ renderNotesList(notesList);
+ return;
+ }
+ const filtered = notesList.filter((n) => {
+ const hay = [
+ n.title,
+ n[NOTE_BODY_PLAIN_FIELD],
+ n[NOTE_BODY_HTML_FIELD] ? htmlToPlainText(n[NOTE_BODY_HTML_FIELD]) : '',
+ n.Job_Number,
+ n.type,
+ ].join(' ').toLowerCase();
+ return hay.includes(q);
+ });
+ renderNotesList(filtered);
+ }
+
+ async function openNoteDetail(note) {
+ if (!note) return;
+ document.getElementById('notesListContainer').classList.add('hidden');
+ document.getElementById('noteContainer').classList.add('hidden');
+ document.getElementById('noteDetailContainer').classList.remove('hidden');
+
+ document.getElementById('noteDetailTitle').textContent = note.title || 'Untitled';
+ document.getElementById('noteDetailType').textContent = note.type || 'personal';
+ document.getElementById('noteDetailJob').textContent = note.job_note ? 'Yes' : 'No';
+ const jobNumRow = document.getElementById('noteDetailJobNumberRow');
+ const showJobNumber = note.job_note && note.Job_Number;
+ document.getElementById('noteDetailJobNumber').textContent = showJobNumber ? note.Job_Number : '—';
+ jobNumRow.classList.toggle('hidden', !showJobNumber);
+ document.getElementById('noteDetailAuthor').textContent = note.Username || note.email || '(unknown)';
+ document.getElementById('noteDetailMeta').textContent = note.created ? `Created ${new Date(note.created).toLocaleString()}` : '';
+ const body = note[NOTE_BODY_PLAIN_FIELD] || (note[NOTE_BODY_HTML_FIELD] ? htmlToPlainText(note[NOTE_BODY_HTML_FIELD]) : '');
+ document.getElementById('noteDetailBody').textContent = body || '(empty)';
+
+ const audioEl = document.getElementById('noteDetailAudio');
+ const audioStatus = document.getElementById('noteDetailAudioStatus');
+ audioEl.classList.add('hidden');
+ audioEl.src = '';
+ audioStatus.textContent = 'Attachments are hidden in detail view.';
+ }
+
+ document.getElementById('notesGrid').addEventListener('click', (e) => {
+ const card = e.target.closest('[data-note-id]');
+ if (!card) return;
+ const note = notesCache[card.dataset.noteId];
+ if (note) openNoteDetail(note);
+ });
+
+ document.getElementById('newNoteBtn').addEventListener('click', () => {
+ resetNoteForm();
+ showNoteForm();
+ });
+
+ document.getElementById('refreshNotesBtn').addEventListener('click', () => {
+ loadNotesList();
+ });
+
+ document.getElementById('backToListBtn').addEventListener('click', () => {
+ showNotesList();
+ });
+
+ document.getElementById('detailBackBtn').addEventListener('click', () => {
+ showNotesList();
+ });
+
+ document.getElementById('detailNewNoteBtn').addEventListener('click', () => {
+ resetNoteForm();
+ showNoteForm();
+ });
+
+ // Search input + voice
+ const notesSearchInput = document.getElementById('notesSearch');
+ const searchVoiceBtn = document.getElementById('searchVoiceBtn');
+
+ function startSearchVoice() {
+ if (!searchRecognition) {
+ const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
+ if (!SR) {
+ console.warn('Web Speech API not supported');
+ return;
+ }
+ searchRecognition = new SR();
+ searchRecognition.continuous = true;
+ searchRecognition.interimResults = true;
+ searchRecognition.lang = 'en-US';
+
+ searchRecognition.onresult = (event) => {
+ let finalTranscript = '';
+ for (let i = 0; i < event.results.length; i++) {
+ if (event.results[i].isFinal) {
+ finalTranscript += event.results[i][0].transcript + ' ';
+ }
+ }
+ if (finalTranscript) {
+ notesSearchInput.value = finalTranscript.trim();
+ searchQuery = notesSearchInput.value;
+ applySearch();
+ }
+ // auto-stop after 2s of silence
+ clearTimeout(searchSilenceTimer);
+ searchSilenceTimer = setTimeout(() => {
+ stopSearchVoice();
+ }, 2000);
+ };
+
+ searchRecognition.onend = () => {
+ isSearchRecognizing = false;
+ searchVoiceBtn.classList.remove('text-red-600');
+ searchVoiceBtn.classList.add('text-purple-600');
+ };
+
+ searchRecognition.onerror = () => {
+ stopSearchVoice();
+ };
+ }
+
+ try {
+ clearTimeout(searchSilenceTimer);
+ searchRecognition.start();
+ isSearchRecognizing = true;
+ searchVoiceBtn.classList.add('text-red-600');
+ searchVoiceBtn.classList.remove('text-purple-600');
+ } catch (e) {
+ console.warn('Search voice start error:', e);
+ }
+ }
+
+ function stopSearchVoice() {
+ isSearchRecognizing = false;
+ clearTimeout(searchSilenceTimer);
+ if (searchRecognition) {
+ try { searchRecognition.stop(); } catch {}
+ }
+ searchVoiceBtn.classList.remove('text-red-600');
+ searchVoiceBtn.classList.add('text-purple-600');
+ }
+
+ notesSearchInput.addEventListener('input', (e) => {
+ searchQuery = e.target.value;
+ applySearch();
+ });
+
+ searchVoiceBtn.addEventListener('click', () => {
+ if (isSearchRecognizing) {
+ stopSearchVoice();
+ } else {
+ startSearchVoice();
+ }
+ });
// --- Submit note ---
async function submitNote() {
@@ -599,6 +1154,9 @@
statusEl.textContent = '';
const noteText = document.getElementById('noteText').value.trim();
let noteTitle = document.getElementById('noteTitle').value.trim();
+ const jobNoteValue = document.getElementById('jobNote').value;
+ const noteType = document.getElementById('noteType').value;
+ const jobNumber = document.getElementById('jobNumber').value.trim();
if (!pb.authStore.isValid) {
statusEl.textContent = 'Not authenticated.';
return;
@@ -607,6 +1165,10 @@
statusEl.textContent = 'Please enter note text.';
return;
}
+ if (jobNoteValue === 'yes' && !jobNumber) {
+ statusEl.textContent = 'Job number is required when Job Note is Yes.';
+ return;
+ }
// Generate title if empty: "Untitled: first 4 words..."
if (!noteTitle) {
@@ -626,14 +1188,23 @@
try {
// Create note record with display name, email, and title
- const noteRecord = await pb.collection(NOTES_COLLECTION).create({
+ const payload = {
[NOTE_BODY_HTML_FIELD]: plainTextToHTML(noteText),
[NOTE_BODY_PLAIN_FIELD]: noteText,
[NOTE_USERNAME_FIELD]: displayNameCache,
email: userEmailCache,
title: noteTitle,
+ job_note: jobNoteValue === 'yes',
+ type: noteType,
userId: pb.authStore.model?.id || pb.authStore.record?.id,
- });
+ };
+
+ // Only include job_number if it has a value
+ if (jobNumber) {
+ payload.Job_Number = jobNumber;
+ }
+
+ const noteRecord = await pb.collection(NOTES_COLLECTION).create(payload);
let attachmentRecord = null;
if (recordedBlob) {
const file = new File([recordedBlob], 'note-audio.webm', { type: recordedBlob.type || 'audio/webm' });
@@ -646,6 +1217,12 @@
clearAudio();
document.getElementById('noteText').value = '';
document.getElementById('noteTitle').value = '';
+ document.getElementById('jobNote').value = 'no';
+ document.getElementById('jobNumber').value = '';
+ document.getElementById('jobNumberContainer').classList.add('hidden');
+ document.getElementById('noteType').value = 'personal';
+ loadNotesList();
+ showNotesList();
} catch (e) {
console.error(e);
statusEl.textContent = `Error saving: ${e.message}`;
@@ -654,70 +1231,6 @@
document.getElementById('submitNoteBtn').addEventListener('click', submitNote);
- // --- Load last note and attachment ---
- async function loadLastNote() {
- const loadStatus = document.getElementById('loadStatus');
- const lastNoteBox = document.getElementById('lastNote');
- loadStatus.textContent = '';
- lastNoteBox.classList.add('hidden');
- try {
- const notes = await pb.collection(NOTES_COLLECTION).getList(1, 1, { sort: '-created' });
- if (!notes.items.length) {
- loadStatus.textContent = 'No notes found.';
- return;
- }
- const note = notes.items[0];
- function extractPlain(r) {
- // Try actual schema fields in order
- const candidates = [
- r[NOTE_BODY_PLAIN_FIELD],
- r[NOTE_BODY_HTML_FIELD],
- r.note,
- r.content,
- r.body,
- ];
- for (const c of candidates) {
- if (c === undefined || c === null) continue;
- if (typeof c === 'string' && c.trim()) return c;
- if (typeof c === 'object') {
- const maybe = tiptapToPlainText(c);
- if (maybe && maybe.trim()) return maybe;
- }
- }
- return '';
- }
- let plain = extractPlain(note);
- // If `plain` is HTML, convert to text
- if (plain && /<\/?[a-z]/i.test(plain)) {
- plain = htmlToPlainText(plain);
- }
- document.getElementById('lastNoteText').textContent = plain || '(empty)';
- // Show raw for debugging
- const rawEl = document.getElementById('lastNoteRaw');
- rawEl.textContent = JSON.stringify({ body_html: note[NOTE_BODY_HTML_FIELD], body_plain: note[NOTE_BODY_PLAIN_FIELD], Username: note[NOTE_USERNAME_FIELD] }, null, 2);
- // Find attachment by pb_id
- const atts = await pb.collection(ATTACHMENTS_COLLECTION).getList(1, 1, {
- sort: '-created',
- filter: `${ATTACHMENTS_PBID_FIELD} = "${note.id}"`,
- });
- if (atts.items.length) {
- const att = atts.items[0];
- const url = pb.files.getUrl(att, att[ATTACHMENTS_FILE_FIELD]);
- const lastAudio = document.getElementById('lastNoteAudio');
- lastAudio.src = url;
- lastAudio.volume = 1.0;
- } else {
- document.getElementById('lastNoteAudio').src = '';
- }
- lastNoteBox.classList.remove('hidden');
- loadStatus.textContent = 'Loaded.';
- } catch (e) {
- console.error(e);
- loadStatus.textContent = `Error: ${e.message}`;
- }
- }
-
- document.getElementById('loadLastBtn').addEventListener('click', loadLastNote);