added restart always to service

This commit is contained in:
2025-12-19 21:51:46 +00:00
parent cf57336c97
commit 56e1131622
4 changed files with 840 additions and 12 deletions
+6
View File
@@ -0,0 +1,6 @@
{
"folders": [
{ "path": "/home/admin/Job-List" }
],
"settings": {}
}
+170 -7
View File
@@ -29,13 +29,167 @@ app.get('/version', (c) => {
}
});
// Serve static files from the frontend directory
app.use('/*', serveStatic({ root: './frontend' }));
// --- Microsoft Graph helpers ---
const getGraphToken = (c?: any) => {
const headerToken = c?.req?.header ? c.req.header('x-graph-token') : '';
const envToken = process.env.GRAPH_TOKEN || process.env.MS_GRAPH_TOKEN || '';
const token = headerToken || envToken;
console.log('[getGraphToken] Header token:', headerToken ? headerToken.substring(0, 20) + '...' : 'NONE');
console.log('[getGraphToken] Env token:', envToken ? 'SET' : 'NOT SET');
console.log('[getGraphToken] Using:', token ? token.substring(0, 20) + '...' : 'NONE');
return token;
};
// Error handler
app.onError((err, c) => {
logLine('logs/error.log', `Unhandled error: ${err?.message || err}`);
return c.text('Internal Server Error', 500);
const b64Url = (input: string) =>
Buffer.from(input).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
type DriveItem = {
id: string;
name: string;
webUrl?: string;
size?: number;
lastModifiedDateTime?: string;
file?: { mimeType?: string };
folder?: { childCount?: number };
parentReference?: { path?: string; driveId?: string };
};
const fetchJson = async (url: string, token: string) => {
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Graph ${res.status}: ${text}`);
}
return res.json();
};
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 };
};
const listChildrenRecursive = async (
driveId: string,
itemId: string,
depth = 0,
maxDepth = 5,
token: string,
): Promise<DriveItem[]> => {
const out: DriveItem[] = [];
const data = await fetchJson(`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/children`, token);
for (const child of data.value || []) {
out.push(child as DriveItem);
if (child.folder && depth < maxDepth) {
const nested = await listChildrenRecursive(driveId, child.id, depth + 1, maxDepth, token);
out.push(...nested);
}
}
return out;
};
const searchWithinFolder = async (driveId: string, itemId: string, query: string, token: string): Promise<DriveItem[]> => {
const data = await fetchJson(
`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/search(q='${encodeURIComponent(query)}')`,
token,
);
return (data.value || []) as DriveItem[];
};
const filterByCategory = (items: DriveItem[], category?: string) => {
if (!category) return items;
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) => {
const n = lc(i.name);
if (category === 'contracts') return nameHas(n, contractWords);
if (category === 'plans') return nameHas(n, planWords);
return true;
});
};
// List/search job files via Graph using a shared folder link
app.get('/api/job-files', async (c) => {
try {
console.log('[job-files] Request received');
const token = getGraphToken(c);
if (!token) {
console.log('[job-files] No token found');
return c.json({ error: 'GRAPH_TOKEN missing' }, 500);
}
console.log('[job-files] Token found:', token.substring(0, 20) + '...');
const link = c.req.query('link');
const q = c.req.query('q') || '';
const category = c.req.query('category') || '';
if (!link) return c.json({ error: 'link required' }, 400);
console.log('[job-files] Link:', link);
const { driveId, itemId } = await resolveShareLink(link, token);
let items: DriveItem[] = [];
if (q) {
items = await searchWithinFolder(driveId, itemId, q, token);
} else {
// Walk subfolders up to depth 5
items = await listChildrenRecursive(driveId, itemId, 0, 5, token);
}
const filtered = filterByCategory(items, category);
const mapped = filtered.map((i) => ({
id: i.id,
name: i.name,
url: i.webUrl,
driveId: i.parentReference?.driveId || driveId,
size: i.size,
modified: i.lastModifiedDateTime,
contentType: i.file?.mimeType,
isFolder: Boolean(i.folder),
path: i.parentReference?.path || '',
}));
return c.json({ items: mapped, total: mapped.length, source: q ? 'search' : 'walk', driveId });
} catch (err) {
const message = (err as Error)?.message || String(err);
logLine('logs/error.log', `/api/job-files error: ${message}`);
return c.json({ error: message }, 500);
}
});
// Stream file content via Graph to avoid CSP issues for inline preview
app.get('/api/job-file-content', 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);
const res = await fetch(`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const text = await res.text();
return c.json({ error: `Graph ${res.status}: ${text}` }, res.status);
}
const headers: Record<string, string> = {};
const ct = res.headers.get('content-type');
const cd = res.headers.get('content-disposition');
if (ct) headers['Content-Type'] = ct;
if (cd) headers['Content-Disposition'] = cd;
return new Response(res.body, { status: 200, headers });
} catch (err) {
const message = (err as Error)?.message || String(err);
logLine('logs/error.log', `/api/job-file-content error: ${message}`);
return c.json({ error: message }, 500);
}
});
// Mutation logging endpoint
@@ -52,7 +206,16 @@ app.post('/log-change', async (c) => {
}
});
const PORT = Number(process.env.PORT || 3000);
// Serve static files from the frontend directory (must come after API routes)
app.use('/*', serveStatic({ root: './frontend' }));
// Error handler
app.onError((err, c) => {
logLine('logs/error.log', `Unhandled error: ${err?.message || err}`);
return c.text('Internal Server Error', 500);
});
const PORT = Number(process.env.PORT || 3003);
logLine('logs/server.log', `Starting frontend server on port ${PORT}`);
+643 -5
View File
@@ -8,6 +8,7 @@
<script>
if (window.tailwind) window.tailwind.config = { corePlugins: { preflight: true } }
</script><script src="https://cdn.jsdelivr.net/npm/pocketbase@0.26.5/dist/pocketbase.umd.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script src="auth.js"></script>
</head>
<body class="font-sans bg-gray-100 m-0 text-gray-900 overflow-hidden touch-pan-y h-screen flex flex-col">
@@ -132,6 +133,61 @@
</div>
</div>
<!-- Iframe Viewer Modal -->
<div id="iframeViewerModal" class="hidden fixed inset-0 bg-black z-[1000] flex flex-col">
<div class="bg-gray-900 text-white px-3 py-3 flex items-center gap-3 flex-shrink-0 shadow-lg">
<button id="iframeBackBtn" aria-label="Back to jobs" class="flex items-center justify-center w-10 h-10 bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</button>
<div class="flex-1 min-w-0 text-center">
<div id="iframeTitle" class="text-sm font-medium truncate px-2">Loading...</div>
</div>
<button id="iframeCloseBtn" aria-label="Close" class="flex items-center justify-center w-10 h-10 bg-red-600 hover:bg-red-700 rounded-lg transition-colors shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</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 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>
<button id="pdfZoomReset" class="px-2 py-1 bg-gray-200 hover:bg-gray-300 rounded">45%</button>
<button id="pdfZoomIn" class="px-2 py-1 bg-gray-200 hover:bg-gray-300 rounded">+</button>
<span id="pdfZoomLabel" class="text-sm text-gray-700 ml-1">45%</span>
</div>
<div class="flex items-center gap-2 ml-4">
<button id="pdfPrev" class="px-2 py-1 bg-gray-200 hover:bg-gray-300 rounded"></button>
<button id="pdfNext" class="px-2 py-1 bg-gray-200 hover:bg-gray-300 rounded"></button>
<span class="text-sm text-gray-700">Page <span id="pdfPageNum">1</span> / <span id="pdfPageCount">1</span></span>
</div>
<span class="flex-1"></span>
<div class="flex items-center gap-2 ml-auto">
<button id="pdfCloseAll" aria-label="Close preview" class="flex items-center justify-center w-9 h-9 bg-red-500 hover:bg-red-600 rounded text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<div class="flex-1 overflow-hidden bg-gray-100 flex items-start justify-center">
<canvas id="pdfCanvas" class="bg-white shadow-sm mt-3 mb-6 cursor-grab touch-none"></canvas>
</div>
</div>
<iframe id="iframeViewer" class="w-full h-full border-none" sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-downloads allow-modals"></iframe>
<div id="iframeLoader" class="absolute inset-0 bg-white flex items-center justify-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="text-gray-600 text-lg">Loading content...</div>
</div>
</div>
</div>
</div>
<script>
(async function() {
const { pb, authHeaders, ensureAuth, getDisplayName, getEmail } = window.Auth;
@@ -143,6 +199,24 @@
const headers = { ...(options.headers||{}), ...authHeaders() };
return fetch(url + `&_ts=${Date.now()}`, { ...options, headers });
};
const GRAPH_TOKEN_KEY = 'graphAccessToken';
const getGraphToken = () => {
// Prefer runtime token already present in PB model/meta, then localStorage
const model = pb?.authStore?.model || {};
const meta = model?.meta || {};
const token = (
model.graphAccessToken ||
model.graph_token ||
model.graphToken ||
meta.graphAccessToken ||
meta.graph_token ||
meta.graphToken ||
localStorage.getItem(GRAPH_TOKEN_KEY) ||
''
);
console.log('[getGraphToken] Found token:', token ? token.substring(0, 30) + '...' : 'NONE');
return token;
};
const PB_COLLECTION_URL = "https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records";
const NOTES_COLLECTION_URL = "https://pocketbase.ccllc.pro/api/collections/notes/records";
const PREFS_COLLECTION_URL = "https://pocketbase.ccllc.pro/api/collections/user_preferences/records";
@@ -250,7 +324,7 @@
for(const job of jobs){
const card=document.createElement('div'); card.className='bg-white rounded-lg p-2.5 border border-blue-100 shadow-sm cursor-pointer relative flex flex-col justify-center gap-1';
card.innerHTML=`
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Job #:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(job.Job_Number||'')}</div></div>
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Job#</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(job.Job_Number||'')}</div></div>
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Job Name:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(job.Job_Full_Name||'')}</div></div>
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Contact:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(job.Contact_Person||'')}</div></div>
`;
@@ -328,7 +402,7 @@
for(const job of firstChunk){
const card=document.createElement('div'); card.className='bg-white rounded-lg p-2.5 border border-blue-100 shadow-sm cursor-pointer relative flex flex-col justify-center gap-1';
card.innerHTML=`
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Job #:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(job.Job_Number||'')}</div></div>
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Job#</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(job.Job_Number||'')}</div></div>
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Job Name:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(job.Job_Full_Name||'')}</div></div>
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Contact:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(job.Contact_Person||'')}</div></div>
`;
@@ -347,7 +421,7 @@
for(const job of remaining){
const card=document.createElement('div'); card.className='bg-white rounded-lg p-2.5 border border-blue-100 shadow-sm cursor-pointer relative flex flex-col justify-center gap-1';
card.innerHTML=`
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Job #:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(job.Job_Number||'')}</div></div>
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Job#</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(job.Job_Number||'')}</div></div>
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Job Name:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(job.Job_Full_Name||'')}</div></div>
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Contact:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(job.Contact_Person||'')}</div></div>
`;
@@ -470,7 +544,7 @@
function openDetail(job){
currentJob = 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","Job_Division"];
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;
while(i < fields.length){
const f = fields[i];
@@ -546,6 +620,24 @@
i += 2; // Skip Job_Type in next iteration
continue;
}
if(f === 'Manager'){
const row = document.createElement('div'); row.className='pb-2 border-b border-gray-100 flex gap-4';
const col1 = document.createElement('div'); col1.className='flex-1';
const label1 = document.createElement('div'); label1.className='text-xs font-bold text-gray-700 uppercase tracking-wide mb-1'; label1.textContent='Manager';
const val1 = document.createElement('div'); val1.className='text-sm text-gray-900'; val1.textContent = job['Manager'] || '—';
col1.appendChild(label1); col1.appendChild(val1);
const col2 = document.createElement('div'); col2.className='flex-1';
const label2 = document.createElement('div'); label2.className='text-xs font-bold text-gray-700 uppercase tracking-wide mb-1'; label2.textContent='Job Division';
const val2 = document.createElement('div'); val2.className='text-sm text-gray-900'; val2.textContent = job['Job_Division'] || '—';
col2.appendChild(label2); col2.appendChild(val2);
row.appendChild(col1); row.appendChild(col2); detailTable.appendChild(row);
i += 1; // Manager handled with Job_Division
continue;
}
// Regular single field
const value = job[f] || '';
@@ -615,7 +707,7 @@
jobFolderBtn.textContent='Show Job Folder';
jobFolderBtn.disabled=false;
jobFolderBtn.className='w-full py-2.5 px-3 rounded-lg border-none text-white cursor-pointer text-[17px] font-bold mb-2.5 transition-colors duration-200 bg-blue-600 hover:bg-blue-700';
jobFolderBtn.onclick=()=>window.open(url.href, '_blank', 'noopener,noreferrer');
jobFolderBtn.onclick=()=>openJobFiles(job);
}catch(e){
jobFolderBtn.textContent='No Folder Available';
jobFolderBtn.disabled=true;
@@ -760,6 +852,552 @@
});
document.getElementById('btnClearNote').addEventListener('click',()=>newNote.innerHTML='');
// --- Iframe Viewer Controls ---
const iframeViewerModal = document.getElementById('iframeViewerModal');
const iframeViewer = document.getElementById('iframeViewer');
const iframeLoader = document.getElementById('iframeLoader');
const iframeTitle = document.getElementById('iframeTitle');
const iframeBackBtn = document.getElementById('iframeBackBtn');
const iframeCloseBtn = document.getElementById('iframeCloseBtn');
const fileListContainer = document.getElementById('fileListContainer');
const categoryKeywords = {
contracts: ['contract', 'estimate', 'proposal', 'bid', 'award', 'agreement', 'sow'],
plans: ['plan', 'drawing', 'dwg', 'pdf', 'sheet', 'layout'],
submittals: ['submittal', 'submittals', 'submit'],
};
const DEFAULT_PDF_SCALE = 0.45;
const MIN_PDF_SCALE = 0.3;
const MAX_PDF_SCALE = 3.0;
const fileListState = { items: [], filter: '', jobNumber: '' };
let currentPreviewUrl = null;
let currentPdf = { doc: null, page: 1, pages: 1, scale: DEFAULT_PDF_SCALE };
let pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
const pdfViewer = document.getElementById('pdfViewer');
const pdfCanvas = document.getElementById('pdfCanvas');
const pdfPageNum = document.getElementById('pdfPageNum');
const pdfPageCount = document.getElementById('pdfPageCount');
const pdfPrev = document.getElementById('pdfPrev');
const pdfNext = document.getElementById('pdfNext');
const pdfZoomIn = document.getElementById('pdfZoomIn');
const pdfZoomOut = document.getElementById('pdfZoomOut');
const pdfZoomReset = document.getElementById('pdfZoomReset');
const pdfZoomLabel = document.getElementById('pdfZoomLabel');
const pdfCloseAll = document.getElementById('pdfCloseAll');
const categorize = (name = '') => {
const n = name.toLowerCase();
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';
return 'other';
};
const formatSize = (bytes) => {
if (!bytes && bytes !== 0) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
};
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 '📎';
};
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');
};
function openFileInViewer(url, name) {
const jobPrefix = fileListState.jobNumber ? `Job# ${fileListState.jobNumber} - ` : '';
iframeViewerModal.classList.remove('hidden');
iframeTitle.textContent = `${jobPrefix}${name || 'File Preview'}`;
fileListContainer.classList.add('hidden');
pdfViewer.classList.add('hidden');
iframeViewer.classList.remove('hidden');
iframeLoader.classList.remove('hidden');
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>
`;
iframeViewer.src = 'about:blank';
setTimeout(() => {
iframeViewer.src = url;
}, 50);
}
window.openFileInViewer = openFileInViewer;
async function renderPdfPage() {
const { doc, page, scale } = currentPdf;
if (!doc) return;
const pageObj = await doc.getPage(page);
const viewport = pageObj.getViewport({ scale });
const canvas = pdfCanvas;
const context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
const renderContext = { canvasContext: context, viewport };
await pageObj.render(renderContext).promise;
pdfPageNum.textContent = page.toString();
pdfPageCount.textContent = doc.numPages.toString();
// re-apply pan transform after render
pdfCanvas.style.transform = `translate(${pdfPan.x}px, ${pdfPan.y}px)`;
pdfZoomLabel.textContent = `${Math.round(currentPdf.scale * 100)}%`;
pdfZoomReset.textContent = `${Math.round(currentPdf.scale * 100)}%`;
}
function showPdfViewer() {
iframeViewer.classList.add('hidden');
pdfViewer.classList.remove('hidden');
iframeLoader.classList.add('hidden');
}
pdfPrev.addEventListener('click', async () => {
if (!currentPdf.doc) return;
if (currentPdf.page <= 1) return;
currentPdf.page -= 1;
await renderPdfPage();
});
pdfNext.addEventListener('click', async () => {
if (!currentPdf.doc) return;
if (currentPdf.page >= currentPdf.pages) return;
currentPdf.page += 1;
await renderPdfPage();
});
pdfZoomIn.addEventListener('click', async () => {
if (!currentPdf.doc) return;
currentPdf.scale = Math.min(currentPdf.scale + 0.1, MAX_PDF_SCALE);
await renderPdfPage();
});
pdfZoomOut.addEventListener('click', async () => {
if (!currentPdf.doc) 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;
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
pdfCanvas.style.transform = 'translate(0px, 0px)';
await renderPdfPage();
});
// Drag-to-pan on PDF canvas + pinch-to-zoom
pdfCanvas.style.touchAction = 'none';
const applyPan = () => {
pdfCanvas.style.transform = `translate(${pdfPan.x}px, ${pdfPan.y}px)`;
};
const activePointers = new Map();
let pinchStartDistance = null;
let pinchStartScale = null;
const distanceBetween = () => {
if (activePointers.size < 2) return null;
const pts = Array.from(activePointers.values());
const a = pts[0];
const b = pts[1];
const dx = a.x - b.x;
const dy = a.y - b.y;
return Math.hypot(dx, dy);
};
pdfCanvas.addEventListener('wheel', async (e) => {
if (!currentPdf.doc) return;
e.preventDefault();
const delta = e.deltaY;
const step = 0.1;
if (delta < 0) {
currentPdf.scale = Math.min(currentPdf.scale + step, MAX_PDF_SCALE);
} else {
currentPdf.scale = Math.max(currentPdf.scale - step, MIN_PDF_SCALE);
}
await renderPdfPage();
}, { passive: false });
// Toolbar close (X) goes back to file list
pdfCloseAll.addEventListener('click', () => {
pdfViewer.classList.add('hidden');
iframeLoader.classList.add('hidden');
iframeViewer.classList.add('hidden');
fileListContainer.classList.remove('hidden');
});
pdfCanvas.addEventListener('pointerdown', (e) => {
if (!currentPdf.doc) return;
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (activePointers.size === 2) {
pinchStartDistance = distanceBetween();
pinchStartScale = currentPdf.scale;
pdfPan.dragging = false;
} else if (activePointers.size === 1) {
pdfPan.dragging = true;
pdfPan.startX = e.clientX;
pdfPan.startY = e.clientY;
pdfPan.baseX = pdfPan.x;
pdfPan.baseY = pdfPan.y;
pdfCanvas.style.cursor = 'grabbing';
}
pdfCanvas.setPointerCapture(e.pointerId);
});
pdfCanvas.addEventListener('pointermove', (e) => {
if (!currentPdf.doc) return;
if (activePointers.has(e.pointerId)) {
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
}
if (activePointers.size >= 2 && pinchStartDistance) {
const dist = distanceBetween();
if (dist) {
const scale = pinchStartScale * (dist / pinchStartDistance);
currentPdf.scale = Math.min(Math.max(scale, MIN_PDF_SCALE), MAX_PDF_SCALE);
renderPdfPage();
}
return;
}
if (!pdfPan.dragging) return;
const dx = e.clientX - pdfPan.startX;
const dy = e.clientY - pdfPan.startY;
pdfPan.x = pdfPan.baseX + dx;
pdfPan.y = pdfPan.baseY + dy;
applyPan();
});
const endPan = (e) => {
if (activePointers.has(e.pointerId)) {
activePointers.delete(e.pointerId);
}
if (activePointers.size < 2) {
pinchStartDistance = null;
pinchStartScale = null;
}
if (activePointers.size === 0) {
pdfPan.dragging = false;
pdfCanvas.style.cursor = 'grab';
}
if (e && e.pointerId) {
try { pdfCanvas.releasePointerCapture(e.pointerId); } catch {}
}
};
pdfCanvas.addEventListener('pointerup', endPan);
pdfCanvas.addEventListener('pointercancel', endPan);
async function openFilePreview(driveId, itemId, name, contentType, fallbackUrl) {
// If no drive/item info, just open the URL directly
if (!driveId || !itemId) {
openFileInViewer(fallbackUrl, name);
return;
}
const graphToken = getGraphToken();
if (!graphToken) {
console.warn('Graph token missing; using fallback URL');
openFileInViewer(fallbackUrl, name);
return;
}
iframeViewerModal.classList.remove('hidden');
const jobPrefix = fileListState.jobNumber ? `Job# ${fileListState.jobNumber} - ` : '';
iframeTitle.textContent = `${jobPrefix}${name || 'File Preview'}`;
fileListContainer.classList.add('hidden');
iframeViewer.classList.add('hidden');
pdfViewer.classList.add('hidden');
iframeLoader.classList.remove('hidden');
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>
`;
// Clear prior blob URL if any
if (currentPreviewUrl) {
URL.revokeObjectURL(currentPreviewUrl);
currentPreviewUrl = null;
}
try {
const res = await fetch(`/api/job-file-content?driveId=${encodeURIComponent(driveId)}&itemId=${encodeURIComponent(itemId)}`, {
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');
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 };
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
pdfCanvas.style.transform = 'translate(0px, 0px)';
await renderPdfPage();
showPdfViewer();
} else {
// fallback to iframe blob view (images, etc.)
iframeViewer.src = 'about:blank';
setTimeout(() => {
iframeViewer.src = currentPreviewUrl;
iframeViewer.classList.remove('hidden');
iframeLoader.classList.add('hidden');
}, 10);
}
} catch (err) {
console.error('Preview load error', err);
// Fallback to direct open
openFileInViewer(fallbackUrl, name);
}
}
window.openFilePreview = openFilePreview;
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: [] };
filtered.forEach((f) => groups[categorize(f.name)].push(f));
const renderGroup = (title, list) => {
if (!list.length) return '';
const rows = list
.map(
(f) => {
const icon = getFileIcon(f.name, f.contentType);
const canPreview = canPreviewInline(f.name, f.contentType);
const openBtn = canPreview
? `<button class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm" onclick="openFilePreview('${f.driveId||''}', '${f.id||''}', '${f.name.replace(/'/g, "\\'")}', '${f.contentType||''}', '${f.url}')">Preview</button>`
: `<a class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm" href="${f.url}" target="_blank" rel="noopener">Open</a>`;
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>
<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">
${formatSize(f.size) || '—'} · ${f.modified ? new Date(f.modified).toLocaleDateString() : ''}
</div>
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
${openBtn}
</div>
</div>
`;
}
)
.join('');
return `
<div class="mb-5">
<div class="text-sm font-semibold text-gray-700 mb-2">${title} (${list.length})</div>
<div class="divide-y divide-gray-100 border border-gray-100 rounded-lg bg-white shadow-sm">${rows}</div>
</div>`;
};
const groupsMarkup = `
${renderGroup('Contracts / Estimates', groups.contracts)}
${renderGroup('Submittals', groups.submittals)}
${renderGroup('Plans', groups.plans)}
${renderGroup('Other', groups.other)}
${!filtered.length ? '<div class="text-gray-500 text-sm">No files found.</div>' : ''}
`;
let groupsHost = fileListContainer.querySelector('.file-groups');
if (!groupsHost) {
fileListContainer.innerHTML = `
<div class="flex items-center gap-2 mb-3 file-search-row">
<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>
`;
groupsHost = fileListContainer.querySelector('.file-groups');
const searchInput = document.getElementById('fileSearchInput');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
fileListState.filter = e.target.value || '';
renderFileGroups(folderLink);
});
}
} else {
const searchInput = document.getElementById('fileSearchInput');
if (searchInput && searchInput.value !== fileListState.filter) {
const wasFocused = document.activeElement === searchInput;
const caret = searchInput.selectionStart || 0;
searchInput.value = fileListState.filter;
if (wasFocused) searchInput.setSelectionRange(caret, caret);
}
const folderAnchor = fileListContainer.querySelector('[data-folder-link]');
if (folderAnchor) folderAnchor.href = folderLink;
}
if (groupsHost) groupsHost.innerHTML = groupsMarkup;
}
async function openJobFiles(job) {
const link = job.Job_Folder_Link;
if (!link) {
alert('No folder link available for this job.');
return;
}
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');
iframeLoader.classList.remove('hidden');
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 files...</div>
</div>
`;
try {
const resp = await fetch(`/api/job-files?link=${encodeURIComponent(link)}`, { headers });
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 => ({
...f,
driveId: f.driveId || rootDriveId
}));
iframeLoader.classList.add('hidden');
renderFileGroups(link);
} catch (err) {
console.error('job-files error', err);
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>
<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>
</div>
`;
}
}
function openIframeViewer(url, title) {
// Detect SharePoint URLs and skip iframe attempt
const isSharePoint = url.includes('sharepoint.com') || url.includes('.sharepoint.');
// If it's SharePoint, skip iframe entirely and open in new tab
if (isSharePoint) {
window.open(url, '_blank');
return;
}
iframeTitle.textContent = title;
iframeLoader.classList.remove('hidden');
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 content...</div>
</div>
`;
iframeViewer.src = '';
iframeViewerModal.classList.remove('hidden');
// Small delay to ensure modal is visible before loading
setTimeout(() => {
iframeViewer.src = url;
}, 50);
}
function closeIframeViewer() {
iframeViewerModal.classList.add('hidden');
fileListContainer.classList.add('hidden');
iframeViewer.classList.remove('hidden');
iframeLoader.classList.add('hidden');
pdfViewer.classList.add('hidden');
currentPdf = { doc: null, page: 1, pages: 1, scale: DEFAULT_PDF_SCALE };
if (currentPreviewUrl) {
URL.revokeObjectURL(currentPreviewUrl);
currentPreviewUrl = null;
}
// Clear iframe src to stop any loading/video/audio and prevent stacking
setTimeout(() => {
iframeViewer.src = 'about:blank';
}, 300);
}
// Hide loader when iframe loads
iframeViewer.addEventListener('load', () => {
// Check if iframe actually loaded content or is blocked
try {
const iframeDoc = iframeViewer.contentDocument || iframeViewer.contentWindow.document;
const currentSrc = iframeViewer.src;
// If iframe loaded but is empty or blocked, show fallback
if (currentSrc && currentSrc !== 'about:blank') {
iframeLoader.classList.add('hidden');
// Try to get title from iframe (will only work for same-origin)
if (iframeDoc && iframeDoc.title) {
iframeTitle.textContent = iframeDoc.title;
}
}
} catch(e) {
// Cross-origin or blocked - if we have a src, it might still work
if (iframeViewer.src && iframeViewer.src !== 'about:blank') {
iframeLoader.classList.add('hidden');
}
}
});
// Show loader if iframe errors
iframeViewer.addEventListener('error', () => {
const target = iframeViewer.src;
closeIframeViewer();
if (target && target !== 'about:blank') window.open(target, '_blank');
});
// Listen for CSP violations (fallback to new tab)
document.addEventListener('securitypolicyviolation', (e) => {
if (e.violatedDirective.includes('frame-ancestors') && !iframeViewerModal.classList.contains('hidden')) {
const target = iframeViewer.src;
closeIframeViewer();
if (target && target !== 'about:blank') window.open(target, '_blank');
}
});
iframeBackBtn.addEventListener('click', closeIframeViewer);
iframeCloseBtn.addEventListener('click', () => { closeIframeViewer(); if (typeof closeModal === 'function') closeModal(); });
// Close on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !iframeViewerModal.classList.contains('hidden')) {
closeIframeViewer();
}
});
// --- Init ---
const prefs = await loadUserPreferences();
console.log('Init: loaded prefs=', prefs);
+21
View File
@@ -30,6 +30,21 @@
<script>
const { pb, getDisplayName } = window.Auth;
const GRAPH_TOKEN_KEY = 'graphAccessToken';
const extractGraphToken = (authData) => {
return (
authData?.meta?.graphAccessToken ||
authData?.meta?.graph_token ||
authData?.meta?.graphToken ||
authData?.meta?.accessToken ||
authData?.meta?.access_token ||
authData?.meta?.token ||
authData?.meta?.rawToken ||
authData?.meta?.authData?.access_token ||
''
);
};
const loginBtn = document.getElementById('loginBtn');
const errorEl = document.getElementById('error');
const userInfo = document.getElementById('userInfo');
@@ -70,6 +85,7 @@
function logout() {
pb.authStore.clear();
localStorage.removeItem(GRAPH_TOKEN_KEY);
loginBtn.classList.remove('hidden');
userInfo.classList.add('hidden');
continueBtn.classList.add('hidden');
@@ -78,8 +94,13 @@
}
function displayUserInfo(authData) {
console.log('Auth response:', authData);
console.log('Auth meta:', authData?.meta);
const displayName = getDisplayName();
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);
showAuthedUI(displayName, email);
}