minor changes added voice to search. one touch highlight
This commit is contained in:
+56
-4
@@ -103,13 +103,27 @@ const searchWithinFolder = async (driveId: string, itemId: string, query: string
|
||||
return (data.value || []) as DriveItem[];
|
||||
};
|
||||
|
||||
const filterByCategory = (items: DriveItem[], category?: string) => {
|
||||
if (!category) return items;
|
||||
const filterByCategory = (items: DriveItem[], category?: string, pdfOnly: boolean = true) => {
|
||||
// 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 lc = (s: string) => (s || '').toLowerCase();
|
||||
const contractWords = ['contract', 'estimate', 'proposal', 'bid', 'award', 'agreement', 'sow'];
|
||||
const planWords = ['plan', 'drawing', 'dwg', 'pdf', 'sheet', 'layout'];
|
||||
return items.filter((i) => {
|
||||
return filtered.filter((i) => {
|
||||
const n = lc(i.name);
|
||||
if (category === 'contracts') return nameHas(n, contractWords);
|
||||
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);
|
||||
}
|
||||
|
||||
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) => ({
|
||||
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
|
||||
app.post('/log-change', async (c) => {
|
||||
try {
|
||||
|
||||
+218
-9
@@ -59,7 +59,17 @@
|
||||
<div id="progressBar" class="h-full w-0 bg-blue-600 transition-all duration-300 ease-out"></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>
|
||||
|
||||
@@ -531,9 +541,179 @@
|
||||
}
|
||||
function escapeHtml(str){ return String(str).replaceAll('&','&').replaceAll('<','<').replaceAll('>','>'); }
|
||||
|
||||
// --- 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 ---
|
||||
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-active').forEach(cb=>{ cb.addEventListener('change',()=>{
|
||||
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 categoryKeywords = {
|
||||
managerInfo: ['manager info', 'manager_info', 'managerinfo'],
|
||||
contracts: ['contract', 'estimate', 'proposal', 'bid', 'award', 'agreement', 'sow'],
|
||||
plans: ['plan', 'drawing', 'dwg', 'pdf', 'sheet', 'layout'],
|
||||
submittals: ['submittal', 'submittals', 'submit'],
|
||||
@@ -994,6 +1175,7 @@
|
||||
|
||||
const categorize = (name = '') => {
|
||||
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.submittals.some((w) => n.includes(w))) return 'submittals';
|
||||
if (categoryKeywords.plans.some((w) => n.includes(w))) return 'plans';
|
||||
@@ -1058,6 +1240,7 @@
|
||||
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>';
|
||||
|
||||
// Show Word icon with PDF badge to indicate it will be converted
|
||||
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>';
|
||||
|
||||
@@ -1082,7 +1265,10 @@
|
||||
const canPreviewInline = (name, contentType) => {
|
||||
const ext = (name || '').toLowerCase().split('.').pop();
|
||||
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) {
|
||||
@@ -1287,10 +1473,15 @@
|
||||
iframeViewer.classList.add('hidden');
|
||||
pdfViewer.classList.add('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 = `
|
||||
<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="text-gray-600 text-lg">Loading preview...</div>
|
||||
<div class="text-gray-600 text-lg">${isWordDoc ? 'Converting to PDF...' : 'Loading preview...'}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1301,19 +1492,26 @@
|
||||
}
|
||||
|
||||
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 }
|
||||
});
|
||||
if (!res.ok) throw new Error(`Preview fetch failed: ${res.status}`);
|
||||
const blob = await res.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']) {
|
||||
const data = await blob.arrayBuffer();
|
||||
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;
|
||||
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 };
|
||||
pdfCanvas.style.transform = 'translate(0px, 0px)';
|
||||
await renderPdfPage();
|
||||
@@ -1338,8 +1536,18 @@
|
||||
function renderFileGroups(folderLink) {
|
||||
const term = (fileListState.filter || '').toLowerCase().trim();
|
||||
const filtered = fileListState.items
|
||||
.filter((f) => !f.isFolder && f.name.toLowerCase().includes(term));
|
||||
const groups = { contracts: [], submittals: [], plans: [], other: [] };
|
||||
.filter((f) => {
|
||||
// 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));
|
||||
|
||||
const renderGroup = (title, list) => {
|
||||
@@ -1378,6 +1586,7 @@
|
||||
</div>`;
|
||||
};
|
||||
const groupsMarkup = `
|
||||
${renderGroup('Manager Info', groups.managerInfo)}
|
||||
${renderGroup('Contracts / Estimates', groups.contracts)}
|
||||
${renderGroup('Submittals', groups.submittals)}
|
||||
${renderGroup('Plans', groups.plans)}
|
||||
|
||||
Reference in New Issue
Block a user