many edits

This commit is contained in:
2025-12-19 22:55:48 +00:00
parent 56e1131622
commit 94e5079a60
3 changed files with 244 additions and 34 deletions
+7 -3
View File
@@ -63,7 +63,9 @@ const fetchJson = async (url: string, token: string) => {
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Graph ${res.status}: ${text}`);
const err: any = new Error(`Graph ${res.status}: ${text}`);
err.status = res.status;
throw err;
}
return res.json();
};
@@ -158,8 +160,9 @@ app.get('/api/job-files', async (c) => {
return c.json({ items: mapped, total: mapped.length, source: q ? 'search' : 'walk', driveId });
} catch (err) {
const message = (err as Error)?.message || String(err);
const status = (err as any)?.status || 500;
logLine('logs/error.log', `/api/job-files error: ${message}`);
return c.json({ error: message }, 500);
return c.json({ error: message }, status);
}
});
@@ -187,8 +190,9 @@ app.get('/api/job-file-content', async (c) => {
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-content error: ${message}`);
return c.json({ error: message }, 500);
return c.json({ error: message }, status);
}
});
+223 -28
View File
@@ -77,9 +77,12 @@
<div id="results" aria-live="polite" class="flex flex-col gap-3 flex-1 overflow-auto p-0"></div>
<div class="text-xs text-gray-500 mt-5 pt-3 border-t border-gray-200 flex items-center justify-between">
<span id="userEmail" class="truncate"></span>
<div class="flex items-center gap-2">
<button id="signOutBtn" class="px-2 py-1 text-[11px] bg-gray-200 hover:bg-gray-300 rounded border border-gray-300 text-gray-700">Sign out</button>
<span id="versionLabel">v1.0.0-beta2</span>
</div>
</div>
</div>
<!-- Detail Modal -->
<div id="detailModal" role="dialog" aria-modal="true" class="hidden flex fixed inset-0 bg-black/45 items-start justify-center pt-[2vh] z-[999]">
@@ -150,9 +153,9 @@
</svg>
</button>
</div>
<div class="flex-1 relative bg-white">
<div id="fileListContainer" class="hidden h-full overflow-y-scroll p-4 pr-5 bg-white" style="scrollbar-gutter: stable both-edges; overscroll-behavior: contain;"></div>
<div id="pdfViewer" class="hidden h-full flex flex-col bg-gray-50">
<div class="flex-1 relative bg-white flex flex-col overflow-hidden">
<div id="fileListContainer" class="hidden flex-1 flex flex-col p-0 bg-white overflow-hidden"></div>
<div id="pdfViewer" class="hidden flex-1 flex flex-col bg-gray-50">
<div class="flex items-center gap-2 px-3 py-2 border-b border-gray-200 bg-white shadow-sm flex-shrink-0">
<div class="flex items-center gap-1">
<button id="pdfZoomOut" class="px-2 py-1 bg-gray-200 hover:bg-gray-300 rounded"></button>
@@ -194,12 +197,31 @@
const authed = await ensureAuth();
if (!authed) return;
// Make sure a Graph token is available before proceeding; this may refresh silently
try {
await ensureGraphToken();
} catch (err) {
console.warn('Graph token missing after refresh; continuing. Preview may prompt for auth.', err?.message || err);
}
// --- Utility ---
const fetchNoCache = (url, options={}) => {
const headers = { ...(options.headers||{}), ...authHeaders() };
return fetch(url + `&_ts=${Date.now()}`, { ...options, headers });
};
const GRAPH_TOKEN_KEY = 'graphAccessToken';
const GRAPH_REAUTH_FLAG = 'graphReauthAttempted';
const extractGraphToken = (metaObj={}) => (
metaObj.graphAccessToken ||
metaObj.graph_token ||
metaObj.graphToken ||
metaObj.accessToken ||
metaObj.access_token ||
metaObj.token ||
metaObj.rawToken ||
metaObj?.authData?.access_token ||
''
);
const getGraphToken = () => {
// Prefer runtime token already present in PB model/meta, then localStorage
const model = pb?.authStore?.model || {};
@@ -240,6 +262,7 @@
const filtersPanel = document.getElementById('filtersPanel');
const filterToggle = document.getElementById('filterToggle');
const userEmailEl = document.getElementById('userEmail');
const signOutBtn = document.getElementById('signOutBtn');
const detailModal = document.getElementById('detailModal');
const detailTable = document.getElementById('detailTable');
@@ -263,6 +286,53 @@
const currentUserEmail = getEmail();
if(userEmailEl && currentUserEmail) userEmailEl.textContent = currentUserEmail;
// Ensure Graph token is present; refresh if missing; redirect to signin if still absent
async function refreshGraphToken(){
try {
const authData = await pb.collection('users').authRefresh();
const meta = authData?.meta || pb?.authStore?.model?.meta || {};
const token = extractGraphToken(meta);
if (token) localStorage.setItem(GRAPH_TOKEN_KEY, token);
return token || '';
} catch(err){
console.warn('Graph token refresh failed:', err?.message||err);
return '';
}
}
async function ensureGraphToken(){
let token = getGraphToken();
if(token) return token;
// If PB session is valid, try to refresh; if still missing, redirect to reauth once
if (pb?.authStore?.isValid) {
token = await refreshGraphToken();
if (token) return token;
const attempted = localStorage.getItem(GRAPH_REAUTH_FLAG) === '1';
if (!attempted) {
localStorage.setItem(GRAPH_REAUTH_FLAG, '1');
console.warn('Graph token unavailable after refresh; redirecting once to sign-in to obtain a new token.');
window.location.href = 'signin.html?reauth=1';
throw new Error('GRAPH_TOKEN missing');
}
console.warn('Graph token unavailable after refresh; reauth already attempted. Proceeding without token.');
return '';
}
// PB session invalid: redirect to signin
window.location.href = 'signin.html';
throw new Error('GRAPH_TOKEN missing');
}
if (signOutBtn) {
signOutBtn.addEventListener('click', () => {
try { pb?.authStore?.clear?.(); } catch {}
try { localStorage.removeItem(GRAPH_TOKEN_KEY); } catch {}
try { localStorage.removeItem('pocketbase_auth'); } catch {}
try { sessionStorage.clear(); } catch {}
location.reload();
});
}
// Load version from backend /version endpoint so UI matches package.json
async function loadVersion(){
if(!versionLabel) return;
@@ -543,6 +613,25 @@
// --- Modal & Notes ---
function openDetail(job){
currentJob = job;
// Show modal immediately
detailModal.classList.remove('hidden');
document.getElementById('detailBox').scrollTop = 0;
switchTab('info');
// Show loading state
detailTable.innerHTML = '<div class="text-gray-500 text-sm">Loading...</div>';
// Preload files in background
if (job.Job_Folder_Link) {
preloadJobFiles(job).catch(err => console.log('Background preload error:', err));
}
// Populate details asynchronously
setTimeout(() => populateDetailContent(job), 0);
}
function populateDetailContent(job){
detailTable.innerHTML = '';
const fields = ["Job_Full_Name","Job_Number","Company_Client","Manager","Contact_Person","Phone_Number","Job_Address","Start_Date","Tax_Exempt","Job_Status","Job_Type"];
let i = 0;
@@ -724,11 +813,6 @@
jobNotes.innerHTML = job.Notes || '';
newNote.innerHTML = '';
tbButtons.forEach(b=>b.classList.remove('bg-blue-100', 'text-gray-900', 'border-blue-600'));
detailModal.classList.remove('hidden');
document.getElementById('detailBox').scrollTop = 0;
// Reset to INFO tab when opening
switchTab('info');
}
const closeModal = ()=>{ detailModal.classList.add('hidden'); renderCards(visibleJobs); };
document.getElementById('btnClose').addEventListener('click',closeModal);
@@ -894,6 +978,49 @@
return 'other';
};
// File cache for preloading
const fileCache = new Map();
async function preloadJobFiles(job) {
const link = job.Job_Folder_Link;
if (!link || fileCache.has(link)) return; // Skip if no link or already cached
try {
const graphToken = await ensureGraphToken();
const headers = graphToken ? { 'x-graph-token': graphToken } : {};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
let resp = await fetch(`/api/job-files?link=${encodeURIComponent(link)}`, { headers, signal: controller.signal });
clearTimeout(timeout);
// If unauthorized, try to refresh Graph token once, then retry
if (resp.status === 401) {
const refreshed = await refreshGraphToken();
if (refreshed) {
headers['x-graph-token'] = refreshed;
const retryCtl = new AbortController();
const retryTimeout = setTimeout(() => retryCtl.abort(), 15000);
resp = await fetch(`/api/job-files?link=${encodeURIComponent(link)}`, { headers, signal: retryCtl.signal });
clearTimeout(retryTimeout);
}
}
if (resp.ok) {
const data = await resp.json();
const rootDriveId = data.driveId || '';
const items = (data.items || []).map(f => ({
...f,
driveId: f.driveId || rootDriveId
}));
fileCache.set(link, { items, driveId: rootDriveId });
console.log(`Preloaded ${items.length} files for job ${job.Job_Number}`);
}
} catch (err) {
console.log('Preload failed (non-critical):', err);
}
}
const formatSize = (bytes) => {
if (!bytes && bytes !== 0) return '';
if (bytes < 1024) return `${bytes} B`;
@@ -905,14 +1032,29 @@
const getFileIcon = (name, contentType) => {
const ext = (name || '').toLowerCase().split('.').pop();
const type = (contentType || '').toLowerCase();
if (ext === 'pdf' || type.includes('pdf')) return '📄';
if (['doc', 'docx'].includes(ext) || type.includes('word')) return '📝';
if (['xls', 'xlsx'].includes(ext) || type.includes('excel') || type.includes('spreadsheet')) return '📊';
if (['ppt', 'pptx'].includes(ext) || type.includes('powerpoint') || type.includes('presentation')) return '📽️';
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg'].includes(ext) || type.includes('image')) return '🖼️';
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext) || type.includes('zip') || type.includes('compressed')) return '📦';
if (['txt', 'csv', 'log'].includes(ext) || type.includes('text')) return '📃';
return '📎';
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>';
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>';
if (['xls', 'xlsx'].includes(ext) || type.includes('excel') || type.includes('spreadsheet'))
return '<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none"><rect fill="#217346" width="24" height="24" rx="2"/><text x="12" y="16" font-size="10" font-weight="bold" fill="white" text-anchor="middle">X</text></svg>';
if (['ppt', 'pptx'].includes(ext) || type.includes('powerpoint') || type.includes('presentation'))
return '<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none"><rect fill="#D35230" width="24" height="24" rx="2"/><text x="12" y="16" font-size="10" font-weight="bold" fill="white" text-anchor="middle">P</text></svg>';
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg'].includes(ext) || type.includes('image'))
return '<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="20" rx="2"/><circle cx="8" cy="8" r="2"/><path d="M21 15l-5-5L5 21"/></svg>';
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext) || type.includes('zip') || type.includes('compressed'))
return '<svg class="w-6 h-6" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-5-9h-3v4h3v-4zm0-3h-3v2h3V7z"/></svg>';
if (['txt', 'csv', 'log'].includes(ext) || type.includes('text'))
return '<svg class="w-6 h-6" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/><path d="M16 13H8M16 17H8M10 9H8"/></svg>';
return '<svg class="w-6 h-6" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/></svg>';
};
const canPreviewInline = (name, contentType) => {
@@ -1191,7 +1333,7 @@
return `
<div class="flex items-center justify-between py-2 border-b border-gray-100">
<div class="flex items-center min-w-0 gap-2">
<span class="text-2xl flex-shrink-0">${icon}</span>
<span class="text-gray-600 flex-shrink-0">${icon}</span>
<div class="min-w-0">
<div class="font-medium text-gray-800 truncate" title="${f.name}">${f.name}</div>
<div class="text-xs text-gray-500">
@@ -1224,11 +1366,11 @@
let groupsHost = fileListContainer.querySelector('.file-groups');
if (!groupsHost) {
fileListContainer.innerHTML = `
<div class="flex items-center gap-2 mb-3 file-search-row">
<div class="flex items-center gap-2 mb-3 file-search-row px-4 pt-4 flex-shrink-0">
<input id="fileSearchInput" type="search" placeholder="Search files" value="${fileListState.filter}" class="flex-1 px-3 py-2 border border-gray-300 rounded-lg" />
<a data-folder-link href="${folderLink}" target="_blank" rel="noopener" class="px-3 py-2 bg-gray-200 hover:bg-gray-300 rounded-lg text-sm text-gray-800">Open folder</a>
</div>
<div class="file-groups"></div>
<div class="file-groups flex-1 overflow-auto px-4 pb-4"></div>
`;
groupsHost = fileListContainer.querySelector('.file-groups');
const searchInput = document.getElementById('fileSearchInput');
@@ -1263,15 +1405,22 @@
fileListState.filter = '';
fileListState.jobNumber = job.Job_Number || '';
const graphToken = getGraphToken();
const headers = graphToken ? { 'x-graph-token': graphToken } : {};
if (!graphToken) console.warn('Graph token not found locally; relying on backend default token if configured.');
// Show modal in list mode
iframeViewerModal.classList.remove('hidden');
iframeTitle.textContent = job.Job_Number ? `Job# ${job.Job_Number}` : 'Job Files';
fileListContainer.classList.remove('hidden');
iframeViewer.classList.add('hidden');
// Check if we have cached files
const cached = fileCache.get(link);
if (cached) {
console.log('Using cached files for job', job.Job_Number);
fileListState.items = cached.items;
renderFileGroups(link);
return;
}
// If not cached, show loading and fetch
iframeLoader.classList.remove('hidden');
iframeLoader.innerHTML = `
<div class="text-center">
@@ -1280,27 +1429,73 @@
</div>
`;
const graphToken = await ensureGraphToken();
const headers = graphToken ? { 'x-graph-token': graphToken } : {};
try {
const resp = await fetch(`/api/job-files?link=${encodeURIComponent(link)}`, { headers });
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
let resp = await fetch(`/api/job-files?link=${encodeURIComponent(link)}`, { headers, signal: controller.signal });
clearTimeout(timeout);
// If unauthorized, try to refresh Graph token once, then retry
if (resp.status === 401) {
const refreshed = await refreshGraphToken();
if (refreshed) {
headers['x-graph-token'] = refreshed;
const retryCtl = new AbortController();
const retryTimeout = setTimeout(() => retryCtl.abort(), 15000);
resp = await fetch(`/api/job-files?link=${encodeURIComponent(link)}`, { headers, signal: retryCtl.signal });
clearTimeout(retryTimeout);
}
}
if (resp.status === 401) throw new Error('AUTH_EXPIRED');
if (resp.status === 500) throw new Error('GRAPH_TOKEN missing or expired');
if (!resp.ok) throw new Error(`File list failed: ${resp.status}`);
const data = await resp.json();
const rootDriveId = data.driveId || '';
fileListState.items = (data.items || []).map(f => ({
const items = (data.items || []).map(f => ({
...f,
driveId: f.driveId || rootDriveId
}));
// Cache the results
fileCache.set(link, { items, driveId: rootDriveId });
fileListState.items = items;
iframeLoader.classList.add('hidden');
renderFileGroups(link);
} catch (err) {
console.error('job-files error', err);
const authExpired = err?.message === 'AUTH_EXPIRED' || String(err || '').includes('401');
const timedOut = err?.name === 'AbortError';
const friendly = authExpired
? 'Your Microsoft Graph session expired. Click Re-authenticate to sign in again.'
: timedOut
? 'Request timed out. If this keeps happening, re-authenticate and retry.'
: (err?.message || 'Unknown error');
iframeLoader.classList.remove('hidden');
iframeLoader.innerHTML = `
<div class="text-center max-w-md mx-auto px-4">
<div class="text-red-600 text-lg font-semibold mb-2">Unable to load files</div>
<div class="text-gray-600 text-sm mb-4">${err?.message || 'Unknown error'}</div>
<div class="text-center max-w-md mx-auto px-4 space-y-3">
<div class="text-red-600 text-lg font-semibold">Unable to load files</div>
<div class="text-gray-600 text-sm">${friendly}</div>
<div class="flex items-center justify-center gap-2 flex-wrap">
<a href="${link}" target="_blank" rel="noopener" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">Open folder instead</a>
<button id="reauthBtn" class="px-4 py-2 bg-gray-200 hover:bg-gray-300 rounded-lg text-sm text-gray-800">Re-authenticate</button>
</div>
</div>
`;
const reauthBtn = document.getElementById('reauthBtn');
if (reauthBtn) {
reauthBtn.addEventListener('click', () => {
try { pb?.authStore?.clear?.(); } catch {}
try { localStorage.removeItem(GRAPH_TOKEN_KEY); } catch {}
try { localStorage.removeItem('pocketbase_auth'); } catch {}
try { sessionStorage.clear(); } catch {}
window.location.href = 'signin.html';
});
}
}
}
+12 -1
View File
@@ -49,6 +49,7 @@
const errorEl = document.getElementById('error');
const userInfo = document.getElementById('userInfo');
const continueBtn = document.getElementById('continueBtn');
const GRAPH_REAUTH_FLAG = 'graphReauthAttempted';
function goToIndex() {
window.location.href = 'index.html';
@@ -100,15 +101,25 @@
const email = authData?.meta?.rawUser?.email || authData?.record?.email || pb.authStore.model?.email || 'Not available';
const graphToken = extractGraphToken(authData);
console.log('Extracted Graph token:', graphToken ? graphToken.substring(0, 30) + '...' : 'NOT FOUND');
if (graphToken) localStorage.setItem(GRAPH_TOKEN_KEY, graphToken);
if (graphToken) {
localStorage.setItem(GRAPH_TOKEN_KEY, graphToken);
localStorage.removeItem(GRAPH_REAUTH_FLAG);
}
showAuthedUI(displayName, email);
}
(async () => {
const params = new URLSearchParams(window.location.search);
const reauth = params.get('reauth') === '1';
if (pb.authStore.isValid) {
try {
const authData = await pb.collection('users').authRefresh();
displayUserInfo(authData);
// If we came from a reauth flow and now have a token, go back automatically
const hasToken = localStorage.getItem(GRAPH_TOKEN_KEY);
if (reauth && hasToken) {
window.location.href = 'index.html';
}
} catch (err) {
pb.authStore.clear();
logout();