Compare commits
3 Commits
d231a0b6ec
...
a191db8449
| Author | SHA1 | Date | |
|---|---|---|---|
| a191db8449 | |||
| b11df8484f | |||
| c65a2000ac |
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user