fixed viewer and folder issues

This commit is contained in:
2025-12-28 01:41:47 +00:00
parent b11df8484f
commit a191db8449
2 changed files with 240 additions and 31 deletions
+57 -5
View File
@@ -185,9 +185,56 @@ const fetchJson = async (url: string, token: string) => {
};
const resolveShareLink = async (link: string, token: string) => {
const encoded = `u!${b64Url(link)}`;
const data = await fetchJson(`https://graph.microsoft.com/v1.0/shares/${encoded}/driveItem`, token);
return { driveId: data.parentReference?.driveId as string, itemId: data.id as string };
// Decode the link in case it comes pre-encoded from the database
const decodedLink = decodeURIComponent(link);
console.log('[resolveShareLink] Decoded link:', decodedLink);
// Check if this is a short sharing link (/:f:/ or /:b:/) or a direct document library URL
if (decodedLink.includes('/:f:/') || decodedLink.includes('/:b:/')) {
// Short sharing link - use shares API
const encoded = `u!${b64Url(decodedLink)}`;
console.log('[resolveShareLink] Using shares API, encoded:', encoded);
const data = await fetchJson(`https://graph.microsoft.com/v1.0/shares/${encoded}/driveItem`, token);
return { driveId: data.parentReference?.driveId as string, itemId: data.id as string };
} else {
// Direct document library URL - parse it and use drive API
// Example: https://czflex.sharepoint.com/sites/Team/Shared Documents/Forms/AllItems.aspx?RootFolder=/sites/Team/Shared Documents/General/...
const url = new URL(decodedLink);
const pathMatch = url.hostname.match(/^([^.]+)\.sharepoint\.com$/);
if (!pathMatch) throw new Error('Invalid SharePoint URL');
const hostname = pathMatch[1]; // e.g., 'czflex'
const sitePath = url.pathname.split('/Shared')[0]; // e.g., /sites/Team
const rootFolderParam = url.searchParams.get('RootFolder');
if (!rootFolderParam) throw new Error('RootFolder parameter missing');
// Extract the folder path relative to the document library
// RootFolder format: /sites/Team/Shared Documents/General/Operations [Server]/...
// We need: General/Operations [Server]/...
const folderPath = rootFolderParam.split('/Shared Documents/')[1];
if (!folderPath) throw new Error('Could not parse folder path');
console.log('[resolveShareLink] Hostname:', hostname);
console.log('[resolveShareLink] Site path:', sitePath);
console.log('[resolveShareLink] Folder path:', folderPath);
// Get the site ID first using hostname:sitePath format
const siteData = await fetchJson(`https://graph.microsoft.com/v1.0/sites/${hostname}.sharepoint.com:${sitePath}`, token);
const siteId = siteData.id;
// Get the default document library drive
const driveData = await fetchJson(`https://graph.microsoft.com/v1.0/sites/${siteId}/drive`, token);
const driveId = driveData.id;
// Get the folder item by path
const itemData = await fetchJson(
`https://graph.microsoft.com/v1.0/drives/${driveId}/root:/${folderPath}`,
token
);
console.log('[resolveShareLink] Resolved to driveId:', driveId, 'itemId:', itemData.id);
return { driveId, itemId: itemData.id };
}
};
const listChildrenRecursive = async (
@@ -225,9 +272,12 @@ const filterByCategory = (items: DriveItem[], category?: string, pdfOnly: boolea
if (i.folder) return false;
const name = i.name.toLowerCase();
const mimeType = i.file?.mimeType?.toLowerCase() || '';
// Include PDFs and Word documents
// Include PDFs, Word documents, and images
return name.endsWith('.pdf') || mimeType.includes('pdf') ||
name.endsWith('.doc') || name.endsWith('.docx') || mimeType.includes('word');
name.endsWith('.doc') || name.endsWith('.docx') || mimeType.includes('word') ||
name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.png') ||
name.endsWith('.gif') || name.endsWith('.bmp') || name.endsWith('.tiff') ||
name.endsWith('.webp') || mimeType.includes('image');
});
}
@@ -274,6 +324,7 @@ app.get('/api/job-files', async (c) => {
// 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);
console.log(`[job-files] Found ${items.length} total items, ${filtered.length} after filtering`);
const mapped = filtered.map((i) => ({
id: i.id,
@@ -291,6 +342,7 @@ app.get('/api/job-files', async (c) => {
} catch (err) {
const message = (err as Error)?.message || String(err);
const status = (err as any)?.status || 500;
console.error('[job-files] ERROR:', message);
logLine('logs/error.log', `/api/job-files error: ${message}`);
return c.json({ error: message }, status);
}
+183 -26
View File
@@ -67,7 +67,7 @@
<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">
<svg id="micIcon" class="w-5 h-5 text-gray-600 transition-colors" 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">
@@ -374,8 +374,17 @@
// Load version from backend /version endpoint so UI matches package.json
async function loadVersion(){
if(!versionLabel) return;
// Display "Redis" for the Redis implementation branch
versionLabel.textContent = 'Redis';
try{
const res = await fetch('/version');
if(!res.ok) {
console.warn(`Version endpoint returned ${res.status}. Using default version.`);
return;
}
const data = await res.json().catch(()=>null);
if(data?.version) versionLabel.textContent = `v${data.version}`;
}catch(err){
console.debug('Version load failed (non-critical):', err.message);
}
}
loadVersion();
@@ -478,7 +487,32 @@
}
function applyFiltersAndRender(){
const q = safeLower(searchBox.value||'');
let q = safeLower(searchBox.value||'');
// Check for recency keywords
const recencyKeywords = ['most recent', 'newest', 'latest', 'current', 'recent'];
let sortByRecent = false;
// Check if query contains any recency keywords (check longer phrases first)
for (const keyword of recencyKeywords) {
if (q.includes(keyword)) {
sortByRecent = true;
// Remove the keyword from search query (use global replace)
const regex = new RegExp(keyword, 'gi');
q = q.replace(regex, ' ');
}
}
// Clean up multiple spaces and trim
q = q.replace(/\s+/g, ' ').trim();
// Update the display to show cleaned query
if (sortByRecent && searchBox.value !== q) {
searchBox.value = q.split(' ').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ');
}
const divisionVals = Array.from(document.querySelectorAll('.filter-division:checked')).map(i=>i.value);
const activeVals = Array.from(document.querySelectorAll('.filter-active:checked')).map(i=>i.value);
const activeWanted = activeVals.map(v=>toBool(v)).filter(v=>v!==null);
@@ -503,11 +537,31 @@
return true;
});
// Data already sorted from PocketBase, only re-sort if filters applied
if(q || divisionVals.length || activeVals.length || statusVals.length || estimatorVals.length){
// If recency keyword used, show only THE most recent match
if(sortByRecent && visibleJobs.length > 0){
console.log('Before sorting, visibleJobs count:', visibleJobs.length);
// Sort by Job_Number (higher number = newer job)
visibleJobs.sort((a, b) => {
const numA = jobNumberValue(a) || 0;
const numB = jobNumberValue(b) || 0;
return numB - numA; // Higher job number first (newest)
});
console.log('After sorting, newest job:', visibleJobs[0]?.Job_Number);
// Keep only the most recent
visibleJobs = visibleJobs.slice(0, 1);
console.log('✓ Showing only the most recent match');
} else if(q || divisionVals.length || activeVals.length || statusVals.length || estimatorVals.length){
sortJobsDescending(visibleJobs);
}
console.log('Filter summary', { q, divisionVals, activeWanted, statusVals, estimatorValsLower, total: jobsCache.length, visible: visibleJobs.length });
console.log('Filter summary', {
originalSearch: searchBox.value,
cleanedQ: q,
sortByRecent,
resultsCount: visibleJobs.length
});
renderCards(visibleJobs);
}
@@ -600,9 +654,17 @@
let autoStopTimer = null;
let silenceTimer = null;
let lastResultIndex = 0;
let baseSearchText = '';
recognition.onstart = () => {
isListening = true;
lastResultIndex = 0;
baseSearchText = searchBox.value.trim();
// Change mic icon to filled maroon/red
micIcon.style.fill = '#991b1b'; // Tailwind red-800
micIcon.style.stroke = '#991b1b';
// Auto-stop after 10 seconds as a safety measure
autoStopTimer = setTimeout(() => {
@@ -619,17 +681,40 @@
silenceTimer = null;
}
const last = event.results.length - 1;
const transcript = event.results[last][0].transcript;
const isFinal = event.results[last].isFinal;
// Process only new final results
for (let i = lastResultIndex; i < event.results.length; i++) {
if (event.results[i].isFinal) {
let transcript = event.results[i][0].transcript.trim();
searchBox.value = transcript.trim();
// Remove trailing punctuation (period, comma, etc.)
transcript = transcript.replace(/[.,!?;:]+$/, '');
// Capitalize each word (title case)
transcript = transcript.split(' ').map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join(' ');
if (transcript) {
// Add space if base text exists and doesn't end with space
if (baseSearchText && !baseSearchText.endsWith(' ')) {
baseSearchText += ' ';
}
baseSearchText += transcript;
}
lastResultIndex = i + 1;
}
}
// Update search box with accumulated text
searchBox.value = baseSearchText;
// Trigger search with results
applyFiltersAndRender();
// Stop after final result with short delay
if (isFinal) {
// Check if all results are final, if so prepare to stop
const allFinal = Array.from(event.results).every(r => r.isFinal);
if (allFinal && event.results.length > 0) {
silenceTimer = setTimeout(() => {
recognition.stop();
}, 1500);
@@ -663,6 +748,10 @@
if (autoStopTimer) clearTimeout(autoStopTimer);
if (silenceTimer) clearTimeout(silenceTimer);
// Reset mic icon to normal
micIcon.style.fill = 'none';
micIcon.style.stroke = '';
isListening = false;
};
@@ -1310,7 +1399,11 @@
window.openFileInViewer = openFileInViewer;
async function renderPdfPage() {
const { doc, page, scale } = currentPdf;
const { doc, page, scale, isImage } = currentPdf;
if (isImage) {
renderImageOnCanvas();
return;
}
if (!doc) return;
const pageObj = await doc.getPage(page);
const viewport = pageObj.getViewport({ scale });
@@ -1328,10 +1421,33 @@
pdfZoomReset.textContent = `${Math.round(currentPdf.scale * 100)}%`;
}
function renderImageOnCanvas() {
const { scale, imageElement, imageWidth, imageHeight } = currentPdf;
if (!imageElement) return;
const canvas = pdfCanvas;
const context = canvas.getContext('2d');
const scaledWidth = imageWidth * scale;
const scaledHeight = imageHeight * scale;
canvas.width = scaledWidth;
canvas.height = scaledHeight;
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(imageElement, 0, 0, scaledWidth, scaledHeight);
// re-apply pan transform after render
pdfCanvas.style.transform = `translate(${pdfPan.x}px, ${pdfPan.y}px)`;
pdfZoomLabel.textContent = `${Math.round(scale * 100)}%`;
pdfZoomReset.textContent = `${Math.round(scale * 100)}%`;
}
function showPdfViewer() {
iframeViewer.classList.add('hidden');
pdfViewer.classList.remove('hidden');
iframeLoader.classList.add('hidden');
// Ensure PDF controls are visible (might be hidden for images)
pdfControls.style.display = '';
// Show page navigation for PDFs
pdfPrev.parentElement.style.display = '';
pdfNext.parentElement.style.display = '';
pdfPageNum.parentElement.style.display = '';
}
pdfPrev.addEventListener('click', async () => {
@@ -1347,18 +1463,18 @@
await renderPdfPage();
});
pdfZoomIn.addEventListener('click', async () => {
if (!currentPdf.doc) return;
if (!currentPdf.doc && !currentPdf.isImage) return;
currentPdf.scale = Math.min(currentPdf.scale + 0.1, MAX_PDF_SCALE);
await renderPdfPage();
});
pdfZoomOut.addEventListener('click', async () => {
if (!currentPdf.doc) return;
if (!currentPdf.doc && !currentPdf.isImage) return;
currentPdf.scale = Math.max(currentPdf.scale - 0.1, MIN_PDF_SCALE);
await renderPdfPage();
});
pdfZoomReset.addEventListener('click', async () => {
if (!currentPdf.doc) return;
currentPdf.scale = DEFAULT_PDF_SCALE;
if (!currentPdf.doc && !currentPdf.isImage) return;
currentPdf.scale = currentPdf.isImage ? 1 : DEFAULT_PDF_SCALE;
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
pdfCanvas.style.transform = 'translate(0px, 0px)';
await renderPdfPage();
@@ -1383,7 +1499,7 @@
};
pdfCanvas.addEventListener('wheel', async (e) => {
if (!currentPdf.doc) return;
if (!currentPdf.doc && !currentPdf.isImage) return;
e.preventDefault();
const delta = e.deltaY;
const step = 0.1;
@@ -1403,7 +1519,7 @@
fileListContainer.classList.remove('hidden');
});
pdfCanvas.addEventListener('pointerdown', (e) => {
if (!currentPdf.doc) return;
if (!currentPdf.doc && !currentPdf.isImage) return;
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (activePointers.size === 2) {
@@ -1423,7 +1539,7 @@
});
pdfCanvas.addEventListener('pointermove', (e) => {
if (!currentPdf.doc) return;
if (!currentPdf.doc && !currentPdf.isImage) return;
if (activePointers.has(e.pointerId)) {
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
@@ -1523,6 +1639,9 @@
// Word docs are converted to PDF, so treat them as PDFs
const isPdf = isWordDoc || (contentType || '').toLowerCase().includes('pdf') || (name || '').toLowerCase().endsWith('.pdf');
const isImage = (contentType || '').toLowerCase().includes('image') ||
/\.(jpg|jpeg|png|gif|bmp|tiff|webp)$/i.test(name || '');
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';
@@ -1533,8 +1652,39 @@
pdfCanvas.style.transform = 'translate(0px, 0px)';
await renderPdfPage();
showPdfViewer();
} else if (isImage) {
// Show images in the PDF viewer area for consistency
pdfViewer.classList.remove('hidden');
iframeViewer.classList.add('hidden');
iframeLoader.classList.add('hidden');
// Store original image for re-rendering at different scales
const img = new Image();
img.onload = () => {
currentPdf = {
doc: null,
page: 1,
pages: 1,
scale: 1,
isImage: true,
imageElement: img,
imageWidth: img.width,
imageHeight: img.height
};
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
pdfCanvas.style.transform = 'translate(0px, 0px)';
renderImageOnCanvas();
// Show PDF controls (zoom works for images too)
pdfControls.style.display = '';
// Hide page navigation for images
pdfPrev.parentElement.style.display = 'none';
pdfNext.parentElement.style.display = 'none';
pdfPageNum.parentElement.style.display = 'none';
};
img.src = currentPreviewUrl;
} else {
// fallback to iframe blob view (images, etc.)
// fallback to iframe blob view
iframeViewer.src = 'about:blank';
setTimeout(() => {
iframeViewer.src = currentPreviewUrl;
@@ -1554,13 +1704,16 @@
const term = (fileListState.filter || '').toLowerCase().trim();
const filtered = fileListState.items
.filter((f) => {
// Show PDF files and Word documents (which will be converted to PDF)
// Show PDF files, Word documents (which will be converted to PDF), and images
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;
const isImage = name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.png') ||
name.endsWith('.gif') || name.endsWith('.bmp') || name.endsWith('.tiff') ||
name.endsWith('.webp') || contentType.includes('image');
if (!isPdfOrWord && !isImage) return false;
// Apply search term filter if present
return name.includes(term);
});
@@ -1665,6 +1818,7 @@
if (cached) {
console.log('Using cached files for job', job.Job_Number);
fileListState.items = cached.items;
iframeLoader.classList.add('hidden');
renderFileGroups(link);
return;
}
@@ -1683,7 +1837,7 @@
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
const timeout = setTimeout(() => controller.abort(), 30000); // 30 seconds for large folders
let resp = await fetch(`/api/job-files?link=${encodeURIComponent(link)}`, { headers, signal: controller.signal });
clearTimeout(timeout);
@@ -1713,10 +1867,11 @@
fileCache.set(link, { items, driveId: rootDriveId });
fileListState.items = items;
console.log(`✓ Loaded ${items.length} files for job ${job.Job_Number}`);
iframeLoader.classList.add('hidden');
renderFileGroups(link);
} catch (err) {
console.error('job-files error', err);
console.error('job-files error for', job.Job_Number, err);
const authExpired = err?.message === 'AUTH_EXPIRED' || String(err || '').includes('401');
const timedOut = err?.name === 'AbortError';
const friendly = authExpired
@@ -1786,6 +1941,8 @@
URL.revokeObjectURL(currentPreviewUrl);
currentPreviewUrl = null;
}
// Clear file cache when closing to ensure fresh data on next open
fileCache.clear();
// Clear iframe src to stop any loading/video/audio and prevent stacking
setTimeout(() => {
iframeViewer.src = 'about:blank';