Compare commits

...

3 Commits

5 changed files with 544 additions and 55 deletions
+101
View File
@@ -0,0 +1,101 @@
import Redis from 'ioredis';
// Create Redis client
const redis = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: Number(process.env.REDIS_PORT || 6379),
password: process.env.REDIS_PASSWORD,
retryStrategy(times) {
const delay = Math.min(times * 50, 2000);
return delay;
},
maxRetriesPerRequest: 3,
lazyConnect: true,
});
// Handle connection events
redis.on('connect', () => {
console.log('✓ Redis connected');
});
redis.on('error', (err) => {
console.error('Redis error:', err.message);
});
redis.on('close', () => {
console.log('Redis connection closed');
});
// Connect to Redis
(async () => {
try {
await redis.connect();
} catch (err) {
console.error('Failed to connect to Redis:', err);
}
})();
/**
* Get a value from cache
*/
export async function getCache(key: string): Promise<any | null> {
try {
const value = await redis.get(key);
if (!value) return null;
return JSON.parse(value);
} catch (err) {
console.error(`Cache get error for key "${key}":`, err);
return null;
}
}
/**
* Set a value in cache with TTL in seconds
*/
export async function setCache(key: string, value: any, ttl: number = 300): Promise<boolean> {
try {
const serialized = JSON.stringify(value);
await redis.setex(key, ttl, serialized);
return true;
} catch (err) {
console.error(`Cache set error for key "${key}":`, err);
return false;
}
}
/**
* Delete a key from cache
*/
export async function deleteCache(key: string): Promise<boolean> {
try {
await redis.del(key);
return true;
} catch (err) {
console.error(`Cache delete error for key "${key}":`, err);
return false;
}
}
/**
* Clear all cache keys matching a pattern
*/
export async function clearCachePattern(pattern: string): Promise<number> {
try {
const keys = await redis.keys(pattern);
if (keys.length === 0) return 0;
await redis.del(...keys);
return keys.length;
} catch (err) {
console.error(`Cache clear error for pattern "${pattern}":`, err);
return 0;
}
}
/**
* Check if Redis is connected
*/
export function isRedisConnected(): boolean {
return redis.status === 'ready';
}
export default redis;
+180 -8
View File
@@ -3,6 +3,7 @@ import { serveStatic } from 'hono/bun';
import fs from 'fs';
// @ts-ignore Bun project may not have dotenv types configured
import { config } from 'dotenv';
import { getCache, setCache, isRedisConnected } from './redis-cache';
config();
@@ -29,6 +30,119 @@ app.get('/version', (c) => {
}
});
// Jobs endpoint with Redis caching
app.get('/api/jobs', async (c) => {
const page = parseInt(c.req.query('page') || '1');
const perPage = parseInt(c.req.query('perPage') || '50');
const sort = c.req.query('sort') || '-Job_Number';
const cacheKey = `jobs:page:${page}:perPage:${perPage}:sort:${sort}`;
const ttl = 300; // 5 minutes cache
try {
// Try to get from cache first
if (isRedisConnected()) {
const cached = await getCache(cacheKey);
if (cached) {
logLine('logs/server.log', `Cache HIT for ${cacheKey}`);
return c.json(cached);
}
logLine('logs/server.log', `Cache MISS for ${cacheKey}`);
}
// Fetch from PocketBase
const pbUrl = `https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records?page=${page}&perPage=${perPage}&sort=${sort}`;
const authHeader = c.req.header('Authorization') || '';
const response = await fetch(pbUrl, {
headers: authHeader ? { Authorization: authHeader } : {}
});
if (!response.ok) {
throw new Error(`PocketBase returned ${response.status}`);
}
const data = await response.json();
// Cache the result
if (isRedisConnected()) {
await setCache(cacheKey, data, ttl);
logLine('logs/server.log', `Cached ${cacheKey} for ${ttl}s`);
}
return c.json(data);
} catch (err) {
logLine('logs/error.log', `jobs endpoint error: ${(err as Error)?.message || String(err)}`);
return c.json({ error: 'Failed to fetch jobs' }, 500);
}
});
// Fast endpoint: returns all jobs in a single cached request
app.get('/api/jobs-all', async (c) => {
const cacheKey = 'jobs:all';
const ttl = 300; // 5 minutes cache
try {
// Try to get from cache first
if (isRedisConnected()) {
const cached = await getCache(cacheKey);
if (cached) {
logLine('logs/server.log', `Cache HIT for ${cacheKey} (${cached.items?.length || 0} jobs)`);
return c.json(cached);
}
logLine('logs/server.log', `Cache MISS for ${cacheKey} - fetching all jobs`);
}
const authHeader = c.req.header('Authorization') || '';
const allItems: any[] = [];
let page = 1;
const perPage = 500; // Large page size
// Fetch all pages from PocketBase
while (true) {
const pbUrl = `https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records?page=${page}&perPage=${perPage}&sort=-Job_Number`;
const response = await fetch(pbUrl, {
headers: authHeader ? { Authorization: authHeader } : {}
});
if (!response.ok) {
throw new Error(`PocketBase returned ${response.status}`);
}
const data = await response.json();
const items = data.items || [];
if (items.length === 0) break;
allItems.push(...items);
if (allItems.length >= (data.totalItems || 0)) break;
page++;
}
const result = {
page: 1,
perPage: allItems.length,
totalItems: allItems.length,
totalPages: 1,
items: allItems
};
// Cache the result
if (isRedisConnected()) {
await setCache(cacheKey, result, ttl);
logLine('logs/server.log', `Cached ${cacheKey} with ${allItems.length} jobs for ${ttl}s`);
}
return c.json(result);
} catch (err) {
logLine('logs/error.log', `jobs-all endpoint error: ${(err as Error)?.message || String(err)}`);
return c.json({ error: 'Failed to fetch jobs' }, 500);
}
});
// --- Microsoft Graph helpers ---
const getGraphToken = (c?: any) => {
const headerToken = c?.req?.header ? c.req.header('x-graph-token') : '';
@@ -71,9 +185,56 @@ const fetchJson = async (url: string, token: string) => {
};
const resolveShareLink = async (link: string, token: string) => {
const encoded = `u!${b64Url(link)}`;
const data = await fetchJson(`https://graph.microsoft.com/v1.0/shares/${encoded}/driveItem`, token);
return { driveId: data.parentReference?.driveId as string, itemId: data.id as string };
// Decode the link in case it comes pre-encoded from the database
const decodedLink = decodeURIComponent(link);
console.log('[resolveShareLink] Decoded link:', decodedLink);
// Check if this is a short sharing link (/:f:/ or /:b:/) or a direct document library URL
if (decodedLink.includes('/:f:/') || decodedLink.includes('/:b:/')) {
// Short sharing link - use shares API
const encoded = `u!${b64Url(decodedLink)}`;
console.log('[resolveShareLink] Using shares API, encoded:', encoded);
const data = await fetchJson(`https://graph.microsoft.com/v1.0/shares/${encoded}/driveItem`, token);
return { driveId: data.parentReference?.driveId as string, itemId: data.id as string };
} else {
// Direct document library URL - parse it and use drive API
// Example: https://czflex.sharepoint.com/sites/Team/Shared Documents/Forms/AllItems.aspx?RootFolder=/sites/Team/Shared Documents/General/...
const url = new URL(decodedLink);
const pathMatch = url.hostname.match(/^([^.]+)\.sharepoint\.com$/);
if (!pathMatch) throw new Error('Invalid SharePoint URL');
const hostname = pathMatch[1]; // e.g., 'czflex'
const sitePath = url.pathname.split('/Shared')[0]; // e.g., /sites/Team
const rootFolderParam = url.searchParams.get('RootFolder');
if (!rootFolderParam) throw new Error('RootFolder parameter missing');
// Extract the folder path relative to the document library
// RootFolder format: /sites/Team/Shared Documents/General/Operations [Server]/...
// We need: General/Operations [Server]/...
const folderPath = rootFolderParam.split('/Shared Documents/')[1];
if (!folderPath) throw new Error('Could not parse folder path');
console.log('[resolveShareLink] Hostname:', hostname);
console.log('[resolveShareLink] Site path:', sitePath);
console.log('[resolveShareLink] Folder path:', folderPath);
// Get the site ID first using hostname:sitePath format
const siteData = await fetchJson(`https://graph.microsoft.com/v1.0/sites/${hostname}.sharepoint.com:${sitePath}`, token);
const siteId = siteData.id;
// Get the default document library drive
const driveData = await fetchJson(`https://graph.microsoft.com/v1.0/sites/${siteId}/drive`, token);
const driveId = driveData.id;
// Get the folder item by path
const itemData = await fetchJson(
`https://graph.microsoft.com/v1.0/drives/${driveId}/root:/${folderPath}`,
token
);
console.log('[resolveShareLink] Resolved to driveId:', driveId, 'itemId:', itemData.id);
return { driveId, itemId: itemData.id };
}
};
const listChildrenRecursive = async (
@@ -111,9 +272,12 @@ const filterByCategory = (items: DriveItem[], category?: string, pdfOnly: boolea
if (i.folder) return false;
const name = i.name.toLowerCase();
const mimeType = i.file?.mimeType?.toLowerCase() || '';
// Include PDFs and Word documents
// Include PDFs, Word documents, and images
return name.endsWith('.pdf') || mimeType.includes('pdf') ||
name.endsWith('.doc') || name.endsWith('.docx') || mimeType.includes('word');
name.endsWith('.doc') || name.endsWith('.docx') || mimeType.includes('word') ||
name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.png') ||
name.endsWith('.gif') || name.endsWith('.bmp') || name.endsWith('.tiff') ||
name.endsWith('.webp') || mimeType.includes('image');
});
}
@@ -160,6 +324,7 @@ app.get('/api/job-files', async (c) => {
// Filter to show only PDFs by default (can be disabled with pdfOnly=false query param)
const pdfOnly = c.req.query('pdfOnly') !== 'false';
const filtered = filterByCategory(items, category, pdfOnly);
console.log(`[job-files] Found ${items.length} total items, ${filtered.length} after filtering`);
const mapped = filtered.map((i) => ({
id: i.id,
@@ -177,6 +342,7 @@ app.get('/api/job-files', async (c) => {
} catch (err) {
const message = (err as Error)?.message || String(err);
const status = (err as any)?.status || 500;
console.error('[job-files] ERROR:', message);
logLine('logs/error.log', `/api/job-files error: ${message}`);
return c.json({ error: message }, status);
}
@@ -263,6 +429,12 @@ app.post('/log-change', async (c) => {
});
// Serve static files from the frontend directory (must come after API routes)
// Add cache headers for static assets
app.use('/assets/*', async (c, next) => {
await next();
// Cache assets for 1 year
c.header('Cache-Control', 'public, max-age=31536000, immutable');
});
app.use('/*', serveStatic({ root: './frontend' }));
// Error handler
@@ -271,10 +443,10 @@ app.onError((err, c) => {
return c.text('Internal Server Error', 500);
});
// Default to 3003; can be overridden by Environment=PORT
const PORT = Number(process.env.PORT || 3003);
// Default to 3004 for Redis branch; can be overridden by Environment=PORT
const PORT = Number(process.env.PORT || 3004);
logLine('logs/server.log', `Starting frontend server on port ${PORT}`);
logLine('logs/server.log', `Starting frontend server (Redis version) on port ${PORT}`);
// Graceful shutdown logging
const shutdown = (signal: string) => {
+45 -4
View File
@@ -7,6 +7,7 @@
"dependencies": {
"dotenv": "^17.2.3",
"hono": "^4.10.8",
"ioredis": "^5.8.2",
"pocketbase": "^0.26.5",
"tailwind": "^4.0.0",
},
@@ -24,6 +25,8 @@
"packages": {
"@babel/runtime": ["@babel/runtime@7.3.4", "", { "dependencies": { "regenerator-runtime": "^0.12.0" } }, "sha512-IvfvnMdSaLBateu0jfsYIpZTxAc2cKEXEMiezGGN75QcBcecDUKd3PgLAncT0oOgxKy8dd8hrJKj9MfzgfZd6g=="],
"@ioredis/commands": ["@ioredis/commands@1.4.0", "", {}, "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ=="],
"@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="],
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
@@ -92,6 +95,8 @@
"chalk": ["chalk@2.4.1", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ=="],
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
"color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
@@ -130,12 +135,14 @@
"datasette": ["datasette@1.0.1", "", { "dependencies": { "comparejs": "1.0.0", "eventemitter2": "5.0.1", "lodash": "4.17.5" } }, "sha512-aJdlCBToEJUP4M57r67r4V6tltwGKa3qetnjpBtXYIlqbX9tM9jsoDMxb4xd9AGjpp3282oHRmqI5Z8TVAU0Mg=="],
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"depd": ["depd@1.1.2", "", {}, "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ=="],
"destroy": ["destroy@1.0.4", "", {}, "sha512-3NdhDuEXnfun/z7x9GOElY49LoqVHoGScmOKwmxhsS8N5Y+Z8KyPPDnaSzqWgYt/ji4mqwfTS34Htrk0zPIXVg=="],
@@ -246,6 +253,8 @@
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"ioredis": ["ioredis@5.8.2", "", { "dependencies": { "@ioredis/commands": "1.4.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
@@ -310,8 +319,12 @@
"lodash": ["lodash@4.17.11", "", {}, "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg=="],
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
"lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="],
@@ -344,7 +357,7 @@
"morgan": ["morgan@1.9.1", "", { "dependencies": { "basic-auth": "~2.0.0", "debug": "2.6.9", "depd": "~1.1.2", "on-finished": "~2.3.0", "on-headers": "~1.0.1" } }, "sha512-HQStPIV4y3afTiCYVxirakhlCfGkI161c76kKFca7Fk1JusM//Qeo1ej2XaMniiNeaZklMVrh3vTtIzpzwbpmA=="],
"ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
@@ -404,6 +417,10 @@
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
"regenerator-runtime": ["regenerator-runtime@0.12.1", "", {}, "sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg=="],
@@ -452,6 +469,8 @@
"stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="],
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
"statuses": ["statuses@1.4.0", "", {}, "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew=="],
"stethoskop": ["stethoskop@1.0.0", "", { "dependencies": { "node-statsd": "0.1.1" } }, "sha512-4JnZ+UmTs9SFfDjSHFlD/EoXcb1bfwntkt4h1ipNGrpxtRzmHTxOmdquCJvIrVu608Um7a09cGX0ZSOSllWJNQ=="],
@@ -530,30 +549,52 @@
"babel-runtime/regenerator-runtime": ["regenerator-runtime@0.11.1", "", {}, "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg=="],
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"commands-events/@babel/runtime": ["@babel/runtime@7.2.0", "", { "dependencies": { "regenerator-runtime": "^0.12.0" } }, "sha512-oouEibCbHMVdZSDlJBO6bZmID/zA/G/Qx3H1d3rSNPTD+L8UNKvCat7aKWSJ74zYbm5zWGh0GQN0hKj8zYFTCg=="],
"commands-events/uuidv4": ["uuidv4@2.0.0", "", { "dependencies": { "sha-1": "0.1.1", "uuid": "3.3.2" } }, "sha512-sAUlwUVepcVk6bwnaW/oi6LCwMdueako5QQzRr90ioAVVcms6p1mV0PaSxK8gyAC4CRvKddsk217uUpZUbKd2Q=="],
"compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"datasette/lodash": ["lodash@4.17.5", "", {}, "sha512-svL3uiZf1RwhH+cWrfZn3A4+U58wbP0tGVTLQPbjplZxZ8ROD9VLuNgsRniTlLe7OlSqR79RUehXgpBW/s0IQw=="],
"ecdsa-sig-formatter/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"flaschenpost/@babel/runtime": ["@babel/runtime@7.2.0", "", { "dependencies": { "regenerator-runtime": "^0.12.0" } }, "sha512-oouEibCbHMVdZSDlJBO6bZmID/zA/G/Qx3H1d3rSNPTD+L8UNKvCat7aKWSJ74zYbm5zWGh0GQN0hKj8zYFTCg=="],
"hase/@babel/runtime": ["@babel/runtime@7.1.2", "", { "dependencies": { "regenerator-runtime": "^0.12.0" } }, "sha512-Y3SCjmhSupzFB6wcv1KmmFucH6gDVnI30WjOcicV10ju0cZjak3Jcs67YLIXBrmZYw1xCrVeJPbycFwrqNyxpg=="],
"jsonwebtoken/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"morgan/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"safe-array-concat/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"safe-push-apply/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"send/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"which-builtin-type/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"amqplib/readable-stream/string_decoder": ["string_decoder@0.10.31", "", {}, "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ=="],
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"morgan/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
}
}
+216 -42
View File
@@ -4,6 +4,11 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" maximum-scale="1" />
<title>Job Info — Vanilla JS</title>
<!-- Preload critical images for instant display -->
<link rel="preload" href="assets/CCwhiteApp.png" as="image">
<link rel="preload" href="assets/lightbulb.jpg" as="image">
<link rel="preload" href="assets/PalmIsland.png" as="image">
<link rel="preload" href="assets/DailyCheck.png" as="image">
<script src="https://cdn.tailwindcss.com"></script> <!-- Suppress Tailwind CDN warning for now -->
<script>
if (window.tailwind) window.tailwind.config = { corePlugins: { preflight: true } }
@@ -62,7 +67,7 @@
<div class="relative mb-2">
<input id="searchBox" placeholder="Search jobs (just start typing or use voice)" class="w-full p-2.5 pr-12 rounded-lg border border-gray-300 text-lg placeholder:text-gray-400">
<button id="voiceSearchBtn" class="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full flex items-center justify-center hover:bg-gray-100 transition-colors" title="Voice search">
<svg id="micIcon" class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg id="micIcon" class="w-5 h-5 text-gray-600 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path>
</svg>
<svg id="micRecordingIcon" class="hidden w-5 h-5 text-red-600 animate-pulse" fill="currentColor" viewBox="0 0 24 24">
@@ -239,7 +244,8 @@
// --- Utility ---
const fetchNoCache = (url, options={}) => {
const headers = { ...(options.headers||{}), ...authHeaders() };
return fetch(url + `&_ts=${Date.now()}`, { ...options, headers });
const separator = url.includes('?') ? '&' : '?';
return fetch(url + `${separator}_ts=${Date.now()}`, { ...options, headers });
};
const GRAPH_TOKEN_KEY = 'graphAccessToken';
const GRAPH_REAUTH_FLAG = 'graphReauthAttempted';
@@ -397,26 +403,46 @@
async function fetchAllJobs(){
startProgress();
clearJobsCache('Loading…');
let page=1, totalItems=0;
const startTime = performance.now();
try{
while(true){
const url = `${PB_COLLECTION_URL}?page=${page}&perPage=${perPage}&sort=-Job_Number`;
const res = await fetchNoCache(url);
if(!res.ok) throw new Error(`Fetch failed: ${res.status}`);
const data = await res.json();
const items = data.items || [];
jobsCache.push(...items);
totalItems = data.totalItems || (items.length + ((page-1)*perPage));
// Apply filters on the currently loaded pages so initial results reflect preferences immediately.
// Check client-side cache first (sessionStorage)
const cachedData = sessionStorage.getItem('jobsCache');
const cacheTime = sessionStorage.getItem('jobsCacheTime');
const cacheAge = cacheTime ? Date.now() - parseInt(cacheTime) : Infinity;
// Use client cache if less than 5 minutes old
if (cachedData && cacheAge < 300000) {
const data = JSON.parse(cachedData);
jobsCache.push(...data);
const loadTime = Math.round(performance.now() - startTime);
console.log(`✓ Loaded ${data.length} jobs from CLIENT CACHE in ${loadTime}ms`);
progressBar.style.width='95%';
applyFiltersAndRender();
if(progTimer) clearInterval(progTimer);
const realProg=Math.min(95, Math.floor((jobsCache.length / Math.max(1,totalItems)) * 95));
progressBar.style.width=realProg+'%';
if(jobsCache.length >= totalItems || items.length===0) break;
page++;
finishProgress();
return;
}
// Use single cached endpoint that returns all jobs at once
const url = `/api/jobs-all`;
const res = await fetchNoCache(url);
if(!res.ok) throw new Error(`Fetch failed: ${res.status}`);
const data = await res.json();
const items = data.items || [];
jobsCache.push(...items);
// Store in client-side cache
sessionStorage.setItem('jobsCache', JSON.stringify(items));
sessionStorage.setItem('jobsCacheTime', Date.now().toString());
const loadTime = Math.round(performance.now() - startTime);
console.log(`✓ Loaded ${items.length} jobs from API in ${loadTime}ms`);
// Show results immediately
progressBar.style.width='95%';
const renderStart = performance.now();
applyFiltersAndRender();
const renderTime = Math.round(performance.now() - renderStart);
console.log(`✓ Rendered jobs in ${renderTime}ms (Total: ${Math.round(performance.now() - startTime)}ms)`);
finishProgress();
}catch(err){ finishProgress(); alert('Failed to fetch jobs: '+err.message); console.error(err);}
}
@@ -461,7 +487,32 @@
}
function applyFiltersAndRender(){
const q = safeLower(searchBox.value||'');
let q = safeLower(searchBox.value||'');
// Check for recency keywords
const recencyKeywords = ['most recent', 'newest', 'latest', 'current', 'recent'];
let sortByRecent = false;
// Check if query contains any recency keywords (check longer phrases first)
for (const keyword of recencyKeywords) {
if (q.includes(keyword)) {
sortByRecent = true;
// Remove the keyword from search query (use global replace)
const regex = new RegExp(keyword, 'gi');
q = q.replace(regex, ' ');
}
}
// Clean up multiple spaces and trim
q = q.replace(/\s+/g, ' ').trim();
// Update the display to show cleaned query
if (sortByRecent && searchBox.value !== q) {
searchBox.value = q.split(' ').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ');
}
const divisionVals = Array.from(document.querySelectorAll('.filter-division:checked')).map(i=>i.value);
const activeVals = Array.from(document.querySelectorAll('.filter-active:checked')).map(i=>i.value);
const activeWanted = activeVals.map(v=>toBool(v)).filter(v=>v!==null);
@@ -486,11 +537,31 @@
return true;
});
// Data already sorted from PocketBase, only re-sort if filters applied
if(q || divisionVals.length || activeVals.length || statusVals.length || estimatorVals.length){
// If recency keyword used, show only THE most recent match
if(sortByRecent && visibleJobs.length > 0){
console.log('Before sorting, visibleJobs count:', visibleJobs.length);
// Sort by Job_Number (higher number = newer job)
visibleJobs.sort((a, b) => {
const numA = jobNumberValue(a) || 0;
const numB = jobNumberValue(b) || 0;
return numB - numA; // Higher job number first (newest)
});
console.log('After sorting, newest job:', visibleJobs[0]?.Job_Number);
// Keep only the most recent
visibleJobs = visibleJobs.slice(0, 1);
console.log('✓ Showing only the most recent match');
} else if(q || divisionVals.length || activeVals.length || statusVals.length || estimatorVals.length){
sortJobsDescending(visibleJobs);
}
console.log('Filter summary', { q, divisionVals, activeWanted, statusVals, estimatorValsLower, total: jobsCache.length, visible: visibleJobs.length });
console.log('Filter summary', {
originalSearch: searchBox.value,
cleanedQ: q,
sortByRecent,
resultsCount: visibleJobs.length
});
renderCards(visibleJobs);
}
@@ -583,9 +654,17 @@
let autoStopTimer = null;
let silenceTimer = null;
let lastResultIndex = 0;
let baseSearchText = '';
recognition.onstart = () => {
isListening = true;
lastResultIndex = 0;
baseSearchText = searchBox.value.trim();
// Change mic icon to filled maroon/red
micIcon.style.fill = '#991b1b'; // Tailwind red-800
micIcon.style.stroke = '#991b1b';
// Auto-stop after 10 seconds as a safety measure
autoStopTimer = setTimeout(() => {
@@ -602,17 +681,40 @@
silenceTimer = null;
}
const last = event.results.length - 1;
const transcript = event.results[last][0].transcript;
const isFinal = event.results[last].isFinal;
// Process only new final results
for (let i = lastResultIndex; i < event.results.length; i++) {
if (event.results[i].isFinal) {
let transcript = event.results[i][0].transcript.trim();
// Remove trailing punctuation (period, comma, etc.)
transcript = transcript.replace(/[.,!?;:]+$/, '');
// Capitalize each word (title case)
transcript = transcript.split(' ').map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join(' ');
if (transcript) {
// Add space if base text exists and doesn't end with space
if (baseSearchText && !baseSearchText.endsWith(' ')) {
baseSearchText += ' ';
}
baseSearchText += transcript;
}
lastResultIndex = i + 1;
}
}
searchBox.value = transcript.trim();
// Update search box with accumulated text
searchBox.value = baseSearchText;
// Trigger search with results
applyFiltersAndRender();
// Stop after final result with short delay
if (isFinal) {
// Check if all results are final, if so prepare to stop
const allFinal = Array.from(event.results).every(r => r.isFinal);
if (allFinal && event.results.length > 0) {
silenceTimer = setTimeout(() => {
recognition.stop();
}, 1500);
@@ -646,6 +748,10 @@
if (autoStopTimer) clearTimeout(autoStopTimer);
if (silenceTimer) clearTimeout(silenceTimer);
// Reset mic icon to normal
micIcon.style.fill = 'none';
micIcon.style.stroke = '';
isListening = false;
};
@@ -1293,7 +1399,11 @@
window.openFileInViewer = openFileInViewer;
async function renderPdfPage() {
const { doc, page, scale } = currentPdf;
const { doc, page, scale, isImage } = currentPdf;
if (isImage) {
renderImageOnCanvas();
return;
}
if (!doc) return;
const pageObj = await doc.getPage(page);
const viewport = pageObj.getViewport({ scale });
@@ -1311,10 +1421,33 @@
pdfZoomReset.textContent = `${Math.round(currentPdf.scale * 100)}%`;
}
function renderImageOnCanvas() {
const { scale, imageElement, imageWidth, imageHeight } = currentPdf;
if (!imageElement) return;
const canvas = pdfCanvas;
const context = canvas.getContext('2d');
const scaledWidth = imageWidth * scale;
const scaledHeight = imageHeight * scale;
canvas.width = scaledWidth;
canvas.height = scaledHeight;
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(imageElement, 0, 0, scaledWidth, scaledHeight);
// re-apply pan transform after render
pdfCanvas.style.transform = `translate(${pdfPan.x}px, ${pdfPan.y}px)`;
pdfZoomLabel.textContent = `${Math.round(scale * 100)}%`;
pdfZoomReset.textContent = `${Math.round(scale * 100)}%`;
}
function showPdfViewer() {
iframeViewer.classList.add('hidden');
pdfViewer.classList.remove('hidden');
iframeLoader.classList.add('hidden');
// Ensure PDF controls are visible (might be hidden for images)
pdfControls.style.display = '';
// Show page navigation for PDFs
pdfPrev.parentElement.style.display = '';
pdfNext.parentElement.style.display = '';
pdfPageNum.parentElement.style.display = '';
}
pdfPrev.addEventListener('click', async () => {
@@ -1330,18 +1463,18 @@
await renderPdfPage();
});
pdfZoomIn.addEventListener('click', async () => {
if (!currentPdf.doc) return;
if (!currentPdf.doc && !currentPdf.isImage) return;
currentPdf.scale = Math.min(currentPdf.scale + 0.1, MAX_PDF_SCALE);
await renderPdfPage();
});
pdfZoomOut.addEventListener('click', async () => {
if (!currentPdf.doc) return;
if (!currentPdf.doc && !currentPdf.isImage) return;
currentPdf.scale = Math.max(currentPdf.scale - 0.1, MIN_PDF_SCALE);
await renderPdfPage();
});
pdfZoomReset.addEventListener('click', async () => {
if (!currentPdf.doc) return;
currentPdf.scale = DEFAULT_PDF_SCALE;
if (!currentPdf.doc && !currentPdf.isImage) return;
currentPdf.scale = currentPdf.isImage ? 1 : DEFAULT_PDF_SCALE;
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
pdfCanvas.style.transform = 'translate(0px, 0px)';
await renderPdfPage();
@@ -1366,7 +1499,7 @@
};
pdfCanvas.addEventListener('wheel', async (e) => {
if (!currentPdf.doc) return;
if (!currentPdf.doc && !currentPdf.isImage) return;
e.preventDefault();
const delta = e.deltaY;
const step = 0.1;
@@ -1386,7 +1519,7 @@
fileListContainer.classList.remove('hidden');
});
pdfCanvas.addEventListener('pointerdown', (e) => {
if (!currentPdf.doc) return;
if (!currentPdf.doc && !currentPdf.isImage) return;
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (activePointers.size === 2) {
@@ -1406,7 +1539,7 @@
});
pdfCanvas.addEventListener('pointermove', (e) => {
if (!currentPdf.doc) return;
if (!currentPdf.doc && !currentPdf.isImage) return;
if (activePointers.has(e.pointerId)) {
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
@@ -1506,6 +1639,9 @@
// Word docs are converted to PDF, so treat them as PDFs
const isPdf = isWordDoc || (contentType || '').toLowerCase().includes('pdf') || (name || '').toLowerCase().endsWith('.pdf');
const isImage = (contentType || '').toLowerCase().includes('image') ||
/\.(jpg|jpeg|png|gif|bmp|tiff|webp)$/i.test(name || '');
if (isPdf && window['pdfjsLib']) {
const data = await blob.arrayBuffer();
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
@@ -1516,8 +1652,39 @@
pdfCanvas.style.transform = 'translate(0px, 0px)';
await renderPdfPage();
showPdfViewer();
} else if (isImage) {
// Show images in the PDF viewer area for consistency
pdfViewer.classList.remove('hidden');
iframeViewer.classList.add('hidden');
iframeLoader.classList.add('hidden');
// Store original image for re-rendering at different scales
const img = new Image();
img.onload = () => {
currentPdf = {
doc: null,
page: 1,
pages: 1,
scale: 1,
isImage: true,
imageElement: img,
imageWidth: img.width,
imageHeight: img.height
};
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
pdfCanvas.style.transform = 'translate(0px, 0px)';
renderImageOnCanvas();
// Show PDF controls (zoom works for images too)
pdfControls.style.display = '';
// Hide page navigation for images
pdfPrev.parentElement.style.display = 'none';
pdfNext.parentElement.style.display = 'none';
pdfPageNum.parentElement.style.display = 'none';
};
img.src = currentPreviewUrl;
} else {
// fallback to iframe blob view (images, etc.)
// fallback to iframe blob view
iframeViewer.src = 'about:blank';
setTimeout(() => {
iframeViewer.src = currentPreviewUrl;
@@ -1537,13 +1704,16 @@
const term = (fileListState.filter || '').toLowerCase().trim();
const filtered = fileListState.items
.filter((f) => {
// Show PDF files and Word documents (which will be converted to PDF)
// Show PDF files, Word documents (which will be converted to PDF), and images
if (f.isFolder) return false;
const name = f.name.toLowerCase();
const contentType = (f.contentType || '').toLowerCase();
const isPdfOrWord = name.endsWith('.pdf') || contentType.includes('pdf') ||
name.endsWith('.doc') || name.endsWith('.docx') || contentType.includes('word');
if (!isPdfOrWord) return false;
const isImage = name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.png') ||
name.endsWith('.gif') || name.endsWith('.bmp') || name.endsWith('.tiff') ||
name.endsWith('.webp') || contentType.includes('image');
if (!isPdfOrWord && !isImage) return false;
// Apply search term filter if present
return name.includes(term);
});
@@ -1648,6 +1818,7 @@
if (cached) {
console.log('Using cached files for job', job.Job_Number);
fileListState.items = cached.items;
iframeLoader.classList.add('hidden');
renderFileGroups(link);
return;
}
@@ -1666,7 +1837,7 @@
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
const timeout = setTimeout(() => controller.abort(), 30000); // 30 seconds for large folders
let resp = await fetch(`/api/job-files?link=${encodeURIComponent(link)}`, { headers, signal: controller.signal });
clearTimeout(timeout);
@@ -1696,10 +1867,11 @@
fileCache.set(link, { items, driveId: rootDriveId });
fileListState.items = items;
console.log(`✓ Loaded ${items.length} files for job ${job.Job_Number}`);
iframeLoader.classList.add('hidden');
renderFileGroups(link);
} catch (err) {
console.error('job-files error', err);
console.error('job-files error for', job.Job_Number, err);
const authExpired = err?.message === 'AUTH_EXPIRED' || String(err || '').includes('401');
const timedOut = err?.name === 'AbortError';
const friendly = authExpired
@@ -1769,6 +1941,8 @@
URL.revokeObjectURL(currentPreviewUrl);
currentPreviewUrl = null;
}
// Clear file cache when closing to ensure fresh data on next open
fileCache.clear();
// Clear iframe src to stop any loading/video/audio and prevent stacking
setTimeout(() => {
iframeViewer.src = 'about:blank';
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "job-info-pb",
"version": "1.0.0-beta2",
"version": "1.0.0-beta3",
"type": "module",
"private": true,
"scripts": {
@@ -20,6 +20,7 @@
"dependencies": {
"dotenv": "^17.2.3",
"hono": "^4.10.8",
"ioredis": "^5.8.2",
"pocketbase": "^0.26.5",
"tailwind": "^4.0.0"
}