minor changes added voice to search. one touch highlight

This commit is contained in:
Golwhit
2025-12-27 15:05:42 -06:00
parent bc7d2e04fb
commit 97d958a400
2 changed files with 274 additions and 13 deletions
+56 -4
View File
@@ -103,13 +103,27 @@ const searchWithinFolder = async (driveId: string, itemId: string, query: string
return (data.value || []) as DriveItem[]; return (data.value || []) as DriveItem[];
}; };
const filterByCategory = (items: DriveItem[], category?: string) => { const filterByCategory = (items: DriveItem[], category?: string, pdfOnly: boolean = true) => {
if (!category) return items; // First filter: show PDF files and Word documents (which will be converted to PDF)
let filtered = items;
if (pdfOnly) {
filtered = items.filter((i) => {
if (i.folder) return false;
const name = i.name.toLowerCase();
const mimeType = i.file?.mimeType?.toLowerCase() || '';
// Include PDFs and Word documents
return name.endsWith('.pdf') || mimeType.includes('pdf') ||
name.endsWith('.doc') || name.endsWith('.docx') || mimeType.includes('word');
});
}
// Second filter: category filtering (if specified)
if (!category) return filtered;
const nameHas = (name: string, words: string[]) => words.some((w) => name.includes(w)); const nameHas = (name: string, words: string[]) => words.some((w) => name.includes(w));
const lc = (s: string) => (s || '').toLowerCase(); const lc = (s: string) => (s || '').toLowerCase();
const contractWords = ['contract', 'estimate', 'proposal', 'bid', 'award', 'agreement', 'sow']; const contractWords = ['contract', 'estimate', 'proposal', 'bid', 'award', 'agreement', 'sow'];
const planWords = ['plan', 'drawing', 'dwg', 'pdf', 'sheet', 'layout']; const planWords = ['plan', 'drawing', 'dwg', 'pdf', 'sheet', 'layout'];
return items.filter((i) => { return filtered.filter((i) => {
const n = lc(i.name); const n = lc(i.name);
if (category === 'contracts') return nameHas(n, contractWords); if (category === 'contracts') return nameHas(n, contractWords);
if (category === 'plans') return nameHas(n, planWords); if (category === 'plans') return nameHas(n, planWords);
@@ -143,7 +157,9 @@ app.get('/api/job-files', async (c) => {
items = await listChildrenRecursive(driveId, itemId, 0, 5, token); items = await listChildrenRecursive(driveId, itemId, 0, 5, token);
} }
const filtered = filterByCategory(items, category); // Filter to show only PDFs by default (can be disabled with pdfOnly=false query param)
const pdfOnly = c.req.query('pdfOnly') !== 'false';
const filtered = filterByCategory(items, category, pdfOnly);
const mapped = filtered.map((i) => ({ const mapped = filtered.map((i) => ({
id: i.id, id: i.id,
@@ -196,6 +212,42 @@ app.get('/api/job-file-content', async (c) => {
} }
}); });
// Convert Word documents to PDF for display
app.get('/api/job-file-pdf', async (c) => {
try {
const token = getGraphToken(c);
if (!token) return c.json({ error: 'GRAPH_TOKEN missing' }, 500);
const driveId = c.req.query('driveId');
const itemId = c.req.query('itemId');
if (!driveId || !itemId) return c.json({ error: 'driveId and itemId required' }, 400);
// Use Graph API to convert to PDF format
const res = await fetch(`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content?format=pdf`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const text = await res.text();
logLine('logs/error.log', `/api/job-file-pdf conversion failed: ${res.status} - ${text}`);
return c.json({ error: `PDF conversion failed: ${res.status}` }, res.status);
}
const headers: Record<string, string> = {
'Content-Type': 'application/pdf',
};
const cd = res.headers.get('content-disposition');
if (cd) {
// Replace .docx/.doc extension with .pdf in filename
headers['Content-Disposition'] = cd.replace(/\.(docx?)/gi, '.pdf');
}
logLine('logs/server.log', `Successfully converted document to PDF: driveId=${driveId}, itemId=${itemId}`);
return new Response(res.body, { status: 200, headers });
} catch (err) {
const message = (err as Error)?.message || String(err);
const status = (err as any)?.status || 500;
logLine('logs/error.log', `/api/job-file-pdf error: ${message}`);
return c.json({ error: message }, status);
}
});
// Mutation logging endpoint // Mutation logging endpoint
app.post('/log-change', async (c) => { app.post('/log-change', async (c) => {
try { try {
+218 -9
View File
@@ -59,7 +59,17 @@
<div id="progressBar" class="h-full w-0 bg-blue-600 transition-all duration-300 ease-out"></div> <div id="progressBar" class="h-full w-0 bg-blue-600 transition-all duration-300 ease-out"></div>
</div> </div>
<input id="searchBox" placeholder="Search jobs (just start typing)" class="w-full p-2.5 rounded-lg border border-gray-300 mb-2 text-lg placeholder:text-gray-400"> <div class="relative mb-2">
<input id="searchBox" placeholder="Search jobs (just start typing or use voice)" class="w-full p-2.5 pr-12 rounded-lg border border-gray-300 text-lg placeholder:text-gray-400">
<button id="voiceSearchBtn" class="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full flex items-center justify-center hover:bg-gray-100 transition-colors" title="Voice search">
<svg id="micIcon" class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path>
</svg>
<svg id="micRecordingIcon" class="hidden w-5 h-5 text-red-600 animate-pulse" fill="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="8"></circle>
</svg>
</button>
</div>
<button id="filterToggle" class="bg-blue-600 text-white py-2 px-2.5 rounded-lg border-none cursor-pointer mb-2 inline-block min-w-[140px]">Show Filters</button> <button id="filterToggle" class="bg-blue-600 text-white py-2 px-2.5 rounded-lg border-none cursor-pointer mb-2 inline-block min-w-[140px]">Show Filters</button>
@@ -531,9 +541,179 @@
} }
function escapeHtml(str){ return String(str).replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;'); } function escapeHtml(str){ return String(str).replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;'); }
// --- Voice Search ---
const voiceSearchBtn = document.getElementById('voiceSearchBtn');
const micIcon = document.getElementById('micIcon');
const micRecordingIcon = document.getElementById('micRecordingIcon');
let recognition = null;
let isListening = false;
let hasPermission = false;
// Check if we're on HTTPS or localhost (required for mic access)
const isSecureContext = window.isSecureContext || location.protocol === 'https:' || location.hostname === 'localhost';
if (!isSecureContext) {
console.warn('⚠️ Voice search requires HTTPS or localhost');
voiceSearchBtn.style.display = 'none';
}
// Request microphone permission explicitly
async function requestMicPermission() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Stop the stream immediately, we just wanted to request permission
stream.getTracks().forEach(track => track.stop());
hasPermission = true;
console.log('✅ Microphone permission granted');
return true;
} catch (err) {
console.error('❌ Microphone permission denied:', err);
alert('🎤 Please allow microphone access to use voice search.\n\nClick the microphone icon in your browser\'s address bar to enable it.');
return false;
}
}
// Check if browser supports speech recognition
if (isSecureContext && ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window)) {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
recognition = new SpeechRecognition();
recognition.continuous = true; // Changed to true to keep listening
recognition.interimResults = true;
recognition.lang = 'en-US';
recognition.maxAlternatives = 1;
let autoStopTimer = null;
let silenceTimer = null;
recognition.onstart = () => {
isListening = true;
// Auto-stop after 10 seconds as a safety measure
autoStopTimer = setTimeout(() => {
if (isListening) {
recognition.stop();
}
}, 10000);
};
recognition.onresult = (event) => {
// Clear any existing silence timer
if (silenceTimer) {
clearTimeout(silenceTimer);
silenceTimer = null;
}
const last = event.results.length - 1;
const transcript = event.results[last][0].transcript;
const isFinal = event.results[last].isFinal;
searchBox.value = transcript.trim();
// Trigger search with results
applyFiltersAndRender();
// Stop after final result with short delay
if (isFinal) {
silenceTimer = setTimeout(() => {
recognition.stop();
}, 1500);
}
};
recognition.onspeechstart = () => {};
recognition.onspeechend = () => {};
recognition.onaudiostart = () => {};
recognition.onaudioend = () => {};
recognition.onsoundstart = () => {};
recognition.onsoundend = () => {};
recognition.onerror = (event) => {
// Clear timers
if (autoStopTimer) clearTimeout(autoStopTimer);
if (silenceTimer) clearTimeout(silenceTimer);
isListening = false;
if (event.error === 'not-allowed' || event.error === 'permission-denied') {
alert('🎤 Microphone access denied.\n\nPlease:\n1. Click the microphone icon in your browser\'s address bar\n2. Allow microphone access\n3. Refresh the page');
hasPermission = false;
} else if (event.error === 'audio-capture') {
alert('🎤 Microphone error. Please check:\n1. Your microphone is connected\n2. It\'s not being used by another app\n3. Browser has microphone access');
}
};
recognition.onend = () => {
// Clear timers
if (autoStopTimer) clearTimeout(autoStopTimer);
if (silenceTimer) clearTimeout(silenceTimer);
isListening = false;
};
voiceSearchBtn.addEventListener('click', async () => {
if (isListening) {
recognition.stop();
return;
}
// Request permission first time
if (!hasPermission) {
const granted = await requestMicPermission();
if (!granted) {
return;
}
}
// Reset state
isListening = false;
try {
recognition.start();
} catch (err) {
if (err.message && (err.message.includes('already started') || err.name === 'InvalidStateError')) {
recognition.stop();
setTimeout(() => {
try {
recognition.start();
} catch (e) {
alert('Voice recognition error. Please refresh the page and try again.');
}
}, 300);
} else {
alert('Could not start voice recognition: ' + err.message);
}
}
});
} else {
// Hide voice button if not supported
if (voiceSearchBtn) voiceSearchBtn.style.display = 'none';
console.warn('⚠️ Speech recognition not supported in this browser');
}
// --- Filters --- // --- Filters ---
filterToggle.addEventListener('click',()=>{ filtersPanel.classList.toggle('hidden'); filterToggle.textContent = filtersPanel.classList.contains('hidden')?'Show Filters':'Hide Filters'; }); filterToggle.addEventListener('click',()=>{ filtersPanel.classList.toggle('hidden'); filterToggle.textContent = filtersPanel.classList.contains('hidden')?'Show Filters':'Hide Filters'; });
searchBox.addEventListener('input',()=>applyFiltersAndRender());
// Select all text on first click, normal behavior on second click
let searchBoxSelected = false;
searchBox.addEventListener('focus', () => {
if (!searchBoxSelected) {
searchBox.select();
searchBoxSelected = true;
}
});
searchBox.addEventListener('mousedown', () => {
if (searchBoxSelected) {
searchBoxSelected = false;
}
});
searchBox.addEventListener('blur', () => {
searchBoxSelected = false;
});
searchBox.addEventListener('input',()=>{
// Remove periods added by voice dictation
searchBox.value = searchBox.value.replace(/\./g, '');
applyFiltersAndRender();
});
document.querySelectorAll('.filter-division').forEach(cb=>cb.addEventListener('change',()=>applyFiltersAndRender())); document.querySelectorAll('.filter-division').forEach(cb=>cb.addEventListener('change',()=>applyFiltersAndRender()));
document.querySelectorAll('.filter-active').forEach(cb=>{ cb.addEventListener('change',()=>{ document.querySelectorAll('.filter-active').forEach(cb=>{ cb.addEventListener('change',()=>{
if(cb.checked) document.querySelectorAll('.filter-active').forEach(o=>{if(o!==cb)o.checked=false;}); applyFiltersAndRender(); if(cb.checked) document.querySelectorAll('.filter-active').forEach(o=>{if(o!==cb)o.checked=false;}); applyFiltersAndRender();
@@ -968,6 +1148,7 @@
const fileListContainer = document.getElementById('fileListContainer'); const fileListContainer = document.getElementById('fileListContainer');
const categoryKeywords = { const categoryKeywords = {
managerInfo: ['manager info', 'manager_info', 'managerinfo'],
contracts: ['contract', 'estimate', 'proposal', 'bid', 'award', 'agreement', 'sow'], contracts: ['contract', 'estimate', 'proposal', 'bid', 'award', 'agreement', 'sow'],
plans: ['plan', 'drawing', 'dwg', 'pdf', 'sheet', 'layout'], plans: ['plan', 'drawing', 'dwg', 'pdf', 'sheet', 'layout'],
submittals: ['submittal', 'submittals', 'submit'], submittals: ['submittal', 'submittals', 'submit'],
@@ -994,6 +1175,7 @@
const categorize = (name = '') => { const categorize = (name = '') => {
const n = name.toLowerCase(); const n = name.toLowerCase();
if (categoryKeywords.managerInfo.some((w) => n.includes(w))) return 'managerInfo';
if (categoryKeywords.contracts.some((w) => n.includes(w))) return 'contracts'; if (categoryKeywords.contracts.some((w) => n.includes(w))) return 'contracts';
if (categoryKeywords.submittals.some((w) => n.includes(w))) return 'submittals'; if (categoryKeywords.submittals.some((w) => n.includes(w))) return 'submittals';
if (categoryKeywords.plans.some((w) => n.includes(w))) return 'plans'; if (categoryKeywords.plans.some((w) => n.includes(w))) return 'plans';
@@ -1058,6 +1240,7 @@
if (ext === 'pdf' || type.includes('pdf')) if (ext === 'pdf' || type.includes('pdf'))
return '<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none"><rect fill="#D32F2F" width="24" height="24" rx="2"/><text x="12" y="16" font-size="10" font-weight="bold" fill="white" text-anchor="middle">PDF</text></svg>'; return '<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none"><rect fill="#D32F2F" width="24" height="24" rx="2"/><text x="12" y="16" font-size="10" font-weight="bold" fill="white" text-anchor="middle">PDF</text></svg>';
// Show Word icon with PDF badge to indicate it will be converted
if (['doc', 'docx'].includes(ext) || type.includes('word')) if (['doc', 'docx'].includes(ext) || type.includes('word'))
return '<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none"><rect fill="#2B579A" width="24" height="24" rx="2"/><text x="12" y="16" font-size="10" font-weight="bold" fill="white" text-anchor="middle">W</text></svg>'; return '<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none"><rect fill="#2B579A" width="24" height="24" rx="2"/><text x="12" y="16" font-size="10" font-weight="bold" fill="white" text-anchor="middle">W</text></svg>';
@@ -1082,7 +1265,10 @@
const canPreviewInline = (name, contentType) => { const canPreviewInline = (name, contentType) => {
const ext = (name || '').toLowerCase().split('.').pop(); const ext = (name || '').toLowerCase().split('.').pop();
const type = (contentType || '').toLowerCase(); const type = (contentType || '').toLowerCase();
return ext === 'pdf' || type.includes('pdf') || ['jpg', 'jpeg', 'png', 'gif', 'svg', 'bmp'].includes(ext) || type.includes('image'); // Allow Word docs to be previewed (will be converted to PDF)
return ext === 'pdf' || type.includes('pdf') ||
['doc', 'docx'].includes(ext) || type.includes('word') ||
['jpg', 'jpeg', 'png', 'gif', 'svg', 'bmp'].includes(ext) || type.includes('image');
}; };
function openFileInViewer(url, name) { function openFileInViewer(url, name) {
@@ -1287,10 +1473,15 @@
iframeViewer.classList.add('hidden'); iframeViewer.classList.add('hidden');
pdfViewer.classList.add('hidden'); pdfViewer.classList.add('hidden');
iframeLoader.classList.remove('hidden'); iframeLoader.classList.remove('hidden');
// Detect if this is a Word document
const ext = (name || '').toLowerCase().split('.').pop();
const isWordDoc = ['doc', 'docx'].includes(ext) || (contentType || '').toLowerCase().includes('word');
iframeLoader.innerHTML = ` iframeLoader.innerHTML = `
<div class="text-center"> <div class="text-center">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div> <div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
<div class="text-gray-600 text-lg">Loading preview...</div> <div class="text-gray-600 text-lg">${isWordDoc ? 'Converting to PDF...' : 'Loading preview...'}</div>
</div> </div>
`; `;
@@ -1301,19 +1492,26 @@
} }
try { try {
const res = await fetch(`/api/job-file-content?driveId=${encodeURIComponent(driveId)}&itemId=${encodeURIComponent(itemId)}`, { // Use PDF conversion endpoint for Word documents
const endpoint = isWordDoc
? `/api/job-file-pdf?driveId=${encodeURIComponent(driveId)}&itemId=${encodeURIComponent(itemId)}`
: `/api/job-file-content?driveId=${encodeURIComponent(driveId)}&itemId=${encodeURIComponent(itemId)}`;
const res = await fetch(endpoint, {
headers: { 'x-graph-token': graphToken } headers: { 'x-graph-token': graphToken }
}); });
if (!res.ok) throw new Error(`Preview fetch failed: ${res.status}`); if (!res.ok) throw new Error(`Preview fetch failed: ${res.status}`);
const blob = await res.blob(); const blob = await res.blob();
currentPreviewUrl = URL.createObjectURL(blob); currentPreviewUrl = URL.createObjectURL(blob);
const isPdf = (contentType || '').toLowerCase().includes('pdf') || (name || '').toLowerCase().endsWith('.pdf'); // Word docs are converted to PDF, so treat them as PDFs
const isPdf = isWordDoc || (contentType || '').toLowerCase().includes('pdf') || (name || '').toLowerCase().endsWith('.pdf');
if (isPdf && window['pdfjsLib']) { if (isPdf && window['pdfjsLib']) {
const data = await blob.arrayBuffer(); const data = await blob.arrayBuffer();
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
const doc = await pdfjsLib.getDocument({ data }).promise; const doc = await pdfjsLib.getDocument({ data }).promise;
currentPdf = { doc, page: 1, pages: doc.numPages, scale: DEFAULT_PDF_SCALE }; const initialScale = isWordDoc ? 1.4 : DEFAULT_PDF_SCALE;
currentPdf = { doc, page: 1, pages: doc.numPages, scale: initialScale };
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 }; pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
pdfCanvas.style.transform = 'translate(0px, 0px)'; pdfCanvas.style.transform = 'translate(0px, 0px)';
await renderPdfPage(); await renderPdfPage();
@@ -1338,8 +1536,18 @@
function renderFileGroups(folderLink) { function renderFileGroups(folderLink) {
const term = (fileListState.filter || '').toLowerCase().trim(); const term = (fileListState.filter || '').toLowerCase().trim();
const filtered = fileListState.items const filtered = fileListState.items
.filter((f) => !f.isFolder && f.name.toLowerCase().includes(term)); .filter((f) => {
const groups = { contracts: [], submittals: [], plans: [], other: [] }; // Show PDF files and Word documents (which will be converted to PDF)
if (f.isFolder) return false;
const name = f.name.toLowerCase();
const contentType = (f.contentType || '').toLowerCase();
const isPdfOrWord = name.endsWith('.pdf') || contentType.includes('pdf') ||
name.endsWith('.doc') || name.endsWith('.docx') || contentType.includes('word');
if (!isPdfOrWord) return false;
// Apply search term filter if present
return name.includes(term);
});
const groups = { managerInfo: [], contracts: [], submittals: [], plans: [], other: [] };
filtered.forEach((f) => groups[categorize(f.name)].push(f)); filtered.forEach((f) => groups[categorize(f.name)].push(f));
const renderGroup = (title, list) => { const renderGroup = (title, list) => {
@@ -1378,6 +1586,7 @@
</div>`; </div>`;
}; };
const groupsMarkup = ` const groupsMarkup = `
${renderGroup('Manager Info', groups.managerInfo)}
${renderGroup('Contracts / Estimates', groups.contracts)} ${renderGroup('Contracts / Estimates', groups.contracts)}
${renderGroup('Submittals', groups.submittals)} ${renderGroup('Submittals', groups.submittals)}
${renderGroup('Plans', groups.plans)} ${renderGroup('Plans', groups.plans)}