added restart always to service
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"folders": [
|
||||
{ "path": "/home/admin/Job-List" }
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
+170
-7
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user