Files
Prism-Notes-Main/index.html
T
aewing a19a4495f4 Add zoom in/out controls to attachment preview
- Add zoom in (+), zoom out (−), reset, and close buttons to preview panel
- Zoom buttons only show for images (other media types don't need zoom)
- Zoom level indicator shows current zoom percentage
- Support zoom levels: 100% to 400% in 25% increments
- Image zoom uses CSS transform scale for smooth performance
- Click on image to toggle between 100% and 150% zoom (quick toggle)
- Reset button appears when zoomed in, allows quick return to original
- Zoom out button only appears when zoomed in
- Preview panel has scrollable content area (max-height 600px)
- Maintains aspect ratio and smooth transitions during zoom
2026-01-11 02:32:11 +00:00

2244 lines
97 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>
</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 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">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>
<div class="flex items-center gap-2">
<button id="notesAttachmentPreviewZoomOut" class="px-2 py-1 rounded border border-gray-200 text-gray-700 hover:bg-gray-100 text-sm hidden" title="Zoom out"></button>
<span id="notesAttachmentPreviewZoomLevel" class="text-xs text-gray-600 min-w-12 text-center"></span>
<button id="notesAttachmentPreviewZoomIn" class="px-2 py-1 rounded border border-gray-200 text-gray-700 hover:bg-gray-100 text-sm hidden" title="Zoom in">+</button>
<button id="notesAttachmentPreviewReset" class="px-2 py-1 rounded border border-gray-200 text-gray-700 hover:bg-gray-100 text-sm hidden" title="Reset zoom">Reset</button>
<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>
<div id="notesAttachmentPreviewContent" class="space-y-2 overflow-auto" style="max-height: 600px;"></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 p-8 space-y-6">
<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-4">
<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="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">
<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">
<label class="block text-sm font-medium text-gray-700">Attachments</label>
<div class="relative">
<div class="flex items-center gap-2">
<button id="attachmentMenuBtn" class="px-3 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.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>
Attach
</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-full left-0 mt-2 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 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="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
</button>
<button id="attachVideoBtn" 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">
<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
</button>
<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>
<button id="recordVideoBtn" 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">
<circle cx="12" cy="13" r="9"/><path d="M12 10v6"/>
</svg>
Record Video
</button>
<button id="takePictureBtn" 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="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 Picture
</button>
</div>
<!-- Hidden file inputs -->
<input id="imageInput" type="file" class="hidden" accept="image/*" multiple>
<input id="videoInput" type="file" class="hidden" accept="video/*">
<input id="fileInput" type="file" class="hidden" multiple accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar">
<input id="cameraInput" type="file" class="hidden" accept="image/*" capture="environment">
<div id="noteAttachmentsList" class="space-y-1"></div>
</div>
</div>
<div class="flex items-center gap-3">
<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">
<label class="block text-sm font-medium text-gray-700">Attachments</label>
<div class="relative">
<div class="flex items-center gap-2">
<button id="conversationAttachmentMenuBtn" class="px-3 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.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>
Attach
</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-full left-0 mt-2 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 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="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
</button>
<button id="conversationAttachVideoBtn" 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">
<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
</button>
<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>
<button id="conversationRecordVideoBtn" 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">
<circle cx="12" cy="13" r="9"/><path d="M12 10v6"/>
</svg>
Record Video
</button>
<button id="conversationTakePictureBtn" 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="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 Picture
</button>
</div>
<!-- Hidden file inputs for conversation -->
<input id="conversationImageInput" type="file" class="hidden" accept="image/*" multiple>
<input id="conversationVideoInput" type="file" class="hidden" accept="video/*">
<input id="conversationFileInput" type="file" class="hidden" multiple accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar">
<input id="conversationCameraInput" type="file" class="hidden" accept="image/*" capture="environment">
<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>
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)');
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');
}
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');
}
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');
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 = '';
document.getElementById('conversationCameraInput').value = '';
renderConversationAttachmentsList();
}
function renderNotesList(items) {
const grid = document.getElementById('notesGrid');
const empty = document.getElementById('notesEmpty');
grid.innerHTML = '';
if (!items.length) {
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
const frag = document.createDocumentFragment();
items.forEach((note) => {
const card = document.createElement('button');
card.type = 'button';
card.dataset.noteId = note.id;
const type = (note.type || 'personal').toLowerCase();
const typeClasses = {
personal: 'bg-amber-100 border border-amber-300 hover:border-amber-400',
manager: 'bg-orange-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('');
card.innerHTML = `
<div class="flex items-center justify-between gap-2">
<div class="font-semibold text-gray-900 truncate">${escapeHtml(title)}</div>
<div class="text-xs text-gray-500">${formatDateShort(note.created)}</div>
</div>
<div class="text-sm text-gray-700 line-clamp-3 flex-1">${escapeHtml(snippet || '(empty)')}</div>
<div class="flex items-center justify-between text-xs text-gray-600">
<span>${escapeHtml(meta)}</span>
<div class="flex items-center gap-1">${attachmentIcons}</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');
const zoomInBtn = document.getElementById('notesAttachmentPreviewZoomIn');
const zoomOutBtn = document.getElementById('notesAttachmentPreviewZoomOut');
const zoomResetBtn = document.getElementById('notesAttachmentPreviewReset');
const zoomLevel = document.getElementById('notesAttachmentPreviewZoomLevel');
// Reset zoom state
let currentZoom = 100;
let currentElement = null;
function updateZoomUI() {
zoomLevel.textContent = `${currentZoom}%`;
zoomOutBtn.classList.toggle('hidden', currentZoom <= 100);
zoomResetBtn.classList.toggle('hidden', currentZoom === 100);
}
contentEl.innerHTML = '';
const match = findFirstAttachmentByKind(note._attachments || [], kind);
if (!match) {
titleEl.textContent = 'Attachment Preview';
contentEl.textContent = 'No matching attachment.';
zoomInBtn.classList.add('hidden');
zoomOutBtn.classList.add('hidden');
zoomResetBtn.classList.add('hidden');
zoomLevel.textContent = '';
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 cursor-zoom-in transition-transform origin-center';
node.alt = fileName || 'image';
node.style.transform = 'scale(1)';
currentElement = node;
// Add zoom handlers for images
zoomInBtn.classList.remove('hidden');
zoomOutBtn.classList.add('hidden');
zoomResetBtn.classList.add('hidden');
updateZoomUI();
const applyZoom = () => {
const scale = currentZoom / 100;
node.style.transform = `scale(${scale})`;
node.style.cursor = currentZoom > 100 ? 'zoom-out' : 'zoom-in';
updateZoomUI();
};
zoomInBtn.onclick = () => {
currentZoom = Math.min(currentZoom + 25, 400);
applyZoom();
};
zoomOutBtn.onclick = () => {
currentZoom = Math.max(currentZoom - 25, 100);
applyZoom();
};
zoomResetBtn.onclick = () => {
currentZoom = 100;
applyZoom();
};
node.addEventListener('click', () => {
if (currentZoom > 100) {
currentZoom = 100;
} else {
currentZoom = 150;
}
applyZoom();
});
} 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';
zoomInBtn.classList.add('hidden');
zoomOutBtn.classList.add('hidden');
zoomResetBtn.classList.add('hidden');
zoomLevel.textContent = '';
} else if (kind === 'audio') {
node = document.createElement('audio');
node.src = url;
node.controls = true;
node.className = 'w-full';
zoomInBtn.classList.add('hidden');
zoomOutBtn.classList.add('hidden');
zoomResetBtn.classList.add('hidden');
zoomLevel.textContent = '';
} 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;
}
zoomInBtn.classList.add('hidden');
zoomOutBtn.classList.add('hidden');
zoomResetBtn.classList.add('hidden');
zoomLevel.textContent = '';
}
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 videoInput = document.getElementById('videoInput');
const fileInput = document.getElementById('fileInput');
const cameraInput = document.getElementById('cameraInput');
attachmentMenuBtn.addEventListener('click', (e) => {
e.preventDefault();
attachmentMenu.classList.toggle('hidden');
});
document.getElementById('attachImageBtn').addEventListener('click', () => {
attachmentMenu.classList.add('hidden');
imageInput.click();
});
document.getElementById('attachVideoBtn').addEventListener('click', () => {
attachmentMenu.classList.add('hidden');
videoInput.click();
});
document.getElementById('attachFileBtn').addEventListener('click', () => {
attachmentMenu.classList.add('hidden');
fileInput.click();
});
document.getElementById('takePictureBtn').addEventListener('click', () => {
attachmentMenu.classList.add('hidden');
cameraInput.click();
});
document.getElementById('recordVideoBtn').addEventListener('click', async () => {
attachmentMenu.classList.add('hidden');
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
const videoRecorder = new MediaRecorder(stream);
const chunks = [];
videoRecorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
videoRecorder.onstop = () => {
const blob = new Blob(chunks, { type: 'video/webm' });
const file = new File([blob], `video-${Date.now()}.webm`, { type: 'video/webm' });
pendingNoteAttachments.push(file);
renderNoteAttachmentsList();
stream.getTracks().forEach(track => track.stop());
};
videoRecorder.start();
// Show recording UI
const recordStatus = document.getElementById('recordStatus');
const originalStatus = recordStatus.textContent;
recordStatus.textContent = 'Recording video... (Click to stop)';
recordStatus.style.cursor = 'pointer';
recordStatus.style.color = '#dc2626';
const stopHandler = () => {
recordStatus.removeEventListener('click', stopHandler);
videoRecorder.stop();
recordStatus.textContent = originalStatus;
recordStatus.style.cursor = 'auto';
recordStatus.style.color = '';
};
recordStatus.addEventListener('click', stopHandler);
// Auto-stop after 5 minutes
setTimeout(() => {
if (videoRecorder.state === 'recording') {
recordStatus.removeEventListener('click', stopHandler);
videoRecorder.stop();
recordStatus.textContent = 'Video recording stopped (5 minute limit)';
recordStatus.style.cursor = 'auto';
setTimeout(() => {
recordStatus.textContent = originalStatus;
recordStatus.style.color = '';
}, 3000);
}
}, 5 * 60 * 1000);
} catch (err) {
console.error('Failed to access camera/microphone:', err);
alert('Unable to access camera or microphone. Check permissions.');
}
});
// File input change handlers
[imageInput, videoInput, fileInput, cameraInput].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 = '';
videoInput.value = '';
fileInput.value = '';
cameraInput.value = '';
renderNoteAttachmentsList();
});
// Close menu on outside click
document.addEventListener('click', (e) => {
if (!attachmentMenuBtn.contains(e.target) && !attachmentMenu.contains(e.target)) {
attachmentMenu.classList.add('hidden');
}
});
// Conversation attachment menu setup
const conversationAttachmentMenuBtn = document.getElementById('conversationAttachmentMenuBtn');
const conversationAttachmentMenu = document.getElementById('conversationAttachmentMenu');
const conversationImageInput = document.getElementById('conversationImageInput');
const conversationVideoInput = document.getElementById('conversationVideoInput');
const conversationFileInput = document.getElementById('conversationFileInput');
const conversationCameraInput = document.getElementById('conversationCameraInput');
conversationAttachmentMenuBtn.addEventListener('click', (e) => {
e.preventDefault();
conversationAttachmentMenu.classList.toggle('hidden');
});
document.getElementById('conversationAttachImageBtn').addEventListener('click', () => {
conversationAttachmentMenu.classList.add('hidden');
conversationImageInput.click();
});
document.getElementById('conversationAttachVideoBtn').addEventListener('click', () => {
conversationAttachmentMenu.classList.add('hidden');
conversationVideoInput.click();
});
document.getElementById('conversationAttachFileBtn').addEventListener('click', () => {
conversationAttachmentMenu.classList.add('hidden');
conversationFileInput.click();
});
document.getElementById('conversationTakePictureBtn').addEventListener('click', () => {
conversationAttachmentMenu.classList.add('hidden');
conversationCameraInput.click();
});
document.getElementById('conversationRecordVideoBtn').addEventListener('click', async () => {
conversationAttachmentMenu.classList.add('hidden');
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
const videoRecorder = new MediaRecorder(stream);
const chunks = [];
videoRecorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
videoRecorder.onstop = () => {
const blob = new Blob(chunks, { type: 'video/webm' });
const file = new File([blob], `video-${Date.now()}.webm`, { type: 'video/webm' });
pendingConversationAttachments.push(file);
renderConversationAttachmentsList();
stream.getTracks().forEach(track => track.stop());
};
videoRecorder.start();
// Show recording UI
const recordStatus = document.getElementById('conversationRecordStatus');
const originalStatus = recordStatus.textContent;
recordStatus.textContent = 'Recording video... (Click to stop)';
recordStatus.style.cursor = 'pointer';
recordStatus.style.color = '#dc2626';
const stopHandler = () => {
recordStatus.removeEventListener('click', stopHandler);
videoRecorder.stop();
recordStatus.textContent = originalStatus;
recordStatus.style.cursor = 'auto';
recordStatus.style.color = '';
};
recordStatus.addEventListener('click', stopHandler);
// Auto-stop after 5 minutes
setTimeout(() => {
if (videoRecorder.state === 'recording') {
recordStatus.removeEventListener('click', stopHandler);
videoRecorder.stop();
recordStatus.textContent = 'Video recording stopped (5 minute limit)';
recordStatus.style.cursor = 'auto';
setTimeout(() => {
recordStatus.textContent = originalStatus;
recordStatus.style.color = '';
}, 3000);
}
}, 5 * 60 * 1000);
} catch (err) {
console.error('Failed to access camera/microphone:', err);
alert('Unable to access camera or microphone. Check permissions.');
}
});
// File input change handlers for conversation
[conversationImageInput, conversationVideoInput, conversationFileInput, conversationCameraInput].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 = '';
conversationVideoInput.value = '';
conversationFileInput.value = '';
conversationCameraInput.value = '';
renderConversationAttachmentsList();
});
// Close conversation menu on outside click
document.addEventListener('click', (e) => {
if (!conversationAttachmentMenuBtn.contains(e.target) && !conversationAttachmentMenu.contains(e.target)) {
conversationAttachmentMenu.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();
}
});
// --- 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,
};
// 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 = `Saved note ${noteRecord.id}${attachmentMsg}`;
await clearAudio();
pendingNoteAttachments = [];
document.getElementById('imageInput').value = '';
document.getElementById('videoInput').value = '';
document.getElementById('fileInput').value = '';
document.getElementById('cameraInput').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(e);
statusEl.textContent = `Error saving: ${e.message}`;
}
}
document.getElementById('submitNoteBtn').addEventListener('click', submitNote);
</script>
</body>
</html>