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[];
|
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
@@ -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('&','&').replaceAll('<','<').replaceAll('>','>'); }
|
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 ---
|
// --- 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)}
|
||||||
|
|||||||
Reference in New Issue
Block a user