Files
Prism-Notes-Main/index.html
T

2867 lines
126 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Prism Notes</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#3730a3',
secondary: '#4c1d95',
}
}
}
}
</script>
<!-- Import standalone tools -->
<script>
// Initialize capture tools globally
console.log('Initializing capture tools...');
window.captureScreenshot = async function() {
console.log('captureScreenshot called');
try {
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
video: { mediaSource: 'screen' }
});
const video = document.createElement('video');
video.srcObject = mediaStream;
return new Promise((resolve) => {
video.onloadedmetadata = () => {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0);
mediaStream.getTracks().forEach(track => track.stop());
resolve(canvas);
};
video.play();
});
} catch (error) {
console.error('Screenshot capture failed:', error);
return null;
}
};
window.captureSnip = async function() {
console.log('captureSnip called');
return new Promise((resolve) => {
// Hide main app elements before showing dialog
const appElements = document.querySelectorAll('body > div:not([style*="z-index"])');
appElements.forEach(el => el.style.visibility = 'hidden');
navigator.mediaDevices.getDisplayMedia({
video: { mediaSource: 'screen' }
}).then(mediaStream => {
// Restore visibility
appElements.forEach(el => el.style.visibility = '');
const video = document.createElement('video');
video.srcObject = mediaStream;
video.onloadedmetadata = () => {
const fullCanvas = document.createElement('canvas');
fullCanvas.width = video.videoWidth;
fullCanvas.height = video.videoHeight;
const fullCtx = fullCanvas.getContext('2d');
fullCtx.drawImage(video, 0, 0);
mediaStream.getTracks().forEach(track => track.stop());
// Create fullscreen preview container
const container = document.createElement('div');
container.style.cssText = `position:fixed;top:0;left:0;width:100%;height:100%;background:black;z-index:99999;overflow:hidden;`;
// Create image preview of the captured screen
const previewImg = document.createElement('img');
previewImg.src = fullCanvas.toDataURL();
previewImg.style.cssText = `width:100%;height:100%;object-fit:contain;`;
// Create overlay canvas for drawing selection
const overlayCanvas = document.createElement('canvas');
overlayCanvas.style.cssText = `position:absolute;top:0;left:0;cursor:crosshair;display:none;`;
// Create control panel
const controlPanel = document.createElement('div');
controlPanel.style.cssText = `position:absolute;bottom:30px;left:50%;transform:translateX(-50%);display:flex;gap:15px;z-index:100001;`;
const fullScreenBtn = document.createElement('button');
fullScreenBtn.textContent = 'Share Entire Screen';
fullScreenBtn.style.cssText = `padding:12px 24px;background:#10B981;color:white;border:none;border-radius:6px;font-size:16px;font-weight:bold;cursor:pointer;`;
const snippetBtn = document.createElement('button');
snippetBtn.textContent = 'Snip';
snippetBtn.style.cssText = `padding:12px 24px;background:#0EA5E9;color:white;border:none;border-radius:6px;font-size:16px;font-weight:bold;cursor:pointer;`;
const cancelBtn = document.createElement('button');
cancelBtn.textContent = '✕ Cancel';
cancelBtn.style.cssText = `padding:12px 24px;background:#EF4444;color:white;border:none;border-radius:6px;font-size:16px;font-weight:bold;cursor:pointer;`;
controlPanel.appendChild(fullScreenBtn);
controlPanel.appendChild(snippetBtn);
controlPanel.appendChild(cancelBtn);
container.appendChild(previewImg);
container.appendChild(overlayCanvas);
container.appendChild(controlPanel);
document.body.appendChild(container);
let snippingMode = false;
// Wait for image to load
previewImg.onload = () => {
const rect = previewImg.getBoundingClientRect();
overlayCanvas.width = rect.width;
overlayCanvas.height = rect.height;
const displayWidth = fullCanvas.width;
const displayHeight = fullCanvas.height;
const scaleX = displayWidth / rect.width;
const scaleY = displayHeight / rect.height;
let isDrawing = false, startX = 0, startY = 0, endX = 0, endY = 0;
const ctx = overlayCanvas.getContext('2d');
function drawSelection() {
ctx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
if (!isDrawing && startX === endX && startY === endY) return;
const x = Math.min(startX, endX);
const y = Math.min(startY, endY);
const w = Math.abs(endX - startX);
const h = Math.abs(endY - startY);
ctx.strokeStyle = '#0EA5E9';
ctx.lineWidth = 3;
ctx.strokeRect(x, y, w, h);
ctx.fillStyle = 'rgba(14, 165, 233, 0.1)';
ctx.fillRect(x, y, w, h);
const sz = 12;
ctx.fillStyle = '#0EA5E9';
ctx.fillRect(x - sz/2, y - sz/2, sz, sz);
ctx.fillRect(x + w - sz/2, y - sz/2, sz, sz);
ctx.fillRect(x - sz/2, y + h - sz/2, sz, sz);
ctx.fillRect(x + w - sz/2, y + h - sz/2, sz, sz);
}
overlayCanvas.addEventListener('mousedown', (e) => {
if (!snippingMode) return;
const rect = overlayCanvas.getBoundingClientRect();
isDrawing = true;
startX = e.clientX - rect.left;
startY = e.clientY - rect.top;
endX = startX;
endY = startY;
});
overlayCanvas.addEventListener('mousemove', (e) => {
if (!snippingMode || !isDrawing) return;
const rect = overlayCanvas.getBoundingClientRect();
endX = e.clientX - rect.left;
endY = e.clientY - rect.top;
drawSelection();
});
overlayCanvas.addEventListener('mouseup', () => {
if (!snippingMode || !isDrawing) return;
isDrawing = false;
const x = Math.min(startX, endX);
const y = Math.min(startY, endY);
const w = Math.abs(endX - startX);
const h = Math.abs(endY - startY);
if (w < 10 || h < 10) {
console.warn('Selection too small');
return;
}
const croppedCanvas = document.createElement('canvas');
croppedCanvas.width = w * scaleX;
croppedCanvas.height = h * scaleY;
const croppedCtx = croppedCanvas.getContext('2d');
croppedCtx.drawImage(fullCanvas, x * scaleX, y * scaleY, w * scaleX, h * scaleY, 0, 0, w * scaleX, h * scaleY);
cleanup();
resolve(croppedCanvas);
});
fullScreenBtn.addEventListener('click', () => {
cleanup();
resolve(fullCanvas);
});
snippetBtn.addEventListener('click', () => {
snippingMode = true;
overlayCanvas.style.display = 'block';
controlPanel.style.display = 'none';
console.log('Snipping mode active: Draw rectangle to select. ESC to cancel.');
});
cancelBtn.addEventListener('click', () => {
cleanup();
resolve(null);
});
const handleKeydown = (e) => {
if (e.key === 'Escape') {
if (snippingMode) {
snippingMode = false;
overlayCanvas.style.display = 'none';
controlPanel.style.display = 'flex';
ctx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
startX = startY = endX = endY = 0;
} else {
cleanup();
resolve(null);
}
}
};
function cleanup() {
document.removeEventListener('keydown', handleKeydown);
container.remove();
}
document.addEventListener('keydown', handleKeydown);
};
};
video.play();
}).catch(error => {
// Restore visibility on error
appElements.forEach(el => el.style.visibility = '');
console.error('Capture failed:', error);
resolve(null);
});
});
};
window.canvasToFile = async function(canvas, filename = null) {
console.log('canvasToFile called');
return new Promise((resolve) => {
const name = filename || `screenshot-${Date.now()}.png`;
canvas.toBlob((blob) => {
const file = new File([blob], name, { type: 'image/png' });
resolve(file);
});
});
};
window.recordScreen = async function(options = {}) {
const {
maxDuration = 5 * 60 * 1000,
mimeType = 'video/webm',
onStart = null,
onStop = null
} = options;
try {
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
video: { mediaSource: 'screen' },
audio: false
});
const mediaRecorder = new MediaRecorder(mediaStream, { mimeType });
const chunks = [];
let isRecording = true;
let timeoutId = null;
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunks.push(event.data);
}
};
mediaRecorder.onstop = () => {
isRecording = false;
clearTimeout(timeoutId);
mediaStream.getTracks().forEach(track => track.stop());
if (onStop) onStop();
};
mediaRecorder.start();
if (onStart) onStart();
timeoutId = setTimeout(() => {
if (isRecording) {
mediaRecorder.stop();
}
}, maxDuration);
mediaStream.getVideoTracks()[0].onended = () => {
if (isRecording) {
mediaRecorder.stop();
}
};
return {
stop: async () => {
return new Promise((resolve) => {
if (!isRecording) {
resolve(null);
return;
}
const stopHandler = () => {
mediaRecorder.removeEventListener('stop', stopHandler);
const blob = new Blob(chunks, { type: mimeType });
const file = new File(
[blob],
`screen-recording-${Date.now()}.webm`,
{ type: mimeType }
);
resolve(file);
};
mediaRecorder.addEventListener('stop', stopHandler);
mediaRecorder.stop();
});
},
isRecording: () => isRecording,
getDuration: () => mediaRecorder.state === 'recording' ? mediaRecorder.state : 0,
mediaStream
};
} catch (error) {
console.error('Screen recording failed:', error);
return null;
}
};
console.log('✓ Capture tools initialized successfully');
console.log('Available: window.captureScreenshot, window.captureSnip, window.canvasToFile, window.recordScreen');
// Alert sound function
window.testAlertSound = async function() {
try {
console.log('🔔 Testing alert sound...');
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const now = audioContext.currentTime;
const playBell = (startTime, volume = 1) => {
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
osc.connect(gain);
gain.connect(audioContext.destination);
osc.frequency.setValueAtTime(1000, startTime);
osc.frequency.exponentialRampToValueAtTime(600, startTime + 0.15);
gain.gain.setValueAtTime(0.3 * volume, startTime);
gain.gain.exponentialRampToValueAtTime(0.01, startTime + 0.2);
osc.start(startTime);
osc.stop(startTime + 0.2);
};
playBell(now, 1);
playBell(now + 0.2, 0.7);
console.log('✓ Sound played');
} catch (error) {
console.error('Failed to play sound:', error);
}
};
console.log('✓ testAlertSound available - call window.testAlertSound()');
</script>
</head>
<body class="bg-gradient-to-br from-primary to-secondary p-4 sm:p-8 flex flex-col items-center justify-center font-sans relative h-screen overflow-hidden">
<!-- Top-right display name -->
<div id="userDisplay" class="hidden fixed right-2 text-white text-xs font-semibold drop-shadow z-50 top-1">
<span id="userDisplayName"></span>
</div>
<!-- Bottom-left email -->
<div id="userEmail" class="hidden absolute bottom-2 left-2 text-white text-xs drop-shadow">
<span id="userEmailValue"></span>
</div>
<!-- Bottom-right version -->
<div class="fixed bottom-2 right-2 text-white/80 text-xs drop-shadow z-50">
v1.0.0-alpha3
</div>
<!-- Login Container -->
<div id="loginContainer" class="bg-white rounded-2xl shadow-2xl max-w-md w-full p-8">
<div class="text-center mb-8">
<h1 class="text-4xl font-bold text-gray-800 mb-2">PB + Microsoft Graph</h1>
<p class="text-gray-600">Sign in with Microsoft</p>
</div>
<div class="space-y-6">
<p class="text-gray-700 text-center">Sign in with your Microsoft account</p>
<button type="button" id="loginBtn" class="w-full py-3 px-4 bg-gradient-to-br from-primary to-secondary text-white font-semibold rounded-lg hover:opacity-95 transition-opacity shadow-lg hover:shadow-xl">
Login with Microsoft
</button>
</div>
<div id="loginError" class="hidden mt-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm"></div>
</div>
<!-- Notes List Container -->
<div id="notesListContainer" class="hidden flex flex-col bg-purple-100 rounded-2xl shadow-2xl border border-purple-200 max-w-5xl w-full p-6 gap-4 max-h-[calc(100vh-136px)] overflow-hidden mb-3" style="transform: translateY(-24px);">
<div class="flex-shrink-0">
<div class="flex flex-row items-center justify-between gap-3">
<div>
<h2 class="text-lg sm:text-2xl font-bold text-gray-800">Notes</h2>
</div>
<div class="flex gap-2 flex-wrap">
<button id="refreshNotesBtn" class="h-6 px-1.5 py-0 rounded hover:bg-gray-200 text-gray-700 transition flex items-center justify-center" title="Refresh notes">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" class="w-5 h-5">
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"/>
</svg>
</button>
<button id="newNoteBtn" class="h-6 px-2 sm:px-3 py-0 text-xs leading-none rounded bg-secondary text-white font-semibold flex items-center justify-center">New Note</button>
<button id="conversationsBtn" class="h-6 px-2 sm:px-3 py-0 text-xs leading-none rounded bg-secondary text-white font-semibold flex items-center justify-center">Conversations</button>
</div>
</div>
<div class="w-full mt-3">
<div class="flex items-center gap-2 border border-gray-200 rounded-lg px-3 py-2 bg-gray-50">
<input id="notesSearch" type="text" class="flex-1 bg-transparent outline-none text-sm text-gray-800" placeholder="Search notes..." />
<button id="searchVoiceBtn" class="text-purple-600 hover:text-purple-700" title="Voice search">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7 4a3 3 0 016 0v4a3 3 0 11-6 0V4zm4 10.93A7.001 7.001 0 0017 8a1 1 0 10-2 0A5 5 0 015 8a1 1 0 00-2 0 7.001 7.001 0 006 6.93V17H6a1 1 0 100 2h8a1 1 0 100-2h-3v-2.07z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
<!-- Attachment preview panel -->
<div id="notesAttachmentPreviewPanel" class="hidden mt-3 rounded-xl border border-purple-200 bg-white shadow p-4">
<div class="flex items-center justify-between mb-2">
<div id="notesAttachmentPreviewTitle" class="text-sm font-semibold text-gray-800">Attachment Preview</div>
<button id="notesAttachmentPreviewClose" class="px-2 py-1 rounded border border-gray-200 text-gray-700 hover:bg-gray-100 text-sm">Close</button>
</div>
<div id="notesAttachmentPreviewContent" class="space-y-2"></div>
</div>
</div>
<div class="flex-1 overflow-x-hidden overflow-y-auto">
<div id="notesListStatus" class="text-sm text-gray-600"></div>
<div id="notesEmpty" class="hidden text-sm text-gray-500">No notes yet. Click New Note to create one.</div>
<div id="notesGrid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 w-full"></div>
</div>
</div>
<!-- Note Detail Container -->
<div id="noteDetailContainer" class="hidden flex flex-col bg-white rounded-2xl shadow-2xl max-w-4xl w-full p-6 gap-3 overflow-hidden mt-2.5 max-h-[calc(100vh-140px)]">
<div class="flex-shrink-0 flex items-center justify-between">
<div>
<div class="text-xs text-gray-500" id="noteDetailMeta"></div>
<h2 id="noteDetailTitle" class="text-2xl font-bold text-gray-900">Note</h2>
</div>
<div class="flex gap-2">
<button id="detailBackBtn" class="px-3 py-2 rounded border border-gray-200 text-gray-700 hover:bg-gray-100">Back to Notes</button>
<button id="detailNewNoteBtn" class="px-4 py-2 rounded bg-secondary text-white font-semibold">New Note</button>
</div>
</div>
<div class="min-h-0 overflow-y-auto">
<div class="text-gray-800 leading-relaxed whitespace-pre-wrap border border-gray-100 rounded-lg p-3 bg-gray-50 mb-3" id="noteDetailBody"></div>
<div class="space-y-2">
<div class="text-sm text-gray-600">Audio</div>
<audio id="noteDetailAudio" controls class="hidden w-full"></audio>
<div id="noteDetailAudioStatus" class="text-xs text-gray-500"></div>
</div>
<div class="space-y-2">
<div class="text-sm text-gray-600">Attachments</div>
<div id="noteDetailAttachments" class="space-y-1 text-sm text-gray-800"></div>
<div id="noteDetailAttachmentsStatus" class="text-xs text-gray-500"></div>
</div>
</div>
</div>
<!-- Note + Audio Container -->
<div id="noteContainer" class="hidden self-center bg-white rounded-2xl shadow-2xl max-w-xl w-full py-[8px] px-8 space-y-2 mt-4" style="transform: translateY(-33px);">
<div class="flex items-center gap-2 flex-wrap">
<button id="backToListBtn" class="px-2 sm:px-3 py-1 sm:py-2 text-xs sm:text-sm rounded border border-gray-200 text-gray-700 hover:bg-gray-100">Back to Notes</button>
<h2 class="text-lg sm:text-2xl font-bold text-gray-800">New Note</h2>
</div>
<div class="space-y-2.5">
<div>
<label for="noteTitle" class="block text-sm font-medium text-gray-700 mb-1">Title</label>
<div class="relative">
<input type="text" id="noteTitle" class="w-full rounded-lg border border-gray-300 p-3 pr-12 focus:outline-none focus:ring-2 focus:ring-primary" placeholder="Enter note title (optional)">
<button id="titleVoiceBtn" class="absolute right-3 top-1/2 -translate-y-1/2 text-purple-600 hover:text-purple-700 transition-colors" title="Click to speak title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7 4a3 3 0 016 0v4a3 3 0 11-6 0V4zm4 10.93A7.001 7.001 0 0017 8a1 1 0 10-2 0A5 5 0 015 8a1 1 0 00-2 0 7.001 7.001 0 006 6.93V17H6a1 1 0 100 2h8a1 1 0 100-2h-3v-2.07z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
<div class="flex gap-4">
<div class="flex-1">
<label for="jobNote" class="block text-sm font-medium text-gray-700 mb-1">Job Note</label>
<select id="jobNote" class="w-full rounded-lg border border-gray-300 p-3 focus:outline-none focus:ring-2 focus:ring-primary">
<option value="no">No</option>
<option value="yes">Yes</option>
</select>
</div>
<div class="flex-1">
<label for="noteType" class="block text-sm font-medium text-gray-700 mb-1">Note Type</label>
<select id="noteType" class="w-full rounded-lg border border-gray-300 p-3 focus:outline-none focus:ring-2 focus:ring-primary">
<option value="personal">Personal</option>
<option value="manager">Manager</option>
<option value="job">Job</option>
</select>
</div>
</div>
<div id="jobNumberContainer" class="hidden">
<label for="jobNumber" class="block text-sm font-medium text-gray-700 mb-1">Job Number <span class="text-red-600">*</span></label>
<input type="text" id="jobNumber" class="w-full rounded-lg border border-gray-300 p-3 focus:outline-none focus:ring-2 focus:ring-primary" placeholder="Enter job number (required)">
</div>
<div>
<label for="shareNoteInput" class="block text-sm font-medium text-gray-700 mb-2">Share Note (comma-separated names)</label>
<input type="text" id="shareNoteInput" class="w-full rounded-lg border border-gray-300 p-3 focus:outline-none focus:ring-2 focus:ring-primary" placeholder="Enter names separated by commas (e.g., John, Sarah, Mike)">
<div id="selectedUsersContainer" class="flex flex-wrap gap-2 mt-3">
<!-- Selected user capsules will appear here -->
</div>
</div>
<div>
<label for="noteText" class="block text-sm font-medium text-gray-700 mb-1">Note Text</label>
<div class="relative">
<textarea id="noteText" rows="4" class="w-full rounded-lg border border-gray-300 p-3 pr-12 focus:outline-none focus:ring-2 focus:ring-primary" placeholder="Type your note or click speaker icon to dictate..."></textarea>
<button id="noteVoiceBtn" class="absolute right-3 top-3 text-purple-600 hover:text-purple-700 transition-colors" title="Click to speak note">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7 4a3 3 0 016 0v4a3 3 0 11-6 0V4zm4 10.93A7.001 7.001 0 0017 8a1 1 0 10-2 0A5 5 0 015 8a1 1 0 00-2 0 7.001 7.001 0 006 6.93V17H6a1 1 0 100 2h8a1 1 0 100-2h-3v-2.07z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
<div class="space-y-2 relative pb-32 -mt-8 gap-2">
<div class="flex items-center gap-3">
<button id="stopBtn" class="hidden w-10 h-10 flex items-center justify-center rounded-full bg-red-600 text-white shadow disabled:opacity-50" title="Stop recording" disabled>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<rect x="5" y="5" width="10" height="10" rx="2"></rect>
</svg>
</button>
</div>
<button id="recordBtn" class="absolute bottom-12 right-0 w-24 h-24 rounded-full overflow-hidden shadow-lg hover:shadow-xl transition-shadow disabled:opacity-50 border-4 border-white bg-white" title="Click to record audio">
<img src="images/prism.png" alt="Prism" class="w-full h-full object-cover object-center scale-125">
</button>
<div class="relative">
<audio id="audioPreview" class="hidden" preload="metadata"></audio>
<div id="audioPlayer" class="hidden mt-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-3 shadow-sm">
<div class="flex items-center gap-3">
<button id="playPauseBtn" class="w-10 h-10 flex items-center justify-center rounded-full bg-primary text-white shadow" title="Play">
</button>
<div class="flex-1">
<div class="h-2 bg-gray-200 rounded-full overflow-hidden cursor-pointer" id="progressTrack">
<div id="progressFill" class="h-full bg-primary w-0"></div>
</div>
<div class="flex justify-between text-xs text-gray-600 mt-1">
<span id="currentTime">0:00</span>
<span id="durationTime">0:00</span>
</div>
</div>
<div id="audioMenuContainer" class="relative">
<button id="audioMenuBtn" class="px-2 py-1 rounded bg-white text-gray-700 hover:bg-gray-100 border border-gray-200" title="More options"></button>
<div id="audioMenu" class="hidden absolute right-0 mt-1 w-36 rounded border border-gray-200 bg-white shadow-lg">
<button id="removeAudioBtn" class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-100">Remove audio</button>
</div>
</div>
</div>
</div>
</div>
<div id="recordStatus" class="text-xs text-gray-500"></div>
</div>
<div class="space-y-2">
<div class="relative">
<div class="flex items-center gap-2">
<button id="attachmentMenuBtn" class="px-2 py-2 rounded border border-gray-300 text-gray-700 hover:bg-gray-100 transition flex items-center gap-2" type="button" title="Add attachment">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-5 h-5">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
</svg>
</button>
<button id="clearAttachmentsBtn" class="px-3 py-1 rounded border border-gray-200 text-gray-700 hover:bg-gray-100 text-sm" type="button">Clear</button>
<div id="noteAttachmentsSummary" class="text-xs text-gray-600">No attachments selected.</div>
</div>
<!-- Attachment Menu -->
<div id="attachmentMenu" class="hidden absolute top-0 left-0 ml-28 w-48 rounded border border-gray-300 bg-white shadow-lg z-10">
<button id="attachImageBtn" class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center justify-between gap-2" type="button">
<span class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
</svg>
Attach Image
</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
<div id="imageSubmenu" class="hidden bg-gray-50 border-t border-gray-200">
<button id="takePhotoBtn" class="mobile-only w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/>
</svg>
Take Photo
</button>
<button id="takeSnipBtn" class="desktop-only hidden w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
<path d="M3 3h6v6H3zM15 3h6v6h-6zM3 15h6v6H3zM15 15h6v6h-6z"/><line x1="9" y1="3" x2="9" y2="24"/><line x1="15" y1="3" x2="15" y2="24"/><line x1="3" y1="9" x2="24" y2="9"/><line x1="3" y1="15" x2="24" y2="15"/>
</svg>
Screenshot/Clip
</button>
<button id="selectImageBtn" class="w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/>
</svg>
Select Image
</button>
</div>
<button id="attachVideoBtn" class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center justify-between gap-2" type="button">
<span class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
Attach Video
</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
<div id="videoSubmenu" class="hidden bg-gray-50 border-t border-gray-200">
<button id="recordVideoBtn" class="mobile-only w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
<circle cx="12" cy="13" r="9"/><path d="M12 10v6"/>
</svg>
Take Video
</button>
<button id="recordScreenBtn" class="desktop-only hidden w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/><circle cx="12" cy="10" r="2"/>
</svg>
Record Screen
</button>
<button id="selectVideoBtn" class="w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/>
</svg>
Select Video
</button>
</div>
<button id="attachFileBtn" class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/>
</svg>
Attach File
</button>
</div>
<!-- Hidden file inputs -->
<input id="imageInput" type="file" class="hidden" accept="image/*" multiple>
<input id="imageCameraInput" type="file" class="hidden" accept="image/*" multiple capture="environment">
<input id="videoInput" type="file" class="hidden" accept="video/*">
<input id="videoCameraInput" type="file" class="hidden" accept="video/*" capture="environment">
<input id="fileInput" type="file" class="hidden" multiple accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar">
<div id="noteAttachmentsList" class="space-y-1"></div>
</div>
</div>
<div class="flex items-center gap-3 -mt-8">
<button id="submitNoteBtn" class="px-4 py-2 rounded bg-secondary text-white font-semibold">Submit Note</button>
<div id="submitStatus" class="text-sm text-gray-600"></div>
</div>
</div>
<!-- Conversations List Container -->
<div id="conversationsContainer" class="hidden flex flex-col bg-purple-100 rounded-2xl shadow-2xl border border-purple-200 max-w-5xl w-full p-6 gap-4 flex-1 overflow-hidden mt-2.5 mb-3">
<div class="flex-shrink-0">
<div class="flex flex-row items-center justify-between gap-3">
<div>
<h2 class="text-lg sm:text-2xl font-bold text-gray-800">Conversations</h2>
</div>
<div class="flex gap-2 flex-wrap">
<button id="backToListFromConversationsBtn" class="px-2 sm:px-3 py-1 sm:py-2 text-xs sm:text-sm rounded border border-gray-200 text-gray-700 hover:bg-gray-100">Back to Notes</button>
<button id="newConversationBtn" class="px-3 sm:px-4 py-1 sm:py-2 text-xs sm:text-sm rounded bg-secondary text-white font-semibold">New Conversation</button>
</div>
</div>
</div>
<div class="flex-1 overflow-x-hidden overflow-y-auto">
<div id="conversationsListStatus" class="text-sm text-gray-600"></div>
<div id="conversationsEmpty" class="hidden text-sm text-gray-500">No conversations yet. Click New Conversation to start one.</div>
<div id="conversationsGrid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 w-full"></div>
</div>
</div>
<!-- Conversation Form Container -->
<div id="conversationContainer" class="hidden self-center bg-white rounded-2xl shadow-2xl max-w-xl w-full p-8 space-y-6">
<div class="flex items-center gap-2 flex-wrap">
<button id="backToConversationsListBtn" class="px-2 sm:px-3 py-1 sm:py-2 text-xs sm:text-sm rounded border border-gray-200 text-gray-700 hover:bg-gray-100">Back to Conversations</button>
<h2 class="text-lg sm:text-2xl font-bold text-gray-800">New Conversation</h2>
</div>
<div class="space-y-4">
<div>
<label for="conversationTitle" class="block text-sm font-medium text-gray-700 mb-1">Title</label>
<div class="relative">
<input type="text" id="conversationTitle" class="w-full rounded-lg border border-gray-300 p-3 pr-12 focus:outline-none focus:ring-2 focus:ring-primary" placeholder="Enter conversation title (optional)">
<button id="conversationTitleVoiceBtn" class="absolute right-3 top-1/2 -translate-y-1/2 text-purple-600 hover:text-purple-700 transition-colors" title="Click to speak title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7 4a3 3 0 016 0v4a3 3 0 11-6 0V4zm4 10.93A7.001 7.001 0 0017 8a1 1 0 10-2 0A5 5 0 015 8a1 1 0 00-2 0 7.001 7.001 0 006 6.93V17H6a1 1 0 100 2h8a1 1 0 100-2h-3v-2.07z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
<div class="flex gap-4">
<div class="flex-1">
<label for="conversationIsJobConversation" class="block text-sm font-medium text-gray-700 mb-1">Job Conversation</label>
<select id="conversationIsJobConversation" class="w-full rounded-lg border border-gray-300 p-3 focus:outline-none focus:ring-2 focus:ring-primary">
<option value="no">No</option>
<option value="yes">Yes</option>
</select>
</div>
<div class="flex-1">
<label for="conversationType" class="block text-sm font-medium text-gray-700 mb-1">Type</label>
<select id="conversationType" class="w-full rounded-lg border border-gray-300 p-3 focus:outline-none focus:ring-2 focus:ring-primary">
<option value="personal">Personal</option>
<option value="manager">Manager</option>
<option value="job">Job</option>
</select>
</div>
</div>
<div id="conversationJobNumberContainer" class="hidden">
<label for="conversationJobNumber" class="block text-sm font-medium text-gray-700 mb-1">Job Number <span class="text-red-600">*</span></label>
<input type="text" id="conversationJobNumber" class="w-full rounded-lg border border-gray-300 p-3 focus:outline-none focus:ring-2 focus:ring-primary" placeholder="Enter job number (required)">
</div>
<div>
<label for="conversationText" class="block text-sm font-medium text-gray-700 mb-1">Conversation Text</label>
<div class="relative">
<textarea id="conversationText" rows="4" class="w-full rounded-lg border border-gray-300 p-3 pr-12 focus:outline-none focus:ring-2 focus:ring-primary" placeholder="Type your conversation or click speaker icon to dictate..."></textarea>
<button id="conversationVoiceBtn" class="absolute right-3 top-3 text-purple-600 hover:text-purple-700 transition-colors" title="Click to speak conversation">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7 4a3 3 0 016 0v4a3 3 0 11-6 0V4zm4 10.93A7.001 7.001 0 0017 8a1 1 0 10-2 0A5 5 0 015 8a1 1 0 00-2 0 7.001 7.001 0 006 6.93V17H6a1 1 0 100 2h8a1 1 0 100-2h-3v-2.07z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
<div class="space-y-2 relative pb-32">
<div class="flex items-center gap-3">
<button id="conversationStopBtn" class="hidden w-10 h-10 flex items-center justify-center rounded-full bg-red-600 text-white shadow disabled:opacity-50" title="Stop recording" disabled>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<rect x="5" y="5" width="10" height="10" rx="2"></rect>
</svg>
</button>
</div>
<button id="conversationRecordBtn" class="absolute bottom-12 right-0 w-24 h-24 rounded-full overflow-hidden shadow-lg hover:shadow-xl transition-shadow disabled:opacity-50 border-4 border-white bg-white" title="Click to record audio">
<img src="images/prism.png" alt="Prism" class="w-full h-full object-cover object-center scale-125">
</button>
<div class="relative">
<audio id="conversationAudioPreview" class="hidden" preload="metadata"></audio>
<div id="conversationAudioPlayer" class="hidden mt-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-3 shadow-sm">
<div class="flex items-center gap-3">
<button id="conversationPlayPauseBtn" class="w-10 h-10 flex items-center justify-center rounded-full bg-primary text-white shadow" title="Play">
</button>
<div class="flex-1">
<div class="h-2 bg-gray-200 rounded-full overflow-hidden cursor-pointer" id="conversationProgressTrack">
<div id="conversationProgressFill" class="h-full bg-primary w-0"></div>
</div>
<div class="flex justify-between text-xs text-gray-600 mt-1">
<span id="conversationCurrentTime">0:00</span>
<span id="conversationDurationTime">0:00</span>
</div>
</div>
<div id="conversationAudioMenuContainer" class="relative">
<button id="conversationAudioMenuBtn" class="px-2 py-1 rounded bg-white text-gray-700 hover:bg-gray-100 border border-gray-200" title="More options"></button>
<div id="conversationAudioMenu" class="hidden absolute right-0 mt-1 w-36 rounded border border-gray-200 bg-white shadow-lg">
<button id="conversationRemoveAudioBtn" class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-100">Remove audio</button>
</div>
</div>
</div>
</div>
</div>
<div id="conversationRecordStatus" class="text-xs text-gray-500"></div>
</div>
<div class="space-y-2">
<div class="relative">
<div class="flex items-center gap-2">
<button id="conversationAttachmentMenuBtn" class="px-2 py-2 rounded border border-gray-300 text-gray-700 hover:bg-gray-100 transition flex items-center gap-2" type="button" title="Add attachment">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-5 h-5">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
</svg>
</button>
<button id="conversationClearAttachmentsBtn" class="px-3 py-1 rounded border border-gray-200 text-gray-700 hover:bg-gray-100 text-sm" type="button">Clear</button>
<div id="conversationAttachmentsSummary" class="text-xs text-gray-600">No attachments selected.</div>
</div>
<!-- Conversation Attachment Menu -->
<div id="conversationAttachmentMenu" class="hidden absolute top-0 left-0 ml-28 w-48 rounded border border-gray-300 bg-white shadow-lg z-10">
<button id="conversationAttachImageBtn" class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center justify-between gap-2" type="button">
<span class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
</svg>
Attach Image
</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
<div id="conversationImageSubmenu" class="hidden bg-gray-50 border-t border-gray-200">
<button id="conversationTakePhotoBtn" class="mobile-only w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/>
</svg>
Take Photo
</button>
<button id="conversationTakeSnipBtn" class="desktop-only hidden w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
<path d="M3 3h6v6H3zM15 3h6v6h-6zM3 15h6v6H3zM15 15h6v6h-6z"/><line x1="9" y1="3" x2="9" y2="24"/><line x1="15" y1="3" x2="15" y2="24"/><line x1="3" y1="9" x2="24" y2="9"/><line x1="3" y1="15" x2="24" y2="15"/>
</svg>
Screenshot/Clip
</button>
<button id="conversationSelectImageBtn" class="w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/>
</svg>
Select Image
</button>
</div>
<button id="conversationAttachVideoBtn" class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center justify-between gap-2" type="button">
<span class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
Attach Video
</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
<div id="conversationVideoSubmenu" class="hidden bg-gray-50 border-t border-gray-200">
<button id="conversationRecordVideoBtn" class="mobile-only w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
<circle cx="12" cy="13" r="9"/><path d="M12 10v6"/>
</svg>
Take Video
</button>
<button id="conversationRecordScreenBtn" class="desktop-only hidden w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/><circle cx="12" cy="10" r="2"/>
</svg>
Record Screen
</button>
<button id="conversationSelectVideoBtn" class="w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/>
</svg>
Select Video
</button>
</div>
<button id="conversationAttachFileBtn" class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/>
</svg>
Attach File
</button>
</div>
<!-- Hidden file inputs for conversation -->
<input id="conversationImageInput" type="file" class="hidden" accept="image/*" multiple>
<input id="conversationImageCameraInput" type="file" class="hidden" accept="image/*" multiple capture="environment">
<input id="conversationVideoInput" type="file" class="hidden" accept="video/*">
<input id="conversationVideoCameraInput" type="file" class="hidden" accept="video/*" capture="environment">
<input id="conversationFileInput" type="file" class="hidden" multiple accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar">
<div id="conversationAttachmentsList" class="space-y-1"></div>
</div>
</div>
<div class="flex items-center gap-3">
<button id="submitConversationBtn" class="px-4 py-2 rounded bg-secondary text-white font-semibold">Submit Conversation</button>
<div id="conversationSubmitStatus" class="text-sm text-gray-600"></div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/pocketbase/dist/pocketbase.umd.js"></script>
<script src="tools/user-email-lookup/index.js"></script>
<script>
const APP_VERSION = '1.0.0-alpha3';
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';
const ATTACHMENTS_COLLECTION = 'Attachments';
const ATTACHMENTS_FILE_FIELD = 'file';
const ATTACHMENTS_PBID_FIELD = 'pb_id';
const NOTE_BODY_HTML_FIELD = 'body_html';
const NOTE_BODY_PLAIN_FIELD = 'body_plain';
const NOTE_USERNAME_FIELD = 'Username';
let mediaRecorder = null;
let recordedBlob = null;
let titleRecognition = null;
let noteRecognition = null;
let isTitleRecognizing = false;
let isNoteRecognizing = false;
let silenceTimer = null;
let searchSilenceTimer = null;
let pendingNoteAttachments = [];
let pendingConversationAttachments = [];
// --- Voice Recognition Setup with auto-stop ---
function setupTitleVoiceRecognition() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
console.warn('Web Speech API not supported');
document.getElementById('titleVoiceBtn').disabled = true;
return null;
}
const recognition = new SpeechRecognition();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'en-US';
recognition.onstart = () => {
isTitleRecognizing = true;
document.getElementById('titleVoiceBtn').classList.add('text-red-600');
document.getElementById('titleVoiceBtn').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 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);
stopTitleVoiceRecognition();
};
recognition.onend = () => {
if (isTitleRecognizing) {
isTitleRecognizing = false;
document.getElementById('titleVoiceBtn').classList.remove('text-red-600');
document.getElementById('titleVoiceBtn').classList.add('text-purple-600');
}
};
return recognition;
}
function setupNoteVoiceRecognition() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
console.warn('Web Speech API not supported');
document.getElementById('noteVoiceBtn').disabled = true;
return null;
}
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 {
clearTimeout(silenceTimer);
titleRecognition.start();
} catch (e) {
console.log('Recognition error:', e);
}
}
function stopTitleVoiceRecognition() {
isTitleRecognizing = false;
clearTimeout(silenceTimer);
if (titleRecognition) {
try {
titleRecognition.stop();
} catch (e) {
console.log('Error stopping recognition:', e);
}
}
document.getElementById('titleVoiceBtn').classList.remove('text-red-600');
document.getElementById('titleVoiceBtn').classList.add('text-purple-600');
}
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 {
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 = '';
}
});
// --- Rich text helpers (TipTap-style JSON) ---
function tiptapDocFromText(text) {
const safe = (text || '').replace(/\r\n|\r|\n/g, '\n');
const lines = safe.split('\n');
const content = lines.map(line => ({
type: 'paragraph',
content: line ? [{ type: 'text', text: line }] : []
}));
return { type: 'doc', content };
}
function tiptapToPlainText(doc) {
if (!doc) return '';
try {
const parts = [];
const walk = (node) => {
if (!node) return;
if (Array.isArray(node)) {
node.forEach(walk);
return;
}
if (node.type === 'text' && node.text) {
parts.push(node.text);
return;
}
if (node.type === 'hardBreak') {
parts.push('\n');
return;
}
if (node.content) {
let beforeLen = parts.length;
walk(node.content);
// Add newline between block nodes
const blockTypes = ['paragraph', 'heading', 'blockquote', 'codeBlock'];
if (blockTypes.includes(node.type) && parts.length !== beforeLen) {
parts.push('\n');
}
}
};
walk(doc);
return parts.join('').replace(/\n{3,}/g, '\n\n').trim();
} catch (e) {
return typeof doc === 'string' ? doc : JSON.stringify(doc);
}
}
// --- HTML helpers for PocketBase editor fields ---
function plainTextToHTML(text) {
const safe = (text || '').replace(/\r\n|\r|\n/g, '\n');
const lines = safe.split('\n');
// Wrap each line in <p>, preserve blank lines
const html = lines.map(line => `<p>${escapeHtml(line)}</p>`).join('');
return html;
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function htmlToPlainText(html) {
try {
const div = document.createElement('div');
div.innerHTML = html || '';
return (div.innerText || '').trim();
} catch {
return html || '';
}
}
function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes <= 0) return '';
const units = ['B', 'KB', 'MB', 'GB'];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
const decimals = value >= 10 ? 0 : 1;
return `${value.toFixed(decimals)} ${units[unitIndex]}`;
}
function attachmentKindFromName(name) {
const ext = (name || '').split('.').pop()?.toLowerCase();
if (!ext) return 'file';
if (['mp3', 'wav', 'm4a', 'aac', 'ogg', 'webm'].includes(ext)) return 'audio';
if (['mp4', 'mov', 'mkv', 'avi', 'm4v', 'webm'].includes(ext)) return 'video';
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) return 'image';
return 'file';
}
function getAttachmentFileNames(att) {
const val = att[ATTACHMENTS_FILE_FIELD];
if (!val) return [];
return Array.isArray(val) ? val : [val];
}
function buildKindSetFromAttachments(atts) {
const kinds = new Set();
(atts || []).forEach((att) => {
getAttachmentFileNames(att).forEach((name) => kinds.add(attachmentKindFromName(name)));
});
return kinds;
}
function findFirstAttachmentByKind(atts, kind) {
for (const att of (atts || [])) {
const names = getAttachmentFileNames(att);
for (const name of names) {
if (attachmentKindFromName(name) === kind) {
return { att, name, url: pb.files.getURL(att, name) };
}
}
}
return null;
}
// Update auth UI based on login state
function updateAuthUI() {
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');
const conversationsContainer = document.getElementById('conversationsContainer');
const conversationContainer = document.getElementById('conversationContainer');
if (pb.authStore.isValid) {
loginContainer.classList.add('hidden');
userDisplay.classList.remove('hidden');
userEmail.classList.remove('hidden');
notesListContainer.classList.remove('hidden');
noteDetailContainer.classList.add('hidden');
noteContainer.classList.add('hidden');
conversationsContainer.classList.add('hidden');
conversationContainer.classList.add('hidden');
loadNotesList();
} else {
loginContainer.classList.remove('hidden');
userDisplay.classList.add('hidden');
userEmail.classList.add('hidden');
document.getElementById('loginError').classList.add('hidden');
notesListContainer.classList.add('hidden');
noteDetailContainer.classList.add('hidden');
noteContainer.classList.add('hidden');
conversationsContainer.classList.add('hidden');
conversationContainer.classList.add('hidden');
}
}
// Check for tokens and log success
async function checkTokens() {
const pbToken = pb.authStore.token;
if (!pbToken) {
console.log('❌ No PocketBase token found');
return;
}
console.log('✓ PocketBase token:', pbToken ? '(present)' : '(missing)');
try {
const healthResp = await fetch('/health');
const health = await healthResp.json();
if (health.ok) {
console.log('✓ Backend health check passed');
}
const graphBadge = document.getElementById('graphStatus');
const conversationGraphBadge = document.getElementById('conversationGraphStatus');
const graphResp = await fetch('/api/graph/status');
if (graphResp.ok) {
const gs = await graphResp.json();
const txt = `Graph: active (exp ${new Date(gs.expiresOnISO).toLocaleTimeString()})`;
const cls = 'text-xs px-2 py-1 rounded bg-green-100 text-green-700';
if (graphBadge) {
graphBadge.textContent = txt;
graphBadge.className = cls;
}
if (conversationGraphBadge) {
conversationGraphBadge.textContent = txt;
conversationGraphBadge.className = cls;
}
console.log('✓ Graph token active');
} else {
const txt = 'Graph: error';
const cls = 'text-xs px-2 py-1 rounded bg-red-100 text-red-700';
if (graphBadge) {
graphBadge.textContent = txt;
graphBadge.className = cls;
}
if (conversationGraphBadge) {
conversationGraphBadge.textContent = txt;
conversationGraphBadge.className = cls;
}
console.log('❌ Graph token status failed');
}
} catch (error) {
console.error('❌ Failed to check backend:', error.message);
const graphBadge = document.getElementById('graphStatus');
const conversationGraphBadge = document.getElementById('conversationGraphStatus');
const txt = 'Graph: error';
const cls = 'text-xs px-2 py-1 rounded bg-red-100 text-red-700';
if (graphBadge) {
graphBadge.textContent = txt;
graphBadge.className = cls;
}
if (conversationGraphBadge) {
conversationGraphBadge.textContent = txt;
conversationGraphBadge.className = cls;
}
}
}
async function ensureUserLogged() {
// If already have a valid token, just refresh to get model details
if (pb.authStore.isValid && pb.authStore.token) {
try {
const refresh = await pb.collection('Users').authRefresh();
const record = refresh?.record || pb.authStore.record || pb.authStore.model;
const meta = refresh?.meta || {};
const model = pb.authStore.model;
const displayName = record?.name || model?.name || meta?.name || record?.email || model?.email || meta?.email || 'Unknown User';
const email = record?.email || model?.email || meta?.email || '(no email)';
displayNameCache = displayName;
userEmailCache = email;
document.getElementById('userDisplayName').textContent = displayName;
document.getElementById('userEmailValue').textContent = email;
console.log('User record:', record);
console.log('User meta:', meta);
console.log('Auth store model:', model);
console.log(`User display name: ${displayName}`);
console.log(`User email: ${email}`);
console.log('PB token:', pb.authStore.token ? '(present)' : '(missing)');
updateAuthUI();
await checkTokens();
return true;
} catch (e) {
console.warn('Existing token invalid, clearing and reauth needed', e);
pb.authStore.clear();
}
}
return false;
}
// Handle login button click
document.getElementById('loginBtn').addEventListener('click', async (e) => {
e.preventDefault();
const loginBtn = document.getElementById('loginBtn');
const loginError = document.getElementById('loginError');
loginBtn.disabled = true;
loginBtn.textContent = 'Checking session...';
loginError.classList.add('hidden');
// If we already have a valid token, just use it
const hadToken = await ensureUserLogged();
if (hadToken) {
loginBtn.disabled = false;
loginBtn.textContent = 'Login with Microsoft';
return;
}
// Otherwise perform OAuth
loginBtn.textContent = 'Logging in...';
try {
const authData = await pb.collection('Users').authWithOAuth2({ provider: 'microsoft' });
console.log('✓ Logged in with Microsoft');
const record = authData?.record || pb.authStore.record || pb.authStore.model;
const meta = authData?.meta || {};
const model = pb.authStore.model;
const displayName = record?.name || model?.name || meta?.name || record?.email || model?.email || meta?.email || 'Unknown User';
const email = record?.email || model?.email || meta?.email || '(no email)';
displayNameCache = displayName;
userEmailCache = email;
document.getElementById('userDisplayName').textContent = displayName;
document.getElementById('userEmailValue').textContent = email;
console.log('User record:', record);
console.log('User meta:', meta);
console.log('Auth store model:', model);
console.log(`User display name: ${displayName}`);
console.log(`User email: ${email}`);
console.log('PB token:', pb.authStore.token ? '(present)' : '(missing)');
// Initialize user alert system for shared notes
try {
const alertedNotes = new Set();
async function verifyUserMatch(firstName) {
try {
console.log(` Checking: firstName="${firstName}", email="${email}"`);
const records = await pb.collection('Associations').getList(1, 50, {
filter: `first_name = "${firstName}"`,
});
console.log(` Found ${records.items.length} records`);
const match = records.items.find((record) => {
console.log(` Checking: ${record.first_name} ${record.last_name} (${record.email})`);
return record.email === email;
});
return !!match;
} catch (error) {
console.error('Error verifying user match:', error);
return false;
}
}
console.log('🔔 Starting user alert monitoring...');
await pb.collection('Notes').subscribe('*', async (event) => {
try {
const note = event.record;
console.log(`📝 Note event (${event.action}):`, note.title, `shared=${note.shared}`);
if (!note.shared) {
return;
}
const sharedWithNames = Array.isArray(note.shared_with)
? note.shared_with
: typeof note.shared_with === 'string'
? note.shared_with.split(',').map((n) => n.trim())
: [];
console.log(` → shared_with:`, sharedWithNames);
let userIsRecipient = false;
for (const sharedName of sharedWithNames) {
const firstName = sharedName.split(' ')[0];
if (await verifyUserMatch(firstName)) {
userIsRecipient = true;
break;
}
}
if (!userIsRecipient || alertedNotes.has(note.id)) {
return;
}
alertedNotes.add(note.id);
console.log(`🔔🔔 ALERT! Note "${note.title}" shared with you`);
await window.testAlertSound();
} catch (error) {
console.error('Error processing note event:', error);
}
});
console.log('✓ User alert monitoring started');
} catch (error) {
console.error('Failed to initialize user alert system:', error);
}
updateAuthUI();
await checkTokens();
} catch (error) {
console.error(error);
loginError.textContent = `Login failed: ${error.message}`;
loginError.classList.remove('hidden');
} finally {
loginBtn.disabled = false;
loginBtn.textContent = 'Login with Microsoft';
}
});
// Initial auth UI update
updateAuthUI();
// If already logged in, use existing token
if (pb.authStore.isValid && pb.authStore.token) {
ensureUserLogged();
}
// --- Audio recording setup with segmented silence handling ---
let audioContext = null;
let analyser = null;
let recordingStream = null;
let silenceCheckInterval = null;
let lastSoundAt = 0;
let stoppingSegment = false;
const SILENCE_THRESHOLD = 0.01; // Volume threshold for silence
const SEGMENT_SILENCE_MS = 2000; // pause a segment after 2s of silence
const SESSION_SILENCE_MS = 3500; // end entire session after 3.5s of silence
let recordingSessionActive = false;
let segmentActive = false;
let currentSegmentChunks = [];
let recordedSegments = [];
recordedBlob = null; // reuse existing variable for merged preview
let hasHeardSpeech = false;
let sessionStartAt = 0;
function computeRms() {
if (!analyser) return 0;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
analyser.getByteTimeDomainData(dataArray);
let sum = 0;
for (let i = 0; i < bufferLength; i++) {
const normalized = (dataArray[i] - 128) / 128;
sum += normalized * normalized;
}
return Math.sqrt(sum / bufferLength);
}
async function ensureStream() {
if (recordingStream) return recordingStream;
try {
recordingStream = 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;
}
audioContext = new (window.AudioContext || window.webkitAudioContext)();
analyser = audioContext.createAnalyser();
const source = audioContext.createMediaStreamSource(recordingStream);
source.connect(analyser);
analyser.fftSize = 2048;
return recordingStream;
}
function startSilenceMonitor() {
if (silenceCheckInterval) return;
silenceCheckInterval = setInterval(checkSilence, 120);
}
function stopSilenceMonitor() {
if (silenceCheckInterval) {
clearInterval(silenceCheckInterval);
silenceCheckInterval = null;
}
}
function cleanupStream() {
stopSilenceMonitor();
if (audioContext) {
audioContext.close();
audioContext = null;
}
mediaRecorder = null;
if (recordingStream) {
recordingStream.getTracks().forEach((t) => t.stop());
recordingStream = null;
}
analyser = null;
}
function updateCombinedPreview() {
const audioEl = document.getElementById('audioPreview');
if (!recordedSegments.length) {
recordedBlob = null;
audioEl.src = '';
audioEl.classList.add('hidden');
document.getElementById('audioPlayer').classList.add('hidden');
document.getElementById('recordStatus').textContent = '';
return;
}
const type = recordedSegments[0]?.blob?.type || 'audio/webm';
recordedBlob = new Blob(recordedSegments.map((s) => s.blob), { type });
audioEl.src = URL.createObjectURL(recordedBlob);
audioEl.volume = 1.0;
audioEl.classList.remove('hidden');
document.getElementById('audioPlayer').classList.remove('hidden');
document.getElementById('recordStatus').textContent = `Segments recorded: ${recordedSegments.length}`;
}
function startSegment() {
if (!recordingStream) return;
currentSegmentChunks = [];
mediaRecorder = new MediaRecorder(recordingStream);
const recorderRef = mediaRecorder;
segmentActive = true;
stoppingSegment = false;
lastSoundAt = Date.now();
mediaRecorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) currentSegmentChunks.push(e.data);
};
mediaRecorder.onstop = () => {
if (currentSegmentChunks.length) {
const blob = new Blob(currentSegmentChunks, { type: recorderRef.mimeType });
recordedSegments.push({ blob, name: `segment-${recordedSegments.length + 1}.webm` });
updateCombinedPreview();
}
segmentActive = false;
if (mediaRecorder === recorderRef) {
mediaRecorder = null;
}
stoppingSegment = false;
};
mediaRecorder.start();
document.getElementById('recordStatus').textContent = `Recording segment ${recordedSegments.length + 1}`;
}
function stopSegment() {
return new Promise((resolve) => {
if (stoppingSegment) {
resolve();
return;
}
if (mediaRecorder && mediaRecorder.state === 'recording') {
stoppingSegment = true;
const onStop = () => {
mediaRecorder.removeEventListener('stop', onStop);
resolve();
};
mediaRecorder.addEventListener('stop', onStop);
mediaRecorder.stop();
} else {
segmentActive = false;
stoppingSegment = false;
resolve();
}
});
}
async function startRecording() {
const recordBtn = document.getElementById('recordBtn');
const stopBtn = document.getElementById('stopBtn');
try {
if (recordingSessionActive) return;
await ensureStream();
recordedSegments = [];
recordedBlob = null;
segmentActive = false;
hasHeardSpeech = false;
sessionStartAt = Date.now();
recordingSessionActive = true;
lastSoundAt = sessionStartAt;
startSegment();
startSilenceMonitor();
recordBtn.disabled = true;
stopBtn.disabled = false;
stopBtn.classList.remove('hidden');
} 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');
}
}
async function endRecordingSession(reason) {
recordingSessionActive = false;
stopSilenceMonitor();
await stopSegment();
cleanupStream();
const recordBtn = document.getElementById('recordBtn');
const stopBtn = document.getElementById('stopBtn');
recordBtn.disabled = false;
stopBtn.disabled = true;
stopBtn.classList.add('hidden');
if (reason) {
document.getElementById('recordStatus').textContent = reason;
}
hasHeardSpeech = false;
}
async function stopRecording() {
await endRecordingSession('Recording stopped.');
}
function checkSilence() {
if (!recordingSessionActive || !analyser) return;
const now = Date.now();
const rms = computeRms();
if (rms >= SILENCE_THRESHOLD) {
hasHeardSpeech = true;
lastSoundAt = now;
if (!segmentActive) {
startSegment();
}
return;
}
// If we have not yet heard speech, allow up to SESSION_SILENCE_MS before ending
if (!hasHeardSpeech) {
if (now - sessionStartAt >= SESSION_SILENCE_MS) {
endRecordingSession('Stopped after silence.');
}
return;
}
if (segmentActive && !stoppingSegment && now - lastSoundAt >= SEGMENT_SILENCE_MS) {
stopSegment();
}
if (!segmentActive && now - lastSoundAt >= SESSION_SILENCE_MS) {
endRecordingSession('Stopped after silence.');
}
}
async function clearAudio() {
await endRecordingSession('Audio cleared.');
recordedSegments = [];
recordedBlob = null;
const audioEl = document.getElementById('audioPreview');
audioEl.src = '';
audioEl.classList.add('hidden');
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('recordStatus').textContent = '';
}
document.getElementById('recordBtn').addEventListener('click', startRecording);
document.getElementById('stopBtn').addEventListener('click', () => {
stopRecording();
});
// 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');
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', async (e) => {
e.preventDefault();
audioMenu.classList.add('hidden');
await 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 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 ''; }
}
function showNotesList() {
document.getElementById('notesListContainer').classList.remove('hidden');
document.getElementById('noteDetailContainer').classList.add('hidden');
document.getElementById('noteContainer').classList.add('hidden');
document.getElementById('conversationsContainer').classList.add('hidden');
document.getElementById('conversationContainer').classList.add('hidden');
}
function showNoteForm() {
document.getElementById('notesListContainer').classList.add('hidden');
document.getElementById('noteDetailContainer').classList.add('hidden');
document.getElementById('noteContainer').classList.remove('hidden');
document.getElementById('conversationsContainer').classList.add('hidden');
document.getElementById('conversationContainer').classList.add('hidden');
// Initialize email lookup for sharing
if (window.setupUserEmailLookup) {
window.setupUserEmailLookup(pb, '#selectedUsersContainer');
}
}
function showConversations() {
document.getElementById('notesListContainer').classList.add('hidden');
document.getElementById('noteDetailContainer').classList.add('hidden');
document.getElementById('noteContainer').classList.add('hidden');
document.getElementById('conversationsContainer').classList.remove('hidden');
document.getElementById('conversationContainer').classList.add('hidden');
}
function showConversationForm() {
document.getElementById('notesListContainer').classList.add('hidden');
document.getElementById('noteDetailContainer').classList.add('hidden');
document.getElementById('noteContainer').classList.add('hidden');
document.getElementById('conversationsContainer').classList.add('hidden');
document.getElementById('conversationContainer').classList.remove('hidden');
// Initialize email lookup for sharing
if (window.setupUserEmailLookup) {
window.setupUserEmailLookup(pb, '#selectedUsersContainer');
}
}
function renderNoteAttachmentsList() {
const listEl = document.getElementById('noteAttachmentsList');
const summaryEl = document.getElementById('noteAttachmentsSummary');
listEl.innerHTML = '';
if (!pendingNoteAttachments.length) {
summaryEl.textContent = 'No attachments selected.';
return;
}
summaryEl.textContent = `${pendingNoteAttachments.length} file(s) selected`;
const frag = document.createDocumentFragment();
pendingNoteAttachments.forEach((file) => {
const row = document.createElement('div');
row.className = 'flex items-center gap-2 text-sm text-gray-800';
const kind = attachmentKindFromName(file.name);
const badge = document.createElement('span');
badge.className = 'px-2 py-0.5 rounded-full text-xs border border-gray-200 text-gray-700 bg-gray-50';
badge.textContent = kind;
const label = document.createElement('span');
const size = formatBytes(file.size);
label.textContent = size ? `${file.name} (${size})` : file.name;
row.appendChild(badge);
row.appendChild(label);
frag.appendChild(row);
});
listEl.appendChild(frag);
}
function renderConversationAttachmentsList() {
const listEl = document.getElementById('conversationAttachmentsList');
const summaryEl = document.getElementById('conversationAttachmentsSummary');
listEl.innerHTML = '';
if (!pendingConversationAttachments.length) {
summaryEl.textContent = 'No attachments selected.';
return;
}
summaryEl.textContent = `${pendingConversationAttachments.length} file(s) selected`;
const frag = document.createDocumentFragment();
pendingConversationAttachments.forEach((file) => {
const row = document.createElement('div');
row.className = 'flex items-center gap-2 text-sm text-gray-800';
const kind = attachmentKindFromName(file.name);
const badge = document.createElement('span');
badge.className = 'px-2 py-0.5 rounded-full text-xs border border-gray-200 text-gray-700 bg-gray-50';
badge.textContent = kind;
const label = document.createElement('span');
const size = formatBytes(file.size);
label.textContent = size ? `${file.name} (${size})` : file.name;
row.appendChild(badge);
row.appendChild(label);
frag.appendChild(row);
});
listEl.appendChild(frag);
}
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');
document.getElementById('shareNoteInput').value = '';
selectedUsers = [];
renderSelectedUsers();
clearAudio();
pendingNoteAttachments = [];
const noteAttachmentsInput = document.getElementById('noteAttachmentsInput');
if (noteAttachmentsInput) noteAttachmentsInput.value = '';
renderNoteAttachmentsList();
}
function resetConversationForm() {
document.getElementById('conversationTitle').value = '';
document.getElementById('conversationText').value = '';
document.getElementById('conversationIsJobConversation').value = 'no';
document.getElementById('conversationType').value = 'personal';
document.getElementById('conversationJobNumber').value = '';
document.getElementById('conversationJobNumberContainer').classList.add('hidden');
document.getElementById('conversationAudioPreview').src = '';
document.getElementById('conversationAudioPlayer').classList.add('hidden');
pendingConversationAttachments = [];
document.getElementById('conversationImageInput').value = '';
document.getElementById('conversationVideoInput').value = '';
document.getElementById('conversationFileInput').value = '';
renderConversationAttachmentsList();
}
function renderNotesList(items) {
// Color palette for shared with person names (different from green)
const personColorPalette = [
{ bg: 'bg-blue-200', border: 'border-blue-400', text: 'text-blue-900' },
{ bg: 'bg-purple-200', border: 'border-purple-400', text: 'text-purple-900' },
{ bg: 'bg-indigo-200', border: 'border-indigo-400', text: 'text-indigo-900' },
{ bg: 'bg-cyan-200', border: 'border-cyan-400', text: 'text-cyan-900' },
{ bg: 'bg-teal-200', border: 'border-teal-400', text: 'text-teal-900' },
{ bg: 'bg-sky-200', border: 'border-sky-400', text: 'text-sky-900' },
{ bg: 'bg-lime-200', border: 'border-lime-400', text: 'text-lime-900' },
{ bg: 'bg-rose-200', border: 'border-rose-400', text: 'text-rose-900' },
];
// Map to store consistent colors for each person
const personColorMap = {};
function getPersonColor(name) {
if (!personColorMap[name]) {
// Generate consistent hash-based color index for this person
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = ((hash << 5) - hash) + name.charCodeAt(i);
hash = hash & hash; // Convert to 32-bit integer
}
const colorIndex = Math.abs(hash) % personColorPalette.length;
personColorMap[name] = personColorPalette[colorIndex];
}
return personColorMap[name];
}
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();
// Use green background for shared notes, otherwise use type-based colors
let typeClasses;
if (note.shared) {
typeClasses = 'bg-green-100 border border-green-300 hover:border-green-400';
} else {
typeClasses = {
personal: 'bg-amber-100 border border-amber-300 hover:border-amber-400',
manager: 'bg-orange-200 border border-orange-400 hover:border-orange-500',
job: 'bg-pink-200 border border-pink-400 hover:border-pink-500',
}[type] || 'bg-gray-100 border border-gray-300 hover:border-primary/60';
}
card.className = `text-left ${typeClasses} hover:shadow-md transition rounded-xl px-2 py-0.5 space-y-0 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 atts = note._attachments || [];
const typeSet = buildKindSetFromAttachments(atts);
function iconBtnHtml(kind) {
const map = {
image: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>',
video: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>',
audio: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 3.89a9 9 0 0 1 0 12.22M18.5 8.5a5 5 0 0 1 0 7"/></svg>',
file: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>',
};
return `<button type="button" class="p-0.5 sm:p-1 rounded hover:bg-gray-200 text-gray-700 transition flex-shrink-0" data-attachment-button data-attachment-kind="${kind}" title="${kind}" style="width:24px;height:24px;min-width:24px;display:flex;align-items:center;justify-content:center;">${map[kind] || ''}</button>`;
}
const attachmentIcons = ['image', 'video', 'file', 'audio']
.filter((k) => typeSet.has(k))
.map(iconBtnHtml)
.join('');
// Create type bubble and shared with capsules for shared notes only
let typeBubble = '';
let sharedWithDisplay = '';
if (note.shared) {
const typeColorMap = {
personal: { bg: 'bg-amber-300', border: 'border-amber-400' },
manager: { bg: 'bg-orange-400', border: 'border-orange-500' },
job: { bg: 'bg-pink-400', border: 'border-pink-500' },
};
const typeColor = typeColorMap[type] || { bg: 'bg-gray-300', border: 'border-gray-400' };
typeBubble = `<span class="inline-block w-3 h-3 rounded-full ${typeColor.bg} ${typeColor.border} border flex-shrink-0" title="${escapeHtml(type)}"></span>`;
if (note.shared_with && note.shared_with.length > 0) {
const sharedNames = Array.isArray(note.shared_with) ? note.shared_with : (typeof note.shared_with === 'string' ? note.shared_with.split(',').map(n => n.trim()) : []);
const capsules = sharedNames.map(name => {
const firstName = name.split(' ')[0];
const colors = getPersonColor(name);
return `<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold ${colors.bg} ${colors.border} border ${colors.text}">${escapeHtml(firstName)}</span>`;
}).join('');
sharedWithDisplay = `<div class="flex items-center flex-wrap gap-1 justify-end">${capsules}</div>`;
}
}
card.innerHTML = `
<div class="flex items-start justify-between gap-2">
<div class="font-semibold text-gray-900 truncate">${escapeHtml(title)}</div>
<div class="text-xs text-gray-500 flex-shrink-0">${formatDateShort(note.created)}</div>
</div>
<div class="flex items-start gap-2 flex-1">
<div class="text-sm text-gray-700 line-clamp-1 flex-1">${escapeHtml(snippet || '(empty)')}</div>
${note.shared ? `<div class="flex items-center gap-1 flex-shrink-0">${typeBubble}</div>` : ''}
</div>
<div class="flex items-center justify-between text-xs text-gray-600 w-full">
<div class="flex items-center gap-1">
${attachmentIcons}
</div>
<div class="flex items-center gap-1">
${note.shared ? sharedWithDisplay : ''}
</div>
</div>
`;
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] = [];
}
}));
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) {
console.error('Failed to load notes:', e);
status.textContent = `Error loading notes: ${e.message}`;
}
}
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('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');
const attachmentsListEl = document.getElementById('noteDetailAttachments');
const attachmentsStatusEl = document.getElementById('noteDetailAttachmentsStatus');
audioEl.classList.add('hidden');
audioEl.src = '';
audioStatus.textContent = 'Loading audio...';
attachmentsListEl.innerHTML = '';
attachmentsStatusEl.textContent = 'Loading attachments...';
try {
const atts = await pb.collection(ATTACHMENTS_COLLECTION).getFullList({
sort: '-created',
filter: `${ATTACHMENTS_PBID_FIELD} = "${note.id}"`,
batch: 200,
});
const audioMatch = findFirstAttachmentByKind(atts, 'audio');
if (audioMatch) {
audioEl.src = audioMatch.url;
audioEl.classList.remove('hidden');
audioStatus.textContent = '';
} else {
audioStatus.textContent = 'No audio attached.';
}
if (!atts.length) {
attachmentsStatusEl.textContent = 'No attachments uploaded.';
} else {
attachmentsStatusEl.textContent = '';
const frag = document.createDocumentFragment();
atts.forEach((att) => {
const names = getAttachmentFileNames(att);
names.forEach((name) => {
const url = pb.files.getURL(att, name);
const kind = attachmentKindFromName(name);
const row = document.createElement('div');
row.className = 'flex items-center gap-2';
const badge = document.createElement('span');
badge.className = 'px-2 py-0.5 rounded-full text-xs border border-gray-200 text-gray-700 bg-gray-50';
badge.textContent = kind;
const link = document.createElement('a');
link.href = url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.className = 'text-purple-700 hover:text-purple-900 break-all';
link.textContent = name;
row.appendChild(badge);
row.appendChild(link);
frag.appendChild(row);
});
});
attachmentsListEl.appendChild(frag);
}
} catch (e) {
audioStatus.textContent = `Audio load failed: ${e.message}`;
attachmentsStatusEl.textContent = `Attachments load failed: ${e.message}`;
}
}
function showAttachmentPreview(note, kind) {
const panel = document.getElementById('notesAttachmentPreviewPanel');
const titleEl = document.getElementById('notesAttachmentPreviewTitle');
const contentEl = document.getElementById('notesAttachmentPreviewContent');
contentEl.innerHTML = '';
const match = findFirstAttachmentByKind(note._attachments || [], kind);
if (!match) {
titleEl.textContent = 'Attachment Preview';
contentEl.textContent = 'No matching attachment.';
panel.classList.remove('hidden');
return;
}
const { att, name: fileName, url } = match;
titleEl.textContent = `Preview: ${kind}`;
let node = null;
if (kind === 'image') {
node = document.createElement('img');
node.src = url;
node.className = 'max-h-80 rounded border border-gray-200';
node.alt = fileName || 'image';
} else if (kind === 'video') {
node = document.createElement('video');
node.src = url;
node.controls = true;
node.className = 'w-full max-h-80 rounded border border-gray-200 bg-black';
} else if (kind === 'audio') {
node = document.createElement('audio');
node.src = url;
node.controls = true;
node.className = 'w-full';
} else {
// Try inline preview for PDFs, otherwise provide a link
const isPdf = (fileName || '').toLowerCase().endsWith('.pdf');
if (isPdf) {
node = document.createElement('embed');
node.src = url;
node.type = 'application/pdf';
node.className = 'w-full h-80 rounded border border-gray-200';
} else {
const link = document.createElement('a');
link.href = url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.className = 'text-purple-700 hover:text-purple-900 underline';
link.textContent = 'Open attachment';
node = link;
}
}
contentEl.appendChild(node);
panel.classList.remove('hidden');
}
document.getElementById('notesAttachmentPreviewClose').addEventListener('click', () => {
document.getElementById('notesAttachmentPreviewPanel').classList.add('hidden');
});
document.getElementById('notesGrid').addEventListener('click', (e) => {
const btn = e.target.closest('[data-attachment-button]');
if (btn) {
e.preventDefault();
e.stopPropagation();
const card = btn.closest('[data-note-id]');
if (!card) return;
const kind = btn.getAttribute('data-attachment-kind');
const note = notesCache[card.dataset.noteId];
if (note && kind) {
showAttachmentPreview(note, kind);
return;
}
}
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('conversationsBtn').addEventListener('click', () => {
showConversations();
});
document.getElementById('backToListFromConversationsBtn').addEventListener('click', () => {
showNotesList();
});
document.getElementById('newConversationBtn').addEventListener('click', () => {
resetConversationForm();
showConversationForm();
});
document.getElementById('backToConversationsListBtn').addEventListener('click', () => {
showConversations();
});
document.getElementById('backToListBtn').addEventListener('click', () => {
showNotesList();
});
document.getElementById('detailBackBtn').addEventListener('click', () => {
showNotesList();
});
document.getElementById('detailNewNoteBtn').addEventListener('click', () => {
resetNoteForm();
showNoteForm();
});
// Attachment menu handling
const attachmentMenuBtn = document.getElementById('attachmentMenuBtn');
const attachmentMenu = document.getElementById('attachmentMenu');
const imageInput = document.getElementById('imageInput');
const imageCameraInput = document.getElementById('imageCameraInput');
const videoInput = document.getElementById('videoInput');
const videoCameraInput = document.getElementById('videoCameraInput');
const fileInput = document.getElementById('fileInput');
// Detect mobile vs desktop and show/hide appropriate buttons
const isMobileDevice = () => /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
const setupDeviceSpecificUI = () => {
if (isMobileDevice()) {
document.querySelectorAll('.desktop-only').forEach(el => el.classList.add('hidden'));
document.querySelectorAll('.mobile-only').forEach(el => el.classList.remove('hidden'));
} else {
document.querySelectorAll('.mobile-only').forEach(el => el.classList.add('hidden'));
document.querySelectorAll('.desktop-only').forEach(el => el.classList.remove('hidden'));
}
};
setupDeviceSpecificUI();
attachmentMenuBtn.addEventListener('click', (e) => {
e.preventDefault();
attachmentMenu.classList.toggle('hidden');
});
const imageSubmenu = document.getElementById('imageSubmenu');
const videoSubmenu = document.getElementById('videoSubmenu');
document.getElementById('attachImageBtn').addEventListener('click', () => {
imageSubmenu.classList.toggle('hidden');
videoSubmenu.classList.add('hidden');
});
document.getElementById('takePhotoBtn').addEventListener('click', () => {
attachmentMenu.classList.add('hidden');
imageSubmenu.classList.add('hidden');
imageCameraInput.click();
});
document.getElementById('selectImageBtn').addEventListener('click', () => {
attachmentMenu.classList.add('hidden');
imageSubmenu.classList.add('hidden');
imageInput.click();
});
document.getElementById('attachVideoBtn').addEventListener('click', () => {
videoSubmenu.classList.toggle('hidden');
imageSubmenu.classList.add('hidden');
});
document.getElementById('recordVideoBtn').addEventListener('click', () => {
attachmentMenu.classList.add('hidden');
videoSubmenu.classList.add('hidden');
videoCameraInput.click();
});
document.getElementById('selectVideoBtn').addEventListener('click', () => {
attachmentMenu.classList.add('hidden');
videoSubmenu.classList.add('hidden');
videoInput.click();
});
document.getElementById('attachFileBtn').addEventListener('click', () => {
attachmentMenu.classList.add('hidden');
imageSubmenu.classList.add('hidden');
videoSubmenu.classList.add('hidden');
fileInput.click();
});
// File input change handlers
[imageInput, imageCameraInput, videoInput, videoCameraInput, fileInput].forEach(input => {
input.addEventListener('change', (e) => {
pendingNoteAttachments.push(...Array.from(e.target.files || []));
renderNoteAttachmentsList();
});
});
document.getElementById('clearAttachmentsBtn').addEventListener('click', (e) => {
e.preventDefault();
pendingNoteAttachments = [];
imageInput.value = '';
imageCameraInput.value = '';
videoInput.value = '';
videoCameraInput.value = '';
fileInput.value = '';
renderNoteAttachmentsList();
});
// Snip handler (partial screenshot)
document.getElementById('takeSnipBtn')?.addEventListener('click', async () => {
try {
attachmentMenu.classList.add('hidden');
imageSubmenu.classList.add('hidden');
const canvas = await window.captureSnip();
if (canvas) {
const file = await window.canvasToFile(canvas);
pendingNoteAttachments.push(file);
renderNoteAttachmentsList();
}
} catch (error) {
console.error('Snip failed:', error);
}
});
// Screen recording handler
document.getElementById('recordScreenBtn')?.addEventListener('click', async () => {
try {
attachmentMenu.classList.add('hidden');
videoSubmenu.classList.add('hidden');
const recorder = await window.recordScreen();
if (recorder) {
// Create stop button overlay
const stopOverlay = document.createElement('div');
stopOverlay.style.cssText = `position:fixed;top:20px;right:20px;z-index:50000;background:white;padding:15px 20px;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.15);`;
const stopBtn = document.createElement('button');
stopBtn.textContent = '⏹ Stop Recording';
stopBtn.style.cssText = `padding:10px 20px;background:#EF4444;color:white;border:none;border-radius:6px;font-size:14px;font-weight:bold;cursor:pointer;`;
stopBtn.addEventListener('click', async () => {
const file = await recorder.stop();
if (file) {
pendingNoteAttachments.push(file);
renderNoteAttachmentsList();
}
stopOverlay.remove();
});
stopOverlay.appendChild(stopBtn);
document.body.appendChild(stopOverlay);
// Auto-stop after 5 minutes as fallback
setTimeout(async () => {
if (recorder.isRecording()) {
const file = await recorder.stop();
if (file) {
pendingNoteAttachments.push(file);
renderNoteAttachmentsList();
}
stopOverlay.remove();
}
}, 5 * 60 * 1000);
}
} catch (error) {
console.error('Screen recording failed:', error);
}
});
// Close menu on outside click
document.addEventListener('click', (e) => {
if (!attachmentMenuBtn.contains(e.target) && !attachmentMenu.contains(e.target)) {
attachmentMenu.classList.add('hidden');
imageSubmenu.classList.add('hidden');
videoSubmenu.classList.add('hidden');
}
});
// Conversation attachment menu setup
const conversationAttachmentMenuBtn = document.getElementById('conversationAttachmentMenuBtn');
const conversationAttachmentMenu = document.getElementById('conversationAttachmentMenu');
const conversationImageInput = document.getElementById('conversationImageInput');
const conversationImageCameraInput = document.getElementById('conversationImageCameraInput');
const conversationVideoInput = document.getElementById('conversationVideoInput');
const conversationVideoCameraInput = document.getElementById('conversationVideoCameraInput');
const conversationFileInput = document.getElementById('conversationFileInput');
conversationAttachmentMenuBtn.addEventListener('click', (e) => {
e.preventDefault();
conversationAttachmentMenu.classList.toggle('hidden');
});
const conversationImageSubmenu = document.getElementById('conversationImageSubmenu');
const conversationVideoSubmenu = document.getElementById('conversationVideoSubmenu');
document.getElementById('conversationAttachImageBtn').addEventListener('click', () => {
conversationImageSubmenu.classList.toggle('hidden');
conversationVideoSubmenu.classList.add('hidden');
});
document.getElementById('conversationTakePhotoBtn').addEventListener('click', () => {
conversationAttachmentMenu.classList.add('hidden');
conversationImageSubmenu.classList.add('hidden');
conversationImageCameraInput.click();
});
document.getElementById('conversationSelectImageBtn').addEventListener('click', () => {
conversationAttachmentMenu.classList.add('hidden');
conversationImageSubmenu.classList.add('hidden');
conversationImageInput.click();
});
document.getElementById('conversationAttachVideoBtn').addEventListener('click', () => {
conversationVideoSubmenu.classList.toggle('hidden');
conversationImageSubmenu.classList.add('hidden');
});
document.getElementById('conversationRecordVideoBtn').addEventListener('click', () => {
conversationAttachmentMenu.classList.add('hidden');
conversationVideoSubmenu.classList.add('hidden');
conversationVideoCameraInput.click();
});
document.getElementById('conversationSelectVideoBtn').addEventListener('click', () => {
conversationAttachmentMenu.classList.add('hidden');
conversationVideoSubmenu.classList.add('hidden');
conversationVideoInput.click();
});
document.getElementById('conversationAttachFileBtn').addEventListener('click', () => {
conversationAttachmentMenu.classList.add('hidden');
conversationImageSubmenu.classList.add('hidden');
conversationVideoSubmenu.classList.add('hidden');
conversationFileInput.click();
});
// File input change handlers for conversation
[conversationImageInput, conversationImageCameraInput, conversationVideoInput, conversationVideoCameraInput, conversationFileInput].forEach(input => {
input.addEventListener('change', (e) => {
pendingConversationAttachments.push(...Array.from(e.target.files || []));
renderConversationAttachmentsList();
});
});
document.getElementById('conversationClearAttachmentsBtn').addEventListener('click', (e) => {
e.preventDefault();
pendingConversationAttachments = [];
conversationImageInput.value = '';
conversationImageCameraInput.value = '';
conversationVideoInput.value = '';
conversationVideoCameraInput.value = '';
conversationFileInput.value = '';
renderConversationAttachmentsList();
});
// Snip handler for conversation (partial screenshot)
document.getElementById('conversationTakeSnipBtn')?.addEventListener('click', async () => {
try {
conversationAttachmentMenu.classList.add('hidden');
conversationImageSubmenu.classList.add('hidden');
const canvas = await window.captureSnip();
if (canvas) {
const file = await window.canvasToFile(canvas);
pendingConversationAttachments.push(file);
renderConversationAttachmentsList();
}
} catch (error) {
console.error('Snip failed:', error);
}
});
// Screen recording handler for conversation
document.getElementById('conversationRecordScreenBtn')?.addEventListener('click', async () => {
try {
conversationAttachmentMenu.classList.add('hidden');
conversationVideoSubmenu.classList.add('hidden');
const recorder = await window.recordScreen();
if (recorder) {
// Create stop button overlay
const stopOverlay = document.createElement('div');
stopOverlay.style.cssText = `position:fixed;top:20px;right:20px;z-index:50000;background:white;padding:15px 20px;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.15);`;
const stopBtn = document.createElement('button');
stopBtn.textContent = '⏹ Stop Recording';
stopBtn.style.cssText = `padding:10px 20px;background:#EF4444;color:white;border:none;border-radius:6px;font-size:14px;font-weight:bold;cursor:pointer;`;
stopBtn.addEventListener('click', async () => {
const file = await recorder.stop();
if (file) {
pendingConversationAttachments.push(file);
renderConversationAttachmentsList();
}
stopOverlay.remove();
});
stopOverlay.appendChild(stopBtn);
document.body.appendChild(stopOverlay);
// Auto-stop after 5 minutes as fallback
setTimeout(async () => {
if (recorder.isRecording()) {
const file = await recorder.stop();
if (file) {
pendingConversationAttachments.push(file);
renderConversationAttachmentsList();
}
stopOverlay.remove();
}
}, 5 * 60 * 1000);
}
} catch (error) {
console.error('Screen recording failed:', error);
}
});
// Close conversation menu on outside click
document.addEventListener('click', (e) => {
if (!conversationAttachmentMenuBtn.contains(e.target) && !conversationAttachmentMenu.contains(e.target)) {
conversationAttachmentMenu.classList.add('hidden');
conversationImageSubmenu.classList.add('hidden');
conversationVideoSubmenu.classList.add('hidden');
}
});
// 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();
}
});
// --- Share Note Logic ---
let selectedUsers = []; // { name }
const shareNoteInput = document.getElementById('shareNoteInput');
const selectedUsersContainer = document.getElementById('selectedUsersContainer');
shareNoteInput.addEventListener('input', (e) => {
const input = e.target.value;
const names = input.split(',')
.map(name => name.trim())
.filter(name => name.length > 0);
selectedUsers = names.map(name => ({
name: name,
firstName: name.split(' ')[0]
}));
renderSelectedUsers();
});
function renderSelectedUsers() {
selectedUsersContainer.innerHTML = '';
selectedUsers.forEach((user, index) => {
const capsule = document.createElement('div');
capsule.className = 'inline-flex items-center gap-2 px-3 py-1 rounded-full bg-green-100 border border-green-300 text-sm text-gray-800';
capsule.innerHTML = `
<span>${escapeHtml(user.firstName)}</span>
<button type="button" class="text-gray-600 hover:text-gray-800 font-bold" data-remove-user="${index}">×</button>
`;
const removeBtn = capsule.querySelector('[data-remove-user]');
removeBtn.addEventListener('click', (e) => {
e.preventDefault();
selectedUsers = selectedUsers.filter((_, i) => i !== index);
const remaining = selectedUsers.map(u => u.name).join(', ');
shareNoteInput.value = remaining;
renderSelectedUsers();
});
selectedUsersContainer.appendChild(capsule);
});
}
// --- Submit note ---
async function submitNote() {
const statusEl = document.getElementById('submitStatus');
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;
}
if (!noteText) {
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) {
const words = noteText.split(/\s+/).filter(w => w.length > 0);
const firstFour = words.slice(0, 4).join(' ');
noteTitle = 'Untitled: ' + (firstFour || 'note');
// Add ellipsis if note has more than 4 words
if (words.length > 4) {
noteTitle += '...';
}
}
// Truncate title if it exceeds 120 characters
if (noteTitle.length > 120) {
noteTitle = noteTitle.substring(0, 120) + '...';
}
try {
// Create note record with display name, email, and title
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,
shared: selectedUsers.length > 0 ? true : false,
shared_with: selectedUsers.map(u => u.name),
};
// Only include job_number if it has a value
if (jobNumber) {
payload.Job_Number = jobNumber;
}
const noteRecord = await pb.collection(NOTES_COLLECTION).create(payload);
const attachmentRecords = [];
let audioAttachmentCount = 0;
let fileAttachmentCount = 0;
if (recordedSegments && recordedSegments.length) {
const fd = new FormData();
for (let i = 0; i < recordedSegments.length; i++) {
const seg = recordedSegments[i];
const blobToUpload = seg.blob || seg;
const fileName = seg.name || `note-audio-seg-${i + 1}.webm`;
const file = new File([blobToUpload], fileName, { type: blobToUpload.type || 'audio/webm' });
fd.append(ATTACHMENTS_FILE_FIELD, file);
audioAttachmentCount += 1;
}
fd.append(ATTACHMENTS_PBID_FIELD, noteRecord.id);
const createdAtt = await pb.collection(ATTACHMENTS_COLLECTION).create(fd);
attachmentRecords.push(createdAtt);
}
const filesToUpload = (pendingNoteAttachments && pendingNoteAttachments.length) ? pendingNoteAttachments : [];
if (filesToUpload && filesToUpload.length) {
const fdFiles = new FormData();
for (let i = 0; i < filesToUpload.length; i++) {
const srcFile = filesToUpload[i];
const file = new File([srcFile], srcFile.name, { type: srcFile.type || 'application/octet-stream' });
fdFiles.append(ATTACHMENTS_FILE_FIELD, file);
fileAttachmentCount += 1;
}
fdFiles.append(ATTACHMENTS_PBID_FIELD, noteRecord.id);
try {
const createdAttFiles = await pb.collection(ATTACHMENTS_COLLECTION).create(fdFiles);
attachmentRecords.push(createdAttFiles);
} catch (uploadErr) {
console.error('Attachment batch upload failed:', uploadErr);
}
}
const attachmentParts = [];
if (audioAttachmentCount) attachmentParts.push(`${audioAttachmentCount} audio`);
if (fileAttachmentCount) attachmentParts.push(`${fileAttachmentCount} file`);
const attachmentMsg = attachmentParts.length ? ` with ${attachmentParts.join(' and ')} attachment(s)` : '';
statusEl.textContent = '';
await clearAudio();
pendingNoteAttachments = [];
document.getElementById('imageInput').value = '';
document.getElementById('imageCameraInput').value = '';
document.getElementById('videoInput').value = '';
document.getElementById('videoCameraInput').value = '';
document.getElementById('fileInput').value = '';
renderNoteAttachmentsList();
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('Error creating note:', e);
console.error('Full error:', JSON.stringify(e, null, 2));
if (e.response) {
console.error('Response data:', e.response);
}
statusEl.textContent = `Error saving: ${e.message}`;
}
}
document.getElementById('submitNoteBtn').addEventListener('click', submitNote);
</script>
</body>
</html>