Mode2Test/AuthAndToken: assume tokens always fresh with background refresh; fix jobs API to use correct collection; align Home UI to Mode1 (exact fields, styling, strict Active dot); add full-field IndexedDB cache w/ pagination; remove progress bar; dev TLS bypass; rebuild bundles
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import { getTokens, saveTokens, clearTokens, hasValidPbToken } from './token-service';
|
||||
import { tokenService } from './token-service';
|
||||
|
||||
// Authentication API routes
|
||||
// Endpoints for frontend to check auth status, get tokens, logout
|
||||
// Depends on: tokenService singleton (centralized token management)
|
||||
|
||||
export function createAuthRoutes(): Hono {
|
||||
const router = new Hono();
|
||||
@@ -11,11 +12,13 @@ export function createAuthRoutes(): Hono {
|
||||
// PERMANENT: Check if user is authenticated
|
||||
// Returns current auth status and which tokens are available
|
||||
router.get('/status', async (c: Context) => {
|
||||
const hasValid = await hasValidPbToken();
|
||||
const tokens = await tokenService.getTokens();
|
||||
const hasToken = !!tokens?.pbToken;
|
||||
return c.json({
|
||||
authenticated: hasValid,
|
||||
pbTokenValid: hasValid,
|
||||
message: hasValid ? 'User authenticated' : 'User not authenticated'
|
||||
authenticated: hasToken,
|
||||
pbTokenValid: hasToken,
|
||||
assumedFresh: hasToken,
|
||||
message: hasToken ? 'Assuming tokens are valid (auto-refresh running)' : 'No PB token yet; login once'
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,17 +26,14 @@ export function createAuthRoutes(): Hono {
|
||||
// Frontend/backend can call this to get fresh token state
|
||||
// Only returns tokens if PB token is valid (auth required)
|
||||
router.get('/tokens', async (c: Context) => {
|
||||
const hasValid = await hasValidPbToken();
|
||||
if (!hasValid) {
|
||||
return c.json({ error: 'Not authenticated' }, 401);
|
||||
}
|
||||
|
||||
const tokens = await getTokens();
|
||||
const tokens = await tokenService.getTokens();
|
||||
return c.json({
|
||||
pbToken: tokens?.pbToken || null,
|
||||
graphToken: tokens?.graphToken || null,
|
||||
pbTokenExpiry: tokens?.pbTokenExpiry || null,
|
||||
graphTokenExpiry: tokens?.graphTokenExpiry || null
|
||||
graphTokenExpiry: tokens?.graphTokenExpiry || null,
|
||||
lastRefresh: tokens?.lastRefresh || null,
|
||||
assumedFresh: !!tokens?.pbToken
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ export function createAuthRoutes(): Hono {
|
||||
return c.json({ error: 'Missing pbToken' }, 400);
|
||||
}
|
||||
|
||||
await saveTokens(pbToken, graphToken);
|
||||
await tokenService.saveTokens(pbToken, graphToken);
|
||||
return c.json({
|
||||
success: true,
|
||||
message: 'Login successful',
|
||||
@@ -66,7 +66,7 @@ export function createAuthRoutes(): Hono {
|
||||
// Called when user clicks logout
|
||||
router.post('/logout', async (c: Context) => {
|
||||
try {
|
||||
await clearTokens();
|
||||
await tokenService.clearTokens();
|
||||
return c.json({
|
||||
success: true,
|
||||
message: 'Logged out',
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import { tokenService } from './token-service';
|
||||
|
||||
// PERMANENT: Jobs API routes
|
||||
// Provides access to job data from PocketBase
|
||||
// Requires authentication (valid PB token)
|
||||
//
|
||||
// Dependencies:
|
||||
// - tokenService for PB token retrieval
|
||||
// - process.env.POCKETBASE_URL for PB API endpoint
|
||||
const JOBS_COLLECTION = 'pbc_1407612689';
|
||||
//
|
||||
// Endpoints:
|
||||
// - GET /api/jobs - List jobs with pagination, filtering, field selection
|
||||
|
||||
export function createJobsRoutes(): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
// GET /api/jobs - Fetch jobs from PocketBase
|
||||
// Query params:
|
||||
// - page: Page number (default 1)
|
||||
// - perPage: Items per page (default 50, max 1000)
|
||||
// - fields: Comma-separated field names to return (optional, for cache optimization)
|
||||
// - filter: PocketBase filter syntax (optional)
|
||||
// - sort: Sort field (default -Job_Number)
|
||||
router.get('/', async (c: Context) => {
|
||||
try {
|
||||
// Get token (assume present after login)
|
||||
const tokens = await tokenService.getTokens();
|
||||
const pbToken = tokens?.pbToken;
|
||||
|
||||
// Parse query params
|
||||
const page = parseInt(c.req.query('page') || '1');
|
||||
const perPage = Math.min(parseInt(c.req.query('perPage') || '50'), 1000);
|
||||
const fields = c.req.query('fields'); // Optional: comma-separated field names
|
||||
const filter = c.req.query('filter'); // Optional: PocketBase filter
|
||||
const sort = c.req.query('sort') || '-Job_Number';
|
||||
|
||||
// Build PocketBase API URL
|
||||
const pbUrl = new URL(`${process.env.POCKETBASE_URL}/api/collections/${JOBS_COLLECTION}/records`);
|
||||
pbUrl.searchParams.set('page', page.toString());
|
||||
pbUrl.searchParams.set('perPage', perPage.toString());
|
||||
pbUrl.searchParams.set('sort', sort);
|
||||
|
||||
if (fields) {
|
||||
pbUrl.searchParams.set('fields', fields);
|
||||
}
|
||||
|
||||
if (filter) {
|
||||
pbUrl.searchParams.set('filter', filter);
|
||||
}
|
||||
|
||||
// Fetch from PocketBase
|
||||
// temporary: dev-only SSL bypass; global rejectUnauthorized already set, keep here as explicit
|
||||
const response = await fetch(pbUrl.toString(), {
|
||||
headers: pbToken ? { 'Authorization': `Bearer ${pbToken}` } : {},
|
||||
// @ts-ignore Bun TLS option
|
||||
tls: { rejectUnauthorized: false }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('PocketBase jobs fetch failed:', response.status, errorText);
|
||||
return c.json({ error: 'Failed to fetch jobs from PocketBase' }, response.status);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return c.json(data);
|
||||
} catch (err) {
|
||||
console.error('Jobs API error:', (err as Error)?.message);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/jobs/:id - Fetch single job by ID
|
||||
router.get('/:id', async (c: Context) => {
|
||||
try {
|
||||
// Check authentication
|
||||
const tokens = await tokenService.getTokens();
|
||||
const pbToken = tokens?.pbToken;
|
||||
|
||||
const jobId = c.req.param('id');
|
||||
const pbUrl = `${process.env.POCKETBASE_URL}/api/collections/${JOBS_COLLECTION}/records/${jobId}`;
|
||||
|
||||
// Fetch from PocketBase
|
||||
const response = await fetch(pbUrl, {
|
||||
headers: pbToken ? { 'Authorization': `Bearer ${pbToken}` } : {}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return c.json({ error: 'Job not found' }, 404);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return c.json(data);
|
||||
} catch (err) {
|
||||
console.error('Job fetch error:', (err as Error)?.message);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,14 +1,25 @@
|
||||
import { Hono } from 'hono';
|
||||
import { serveStatic } from 'hono/bun';
|
||||
import { join } from 'path';
|
||||
import { initializeTokenService, startBackgroundRefresh } from './token-service';
|
||||
import { tokenService } from './token-service';
|
||||
import { createAuthRoutes } from './auth-routes';
|
||||
import { createJobsRoutes } from './jobs-routes';
|
||||
|
||||
// temporary: dev-only SSL bypass for upstream PocketBase cert issues
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
|
||||
// PERMANENT: Load environment variables
|
||||
// Check if POCKETBASE_URL is set, use default if not
|
||||
if (!process.env.POCKETBASE_URL) {
|
||||
process.env.POCKETBASE_URL = 'https://pocketbase.ccllc.pro';
|
||||
console.log('[Mode2Test] Using default POCKETBASE_URL:', process.env.POCKETBASE_URL);
|
||||
}
|
||||
|
||||
// PERMANENT: Initialize token service and start background refresh
|
||||
// Loads existing tokens from file if available
|
||||
// Starts 5-minute background refresh cycle
|
||||
initializeTokenService();
|
||||
startBackgroundRefresh();
|
||||
tokenService.initialize();
|
||||
tokenService.startBackgroundRefresh();
|
||||
|
||||
// Create Hono app
|
||||
const app = new Hono();
|
||||
@@ -16,6 +27,9 @@ const app = new Hono();
|
||||
// Mount auth routes
|
||||
app.route('/api/auth', createAuthRoutes());
|
||||
|
||||
// Mount jobs routes
|
||||
app.route('/api/jobs', createJobsRoutes());
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (c) => {
|
||||
return c.text('OK');
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { join } from 'path';
|
||||
|
||||
// Token storage and refresh management
|
||||
// Stores both PocketBase and Graph tokens locally (tokens.json)
|
||||
// Background refresh keeps both tokens fresh without user intervention
|
||||
// PERMANENT: Token Service Module
|
||||
// Singleton module for centralized token acquisition, storage, and refresh
|
||||
// Available to any part of the application via: import { tokenService } from './token-service.ts'
|
||||
//
|
||||
// Token Lifecycle:
|
||||
// - Tokens stored in tokens.json (persistent across restarts)
|
||||
// - Never delete old token before new one acquired
|
||||
// - Background refresh every 5 minutes
|
||||
// - Startup: if PB token exists, resume refresh; else require login
|
||||
//
|
||||
// Usage:
|
||||
// const tokens = await tokenService.getTokens();
|
||||
// await tokenService.saveTokens(pbToken, graphToken);
|
||||
// await tokenService.initialize();
|
||||
// tokenService.startBackgroundRefresh();
|
||||
|
||||
interface TokenData {
|
||||
pbToken: string;
|
||||
@@ -14,13 +26,10 @@ interface TokenData {
|
||||
|
||||
const tokensFilePath = join(import.meta.dir, '../../tokens.json');
|
||||
|
||||
// PERMANENT: Token lifecycle management
|
||||
// - Tokens stored in tokens.json (persistent across restarts)
|
||||
// - Never delete old token before new one acquired
|
||||
// - Background refresh every 5 minutes
|
||||
// - Startup: if PB token exists, resume refresh; else require login
|
||||
class TokenService {
|
||||
private backgroundStarted = false;
|
||||
|
||||
export async function initializeTokenService(): Promise<void> {
|
||||
async initialize(): Promise<void> {
|
||||
// Load tokens from file if they exist
|
||||
try {
|
||||
const file = Bun.file(tokensFilePath);
|
||||
@@ -36,25 +45,32 @@ export async function initializeTokenService(): Promise<void> {
|
||||
} catch (err) {
|
||||
console.log('[AuthAndToken] Token file init error (will create on first login):', (err as Error)?.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startBackgroundRefresh(): void {
|
||||
if (this.backgroundStarted) return;
|
||||
this.backgroundStarted = true;
|
||||
|
||||
export function startBackgroundRefresh(): void {
|
||||
// PERMANENT: Background token refresh
|
||||
// Runs every 5 minutes, attempts to refresh both PB and Graph tokens
|
||||
// Safe: never deletes old token before new one is confirmed
|
||||
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await refreshTokens();
|
||||
await this.refreshTokens();
|
||||
} catch (err) {
|
||||
console.log('[AuthAndToken] Background refresh error:', (err as Error)?.message);
|
||||
}
|
||||
}, 5 * 60 * 1000); // 5 minutes
|
||||
|
||||
console.log('[AuthAndToken] Background refresh started (every 5 minutes)');
|
||||
}
|
||||
// Kick off an immediate refresh attempt so we start from a fresh token without waiting
|
||||
this.refreshTokens().catch((err) => {
|
||||
console.log('[AuthAndToken] Immediate refresh error:', (err as Error)?.message);
|
||||
});
|
||||
|
||||
export async function saveTokens(pbToken: string, graphToken?: string): Promise<void> {
|
||||
console.log('[AuthAndToken] Background refresh started (every 5 minutes + immediate)');
|
||||
}
|
||||
|
||||
async saveTokens(pbToken: string, graphToken?: string): Promise<void> {
|
||||
// PERMANENT: Token persistence
|
||||
// Called after login or successful refresh
|
||||
// Stores both tokens with expiry times
|
||||
@@ -69,9 +85,9 @@ export async function saveTokens(pbToken: string, graphToken?: string): Promise<
|
||||
|
||||
await Bun.write(tokensFilePath, JSON.stringify(data, null, 2));
|
||||
console.log('[AuthAndToken] Tokens saved');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTokens(): Promise<TokenData | null> {
|
||||
async getTokens(): Promise<TokenData | null> {
|
||||
try {
|
||||
const file = Bun.file(tokensFilePath);
|
||||
if (!(await file.exists())) return null;
|
||||
@@ -80,28 +96,42 @@ export async function getTokens(): Promise<TokenData | null> {
|
||||
return JSON.parse(content) as TokenData;
|
||||
} catch (err) {
|
||||
console.log('[AuthAndToken] Error reading tokens:', (err as Error)?.message);
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
await fs.unlink(tokensFilePath);
|
||||
console.log('[AuthAndToken] Corrupt tokens file removed');
|
||||
} catch (innerErr) {
|
||||
console.log('[AuthAndToken] Failed to remove corrupt tokens file:', (innerErr as Error)?.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearTokens(): Promise<void> {
|
||||
async clearTokens(): Promise<void> {
|
||||
try {
|
||||
const file = Bun.file(tokensFilePath);
|
||||
if (await file.exists()) {
|
||||
await Bun.write(tokensFilePath, '');
|
||||
const fs = await import('fs/promises');
|
||||
await fs.unlink(tokensFilePath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('[AuthAndToken] Error clearing tokens:', (err as Error)?.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTokens(): Promise<void> {
|
||||
async hasValidPbToken(): Promise<boolean> {
|
||||
// Simplified: treat presence of PB token as valid; no expiry enforcement
|
||||
const tokens = await this.getTokens();
|
||||
return !!tokens?.pbToken;
|
||||
}
|
||||
|
||||
private async refreshTokens(): Promise<void> {
|
||||
// PERMANENT: Auto-refresh logic for both tokens
|
||||
// Called every 5 minutes by background job
|
||||
// Fetches fresh tokens from PocketBase, attempts Graph token refresh
|
||||
// Only updates file after new tokens confirmed valid
|
||||
|
||||
const tokens = await getTokens();
|
||||
const tokens = await this.getTokens();
|
||||
if (!tokens || !tokens.pbToken) {
|
||||
console.log('[AuthAndToken] No active tokens to refresh');
|
||||
return;
|
||||
@@ -114,7 +144,9 @@ async function refreshTokens(): Promise<void> {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${tokens.pbToken}`
|
||||
}
|
||||
},
|
||||
// @ts-ignore Bun TLS option
|
||||
tls: { rejectUnauthorized: false }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -131,14 +163,14 @@ async function refreshTokens(): Promise<void> {
|
||||
}
|
||||
|
||||
// Update tokens with new PB token
|
||||
await saveTokens(newPbToken, tokens.graphToken);
|
||||
await this.saveTokens(newPbToken, tokens.graphToken);
|
||||
console.log('[AuthAndToken] Tokens refreshed successfully');
|
||||
} catch (err) {
|
||||
console.log('[AuthAndToken] Refresh error:', (err as Error)?.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasValidPbToken(): Promise<boolean> {
|
||||
const tokens = await getTokens();
|
||||
return !!tokens?.pbToken && tokens.pbTokenExpiry > Date.now();
|
||||
}
|
||||
// PERMANENT: Singleton export
|
||||
// Import as: import { tokenService } from './token-service.ts'
|
||||
export const tokenService = new TokenService();
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Job Details</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="font-sans bg-gray-100 p-4">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-4 text-blue-600">Job Details</h1>
|
||||
<p class="text-gray-600">Details view - Under construction</p>
|
||||
<a href="/home.html" class="text-blue-600 underline mt-4 inline-block">← Back to Home</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,104 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" maximum-scale="1" />
|
||||
<title>Job Info — Home</title>
|
||||
<link rel="preload" href="assets/CCwhiteApp.png" as="image">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
if (window.tailwind) window.tailwind.config = { corePlugins: { preflight: true } }
|
||||
</script>
|
||||
</head>
|
||||
<body class="font-sans bg-gray-100 m-0 text-gray-900 overflow-hidden touch-pan-y h-screen flex flex-col">
|
||||
<div class="max-w-full md:max-w-2xl mx-auto p-4 sm:p-3 text-[17px] flex flex-col flex-1 overflow-hidden">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="w-full bg-gray-400 border border-gray-300 rounded-lg px-0.5 py-0.5 mb-3 shadow-sm flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-6 w-[186px] rounded-lg bg-black flex items-center justify-center overflow-hidden">
|
||||
<img src="assets/CCwhiteApp.png" alt="CC" class="h-6 w-auto object-cover scale-80" loading="lazy">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-0">
|
||||
<a href="https://idea-form.ccllc.pro" target="_blank" rel="noopener" class="h-6 w-12 rounded-3xl overflow-hidden flex items-center justify-center bg-transparent hover:bg-gray-50/20" title="Submit an idea">
|
||||
<img src="assets/lightbulb.jpg" alt="Idea" class="h-full w-auto object-cover" loading="lazy">
|
||||
</a>
|
||||
<a href="https://czflex.sharepoint.com/:x:/r/sites/Team/Shared%20Documents/General/ALFONSO/Comprehensive%20Asset%20Inventory.xlsx?d=w9005440917e945d680e9885f33ccf2ba&csf=1&web=1&e=rifTzf" target="_blank" rel="noopener" class="h-6 w-12 rounded-3xl overflow-hidden flex items-center justify-center bg-transparent hover:bg-gray-50/20" title="Open Asset Inventory">
|
||||
<img src="assets/PalmIsland.png" alt="Palm Island" class="h-full w-auto object-cover" loading="lazy">
|
||||
</a>
|
||||
<a href="https://czflex.sharepoint.com/:x:/r/sites/Team/Shared%20Documents/General/ALFONSO/GM%20Rollup.xlsx?d=w3e59f37ee1c04cb798e14f86bd87c968&csf=1&web=1&e=vCnfbR" target="_blank" rel="noopener" class="h-6 w-12 rounded-3xl overflow-hidden flex items-center justify-center bg-transparent hover:bg-gray-50/20" title="Open Operations Dashboard">
|
||||
<img src="assets/DailyCheck.png" alt="Operations Dashboard" class="h-full w-auto object-cover" loading="lazy">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="m-0 mb-3 text-center text-blue-600 text-[33px] font-bold">Job Info</h1>
|
||||
|
||||
<!-- Search Box -->
|
||||
<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 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">
|
||||
<circle cx="12" cy="12" r="8"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter Toggle -->
|
||||
<button id="filterToggle" class="bg-blue-600 text-white py-2 px-2.5 rounded-lg border-none cursor-pointer mb-2 inline-block min-w-[140px]">Show Filters</button>
|
||||
|
||||
<!-- Filters Panel -->
|
||||
<div id="filtersPanel" class="hidden bg-white border border-gray-200 rounded-lg p-2.5 mb-2.5">
|
||||
<div class="mb-2 text-sm">
|
||||
<strong>Division</strong><br/>
|
||||
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-division" value="C#"> Commercial</label>
|
||||
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-division" value="R#"> Residential</label>
|
||||
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-division" value="S#"> Small Jobs</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 text-sm">
|
||||
<strong>Active</strong><br/>
|
||||
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-active" value="Yes"> Yes</label>
|
||||
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-active" value="No"> No</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 text-sm">
|
||||
<strong>Estimator</strong><br/>
|
||||
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-estimator" value="Steve Brewer"> Steve</label>
|
||||
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-estimator" value="Beth Cardoza"> Beth</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 text-sm">
|
||||
<strong>Status</strong><br/>
|
||||
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Estimating"> Estimating</label>
|
||||
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="In Progress"> In Progress</label>
|
||||
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Awarded"> Awarded</label>
|
||||
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Billed (In Progress)"> Billed (In Progress)</label>
|
||||
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Not Bidding"> Not Bidding</label>
|
||||
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Est Sent"> Est Sent</label>
|
||||
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Not Awarded"> Not Awarded</label>
|
||||
</div>
|
||||
<button id="clearFilters" class="bg-gray-500 text-white py-2 px-2 rounded-lg border-none">Clear Filters</button>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div id="results" aria-live="polite" class="flex flex-col gap-3 flex-1 overflow-auto p-0"></div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="text-xs text-gray-500 mt-5 pt-3 border-t border-gray-200 flex items-center justify-between">
|
||||
<span id="userEmail" class="truncate"></span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="signOutBtn" class="px-2 py-1 text-[11px] bg-gray-200 hover:bg-gray-300 rounded border border-gray-300 text-gray-700">Sign out</button>
|
||||
<span id="modeLabel" class="px-2 py-1 text-[11px] bg-blue-100 text-blue-700 rounded border border-blue-300">[Mode2Test]</span>
|
||||
<span id="versionLabel">v1.0.0-beta1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="js/home.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,50 +4,35 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mode2Test</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/pocketbase@0.26.5/dist/pocketbase.umd.js"></script>
|
||||
</head>
|
||||
<body class="font-sans bg-gray-50 min-h-screen">
|
||||
<header class="bg-white shadow">
|
||||
<div class="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">Mode2Test</h1>
|
||||
<p class="text-sm text-gray-600">Authenticated Application</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div id="userDisplay" class="text-right">
|
||||
<p id="userName" class="text-sm font-medium text-gray-800"></p>
|
||||
<p id="userEmail" class="text-xs text-gray-600"></p>
|
||||
</div>
|
||||
<button onclick="handleLogout()" class="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-7xl mx-auto px-4 py-8">
|
||||
<div class="bg-white p-8 rounded-lg shadow">
|
||||
<h2 class="text-xl font-bold mb-4 text-gray-800">Welcome to Mode2Test</h2>
|
||||
<p class="text-gray-600 mb-6">Authenticated and ready to work.</p>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded border border-gray-200">
|
||||
<h3 class="font-medium mb-2 text-gray-800">Token Status</h3>
|
||||
<div id="tokenStatus" class="text-sm text-gray-600 space-y-2">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const pb = new PocketBase('https://pocketbase.ccllc.pro');
|
||||
|
||||
async function checkAuth() {
|
||||
if (!pb.authStore.isValid) {
|
||||
// Try to refresh
|
||||
// Redirect to appropriate page based on auth status
|
||||
async function checkAuthAndRedirect() {
|
||||
try {
|
||||
await pb.collection('users').authRefresh();
|
||||
const response = await fetch('/api/auth/status');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.authenticated) {
|
||||
// Redirect to home
|
||||
window.location.href = '/home.html';
|
||||
} else {
|
||||
// Redirect to signin
|
||||
window.location.href = '/signin.html';
|
||||
}
|
||||
} catch (err) {
|
||||
// On error, go to signin
|
||||
window.location.href = '/signin.html';
|
||||
}
|
||||
}
|
||||
|
||||
checkAuthAndRedirect();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div style="text-align: center; padding: 50px;">
|
||||
<p>Redirecting...</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
} catch {
|
||||
window.location.href = '/signin.html';
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
// job-cache.js
|
||||
var DB_NAME = "JobInfoCache";
|
||||
var DB_VERSION = 2;
|
||||
var JOBS_STORE = "jobs";
|
||||
var META_STORE = "metadata";
|
||||
var CACHE_TTL = 5 * 60 * 1000;
|
||||
var PAGE_SIZE = 500;
|
||||
|
||||
class JobCache {
|
||||
db = null;
|
||||
async initialize() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
resolve();
|
||||
};
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains(JOBS_STORE)) {
|
||||
db.createObjectStore(JOBS_STORE, { keyPath: "Job_Number" });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(META_STORE)) {
|
||||
db.createObjectStore(META_STORE);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
async getCachedJobs() {
|
||||
if (!this.db)
|
||||
throw new Error("Database not initialized");
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([JOBS_STORE], "readonly");
|
||||
const store = transaction.objectStore(JOBS_STORE);
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
async isCacheValid() {
|
||||
if (!this.db)
|
||||
return false;
|
||||
try {
|
||||
const metadata = await this.getMetadata();
|
||||
if (!metadata || !metadata.lastUpdated)
|
||||
return false;
|
||||
const age = Date.now() - metadata.lastUpdated;
|
||||
return age < CACHE_TTL;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async saveJobs(jobs) {
|
||||
if (!this.db)
|
||||
throw new Error("Database not initialized");
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([JOBS_STORE, META_STORE], "readwrite");
|
||||
const jobsStore = transaction.objectStore(JOBS_STORE);
|
||||
const metaStore = transaction.objectStore(META_STORE);
|
||||
jobsStore.clear();
|
||||
jobs.forEach((job) => jobsStore.add(job));
|
||||
const metadata = {
|
||||
lastUpdated: Date.now(),
|
||||
version: DB_VERSION
|
||||
};
|
||||
metaStore.put(metadata, "main");
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
async refreshJobs() {
|
||||
try {
|
||||
let page = 1;
|
||||
let all = [];
|
||||
let totalPages = 1;
|
||||
while (page <= totalPages) {
|
||||
const url = `/api/jobs?page=${page}&perPage=${PAGE_SIZE}&sort=-Job_Number`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch jobs (status ${response.status})`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const items = data.items || [];
|
||||
const reportedTotalPages = data.totalPages || 1;
|
||||
all = all.concat(items);
|
||||
totalPages = Math.max(totalPages, reportedTotalPages);
|
||||
page += 1;
|
||||
}
|
||||
await this.saveJobs(all);
|
||||
return all;
|
||||
} catch (err) {
|
||||
console.error("Failed to refresh jobs cache:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
async getMetadata() {
|
||||
if (!this.db)
|
||||
return null;
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([META_STORE], "readonly");
|
||||
const store = transaction.objectStore(META_STORE);
|
||||
const request = store.get("main");
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
async clearCache() {
|
||||
if (!this.db)
|
||||
throw new Error("Database not initialized");
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([JOBS_STORE, META_STORE], "readwrite");
|
||||
transaction.objectStore(JOBS_STORE).clear();
|
||||
transaction.objectStore(META_STORE).clear();
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
async getCacheAge() {
|
||||
const metadata = await this.getMetadata();
|
||||
if (!metadata || !metadata.lastUpdated)
|
||||
return null;
|
||||
return Date.now() - metadata.lastUpdated;
|
||||
}
|
||||
}
|
||||
var jobCache = new JobCache;
|
||||
|
||||
// home.ts
|
||||
var allJobs = [];
|
||||
var filteredJobs = [];
|
||||
var currentPage = 1;
|
||||
var perPage = 50;
|
||||
var isRefreshing = false;
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const response = await fetch("/api/auth/status");
|
||||
const data = await response.json();
|
||||
if (!data.authenticated) {
|
||||
window.location.href = "/signin.html";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Auth check failed:", err);
|
||||
window.location.href = "/signin.html";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function fetchJobs() {
|
||||
try {
|
||||
await jobCache.initialize();
|
||||
const isCacheValid = await jobCache.isCacheValid();
|
||||
if (isCacheValid) {
|
||||
const cachedJobs = await jobCache.getCachedJobs();
|
||||
allJobs = cachedJobs;
|
||||
filteredJobs = [...allJobs];
|
||||
renderJobs();
|
||||
const cacheAge = await jobCache.getCacheAge();
|
||||
console.log(`Loaded ${allJobs.length} jobs from cache (age: ${Math.round((cacheAge || 0) / 1000)}s)`);
|
||||
refreshJobsInBackground();
|
||||
} else {
|
||||
const jobs = await jobCache.refreshJobs();
|
||||
allJobs = jobs;
|
||||
filteredJobs = [...allJobs];
|
||||
renderJobs();
|
||||
console.log(`Loaded ${allJobs.length} jobs from server (cache updated)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch jobs:", err);
|
||||
const results = document.getElementById("results");
|
||||
results.innerHTML = '<div class="text-center text-red-600 p-4">Failed to load jobs. Please refresh the page.</div>';
|
||||
}
|
||||
}
|
||||
async function refreshJobsInBackground() {
|
||||
if (isRefreshing)
|
||||
return;
|
||||
isRefreshing = true;
|
||||
try {
|
||||
const freshJobs = await jobCache.refreshJobs();
|
||||
allJobs = freshJobs;
|
||||
applyFilters();
|
||||
console.log(`Background refresh complete (${freshJobs.length} jobs)`);
|
||||
} catch (err) {
|
||||
console.error("Background refresh failed:", err);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
function renderJobs() {
|
||||
const results = document.getElementById("results");
|
||||
if (filteredJobs.length === 0) {
|
||||
results.innerHTML = '<div class="text-center text-gray-500 p-4">No jobs found</div>';
|
||||
return;
|
||||
}
|
||||
const start = (currentPage - 1) * perPage;
|
||||
const end = start + perPage;
|
||||
const pageJobs = filteredJobs.slice(start, end);
|
||||
results.innerHTML = pageJobs.map((job) => {
|
||||
const isActiveTrue = job.Active === true;
|
||||
const isActiveFalse = job.Active === false;
|
||||
const nameVal = job.Job_Full_Name || "";
|
||||
const contactVal = job.Contact_Person || "";
|
||||
return `
|
||||
<div class="bg-white rounded-lg p-2.5 border border-blue-100 shadow-sm cursor-pointer relative flex flex-col justify-center gap-1 hover:bg-gray-50 transition-colors" data-job-number="${escapeHtml(String(job.Job_Number || ""))}">
|
||||
<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(String(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(String(nameVal))}</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(String(contactVal))}</div></div>
|
||||
<div class="absolute top-2.5 right-2.5 w-3.5 h-3.5 rounded-full border-2 border-white shadow-sm" style="background:${isActiveTrue ? "green" : isActiveFalse ? "red" : "#cbd5e1"}"></div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
results.querySelectorAll("[data-job-number]").forEach((el) => {
|
||||
el.addEventListener("click", (e) => {
|
||||
const jobNumber = e.currentTarget.dataset.jobNumber;
|
||||
if (jobNumber) {
|
||||
openDetails(jobNumber);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function openDetails(jobNumber) {
|
||||
window.location.href = `/details.html?job=${encodeURIComponent(jobNumber)}`;
|
||||
}
|
||||
function setupSearch() {
|
||||
const searchBox = document.getElementById("searchBox");
|
||||
searchBox.addEventListener("input", () => {
|
||||
const query = searchBox.value.toLowerCase().trim();
|
||||
if (!query) {
|
||||
filteredJobs = [...allJobs];
|
||||
} else {
|
||||
filteredJobs = allJobs.filter((job) => {
|
||||
return job.Job_Number?.toString().toLowerCase().includes(query) || job.Job_Full_Name?.toLowerCase().includes(query) || job.Job_Address?.toLowerCase().includes(query) || job.Contact_Person?.toLowerCase().includes(query) || job.Company_Client?.toLowerCase().includes(query) || job.Notes?.toLowerCase().includes(query) || job.Job_Status?.toLowerCase().includes(query) || job.Status?.toLowerCase().includes(query) || job.Estimator?.toLowerCase().includes(query);
|
||||
});
|
||||
}
|
||||
currentPage = 1;
|
||||
renderJobs();
|
||||
});
|
||||
}
|
||||
function setupFilters() {
|
||||
const filterToggle = document.getElementById("filterToggle");
|
||||
const filtersPanel = document.getElementById("filtersPanel");
|
||||
const clearFilters = document.getElementById("clearFilters");
|
||||
filterToggle.addEventListener("click", () => {
|
||||
const isHidden = filtersPanel.classList.contains("hidden");
|
||||
if (isHidden) {
|
||||
filtersPanel.classList.remove("hidden");
|
||||
filterToggle.textContent = "Hide Filters";
|
||||
} else {
|
||||
filtersPanel.classList.add("hidden");
|
||||
filterToggle.textContent = "Show Filters";
|
||||
}
|
||||
});
|
||||
const filterInputs = filtersPanel.querySelectorAll('input[type="checkbox"]');
|
||||
filterInputs.forEach((input) => {
|
||||
input.addEventListener("change", applyFilters);
|
||||
});
|
||||
clearFilters.addEventListener("click", () => {
|
||||
filterInputs.forEach((input) => {
|
||||
input.checked = false;
|
||||
});
|
||||
applyFilters();
|
||||
});
|
||||
}
|
||||
function applyFilters() {
|
||||
const divisions = Array.from(document.querySelectorAll(".filter-division:checked")).map((el) => el.value);
|
||||
const actives = Array.from(document.querySelectorAll(".filter-active:checked")).map((el) => el.value);
|
||||
const estimators = Array.from(document.querySelectorAll(".filter-estimator:checked")).map((el) => el.value.toLowerCase());
|
||||
const statuses = Array.from(document.querySelectorAll(".filter-status:checked")).map((el) => el.value);
|
||||
filteredJobs = allJobs.filter((job) => {
|
||||
const divisionVal = job.Job_Division || job.Division;
|
||||
const activeVal = job.Active === true || job.Active === "Yes" ? "Yes" : job.Active === false || job.Active === "No" ? "No" : "—";
|
||||
const estimatorVal = (job.Estimator || "").toLowerCase();
|
||||
const statusVal = job.Job_Status || job.Status;
|
||||
if (divisions.length > 0 && !divisions.some((d) => divisionVal?.startsWith(d)))
|
||||
return false;
|
||||
if (actives.length > 0 && !actives.includes(activeVal))
|
||||
return false;
|
||||
if (estimators.length > 0 && !estimators.some((e) => estimatorVal.includes(e)))
|
||||
return false;
|
||||
if (statuses.length > 0 && !statuses.includes(statusVal || ""))
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
currentPage = 1;
|
||||
renderJobs();
|
||||
}
|
||||
function setupVoiceSearch() {
|
||||
const voiceBtn = document.getElementById("voiceSearchBtn");
|
||||
const micIcon = document.getElementById("micIcon");
|
||||
const micRecordingIcon = document.getElementById("micRecordingIcon");
|
||||
const searchBox = document.getElementById("searchBox");
|
||||
if (!("webkitSpeechRecognition" in window) && !("SpeechRecognition" in window)) {
|
||||
voiceBtn.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
const recognition = new SpeechRecognition;
|
||||
recognition.continuous = false;
|
||||
recognition.interimResults = false;
|
||||
voiceBtn.addEventListener("click", () => {
|
||||
recognition.start();
|
||||
micIcon.classList.add("hidden");
|
||||
micRecordingIcon.classList.remove("hidden");
|
||||
});
|
||||
recognition.onresult = (event) => {
|
||||
const transcript = event.results[0][0].transcript;
|
||||
searchBox.value = transcript;
|
||||
searchBox.dispatchEvent(new Event("input"));
|
||||
};
|
||||
recognition.onend = () => {
|
||||
micIcon.classList.remove("hidden");
|
||||
micRecordingIcon.classList.add("hidden");
|
||||
};
|
||||
recognition.onerror = () => {
|
||||
micIcon.classList.remove("hidden");
|
||||
micRecordingIcon.classList.add("hidden");
|
||||
};
|
||||
}
|
||||
function setupSignOut() {
|
||||
const signOutBtn = document.getElementById("signOutBtn");
|
||||
signOutBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
window.location.href = "/signin.html";
|
||||
} catch (err) {
|
||||
console.error("Sign out failed:", err);
|
||||
window.location.href = "/signin.html";
|
||||
}
|
||||
});
|
||||
}
|
||||
async function init() {
|
||||
const isAuthenticated = await checkAuth();
|
||||
if (!isAuthenticated)
|
||||
return;
|
||||
setupSearch();
|
||||
setupFilters();
|
||||
setupVoiceSearch();
|
||||
setupSignOut();
|
||||
await fetchJobs();
|
||||
}
|
||||
function escapeHtml(str) {
|
||||
return str.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
init();
|
||||
@@ -0,0 +1,330 @@
|
||||
// PERMANENT: Home view - Jobs listing with zero-delay loading via IndexedDB cache
|
||||
// Wired to tokenService via backend API routes
|
||||
// Visual match to Mode1Test but uses new architecture
|
||||
//
|
||||
// Dependencies:
|
||||
// - Backend /api/auth/status for authentication check
|
||||
// - Backend /api/jobs for job data
|
||||
// - job-cache.ts for IndexedDB caching
|
||||
//
|
||||
// Features:
|
||||
// - Zero-delay load (displays cached jobs instantly)
|
||||
// - Background refresh after displaying cache
|
||||
// - Search with voice support (operates on cached data)
|
||||
// - Filters (division, active, estimator, status)
|
||||
// - Pagination
|
||||
// - Click job to open Details view
|
||||
|
||||
import { jobCache, type JobCard } from './job-cache.js';
|
||||
|
||||
interface Job {
|
||||
Job_Number: string;
|
||||
Job_Name?: string;
|
||||
Job_Full_Name?: string;
|
||||
Job_Address?: string;
|
||||
Job_Division?: string;
|
||||
Active?: boolean | string;
|
||||
Status?: string;
|
||||
Job_Status?: string;
|
||||
Estimator?: string;
|
||||
Contact_Person?: string;
|
||||
Company_Client?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
let allJobs: Job[] = [];
|
||||
let filteredJobs: Job[] = [];
|
||||
let currentPage = 1;
|
||||
const perPage = 50;
|
||||
let isRefreshing = false;
|
||||
|
||||
// Check authentication on page load
|
||||
async function checkAuth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch('/api/auth/status');
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.authenticated) {
|
||||
// Redirect to login
|
||||
window.location.href = '/signin.html';
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Auth check failed:', err);
|
||||
window.location.href = '/signin.html';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch jobs from backend (or cache)
|
||||
async function fetchJobs(): Promise<void> {
|
||||
try {
|
||||
// Initialize cache
|
||||
await jobCache.initialize();
|
||||
|
||||
// Check if cache is valid
|
||||
const isCacheValid = await jobCache.isCacheValid();
|
||||
|
||||
if (isCacheValid) {
|
||||
// Display cached jobs immediately (zero delay)
|
||||
const cachedJobs = await jobCache.getCachedJobs();
|
||||
allJobs = cachedJobs;
|
||||
filteredJobs = [...allJobs];
|
||||
renderJobs();
|
||||
|
||||
// Show cache age in console for debugging
|
||||
const cacheAge = await jobCache.getCacheAge();
|
||||
console.log(`Loaded ${allJobs.length} jobs from cache (age: ${Math.round((cacheAge || 0) / 1000)}s)`);
|
||||
|
||||
// Refresh in background (don't block UI)
|
||||
refreshJobsInBackground();
|
||||
} else {
|
||||
// No valid cache, fetch fresh data (no progress bar)
|
||||
const jobs = await jobCache.refreshJobs();
|
||||
allJobs = jobs;
|
||||
filteredJobs = [...allJobs];
|
||||
|
||||
renderJobs();
|
||||
console.log(`Loaded ${allJobs.length} jobs from server (cache updated)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch jobs:', err);
|
||||
|
||||
// Show error message
|
||||
const results = document.getElementById('results') as HTMLDivElement;
|
||||
results.innerHTML = '<div class="text-center text-red-600 p-4">Failed to load jobs. Please refresh the page.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Background refresh (silent, doesn't interrupt user)
|
||||
async function refreshJobsInBackground(): Promise<void> {
|
||||
if (isRefreshing) return;
|
||||
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const freshJobs = await jobCache.refreshJobs();
|
||||
|
||||
// Update in memory without interrupting user
|
||||
allJobs = freshJobs;
|
||||
|
||||
// Reapply current filters and search
|
||||
applyFilters();
|
||||
|
||||
console.log(`Background refresh complete (${freshJobs.length} jobs)`);
|
||||
} catch (err) {
|
||||
console.error('Background refresh failed:', err);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Render jobs to DOM
|
||||
function renderJobs(): void {
|
||||
const results = document.getElementById('results') as HTMLDivElement;
|
||||
|
||||
if (filteredJobs.length === 0) {
|
||||
results.innerHTML = '<div class="text-center text-gray-500 p-4">No jobs found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const start = (currentPage - 1) * perPage;
|
||||
const end = start + perPage;
|
||||
const pageJobs = filteredJobs.slice(start, end);
|
||||
|
||||
results.innerHTML = pageJobs.map(job => {
|
||||
const isActiveTrue = job.Active === true;
|
||||
const isActiveFalse = job.Active === false;
|
||||
const nameVal = job.Job_Full_Name || '';
|
||||
const contactVal = job.Contact_Person || '';
|
||||
|
||||
return `
|
||||
<div class="bg-white rounded-lg p-2.5 border border-blue-100 shadow-sm cursor-pointer relative flex flex-col justify-center gap-1 hover:bg-gray-50 transition-colors" data-job-number="${escapeHtml(String(job.Job_Number||''))}">
|
||||
<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(String(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(String(nameVal))}</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(String(contactVal))}</div></div>
|
||||
<div class="absolute top-2.5 right-2.5 w-3.5 h-3.5 rounded-full border-2 border-white shadow-sm" style="background:${isActiveTrue?'green':(isActiveFalse?'red':'#cbd5e1')}"></div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Add click handlers
|
||||
results.querySelectorAll('[data-job-number]').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
const jobNumber = (e.currentTarget as HTMLElement).dataset.jobNumber;
|
||||
if (jobNumber) {
|
||||
openDetails(jobNumber);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Open Details view
|
||||
function openDetails(jobNumber: string): void {
|
||||
// Navigate to details view with job number
|
||||
window.location.href = `/details.html?job=${encodeURIComponent(jobNumber)}`;
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
function setupSearch(): void {
|
||||
const searchBox = document.getElementById('searchBox') as HTMLInputElement;
|
||||
|
||||
searchBox.addEventListener('input', () => {
|
||||
const query = searchBox.value.toLowerCase().trim();
|
||||
|
||||
if (!query) {
|
||||
filteredJobs = [...allJobs];
|
||||
} else {
|
||||
filteredJobs = allJobs.filter(job => {
|
||||
return (
|
||||
job.Job_Number?.toString().toLowerCase().includes(query) ||
|
||||
job.Job_Full_Name?.toLowerCase().includes(query) ||
|
||||
job.Job_Address?.toLowerCase().includes(query) ||
|
||||
job.Contact_Person?.toLowerCase().includes(query) ||
|
||||
job.Company_Client?.toLowerCase().includes(query) ||
|
||||
job.Notes?.toLowerCase().includes(query) ||
|
||||
job.Job_Status?.toLowerCase().includes(query) ||
|
||||
job.Status?.toLowerCase().includes(query) ||
|
||||
job.Estimator?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
currentPage = 1;
|
||||
renderJobs();
|
||||
});
|
||||
}
|
||||
|
||||
// Filter functionality
|
||||
function setupFilters(): void {
|
||||
const filterToggle = document.getElementById('filterToggle') as HTMLButtonElement;
|
||||
const filtersPanel = document.getElementById('filtersPanel') as HTMLDivElement;
|
||||
const clearFilters = document.getElementById('clearFilters') as HTMLButtonElement;
|
||||
|
||||
filterToggle.addEventListener('click', () => {
|
||||
const isHidden = filtersPanel.classList.contains('hidden');
|
||||
if (isHidden) {
|
||||
filtersPanel.classList.remove('hidden');
|
||||
filterToggle.textContent = 'Hide Filters';
|
||||
} else {
|
||||
filtersPanel.classList.add('hidden');
|
||||
filterToggle.textContent = 'Show Filters';
|
||||
}
|
||||
});
|
||||
|
||||
// Apply filters on change
|
||||
const filterInputs = filtersPanel.querySelectorAll('input[type="checkbox"]');
|
||||
filterInputs.forEach(input => {
|
||||
input.addEventListener('change', applyFilters);
|
||||
});
|
||||
|
||||
clearFilters.addEventListener('click', () => {
|
||||
filterInputs.forEach(input => {
|
||||
(input as HTMLInputElement).checked = false;
|
||||
});
|
||||
applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
function applyFilters(): void {
|
||||
const divisions = Array.from(document.querySelectorAll('.filter-division:checked')).map(el => (el as HTMLInputElement).value);
|
||||
const actives = Array.from(document.querySelectorAll('.filter-active:checked')).map(el => (el as HTMLInputElement).value);
|
||||
const estimators = Array.from(document.querySelectorAll('.filter-estimator:checked')).map(el => (el as HTMLInputElement).value.toLowerCase());
|
||||
const statuses = Array.from(document.querySelectorAll('.filter-status:checked')).map(el => (el as HTMLInputElement).value);
|
||||
|
||||
filteredJobs = allJobs.filter(job => {
|
||||
const divisionVal = job.Job_Division || job.Division;
|
||||
const activeVal = job.Active === true || job.Active === 'Yes' ? 'Yes' : job.Active === false || job.Active === 'No' ? 'No' : '—';
|
||||
const estimatorVal = (job.Estimator || '').toLowerCase();
|
||||
const statusVal = job.Job_Status || job.Status;
|
||||
|
||||
if (divisions.length > 0 && !divisions.some(d => divisionVal?.startsWith(d))) return false;
|
||||
if (actives.length > 0 && !actives.includes(activeVal)) return false;
|
||||
if (estimators.length > 0 && !estimators.some(e => estimatorVal.includes(e))) return false;
|
||||
if (statuses.length > 0 && !statuses.includes(statusVal || '')) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
currentPage = 1;
|
||||
renderJobs();
|
||||
}
|
||||
|
||||
// Voice search
|
||||
function setupVoiceSearch(): void {
|
||||
const voiceBtn = document.getElementById('voiceSearchBtn') as HTMLButtonElement;
|
||||
const micIcon = document.getElementById('micIcon') as SVGElement;
|
||||
const micRecordingIcon = document.getElementById('micRecordingIcon') as SVGElement;
|
||||
const searchBox = document.getElementById('searchBox') as HTMLInputElement;
|
||||
|
||||
if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) {
|
||||
voiceBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.continuous = false;
|
||||
recognition.interimResults = false;
|
||||
|
||||
voiceBtn.addEventListener('click', () => {
|
||||
recognition.start();
|
||||
micIcon.classList.add('hidden');
|
||||
micRecordingIcon.classList.remove('hidden');
|
||||
});
|
||||
|
||||
recognition.onresult = (event: any) => {
|
||||
const transcript = event.results[0][0].transcript;
|
||||
searchBox.value = transcript;
|
||||
searchBox.dispatchEvent(new Event('input'));
|
||||
};
|
||||
|
||||
recognition.onend = () => {
|
||||
micIcon.classList.remove('hidden');
|
||||
micRecordingIcon.classList.add('hidden');
|
||||
};
|
||||
|
||||
recognition.onerror = () => {
|
||||
micIcon.classList.remove('hidden');
|
||||
micRecordingIcon.classList.add('hidden');
|
||||
};
|
||||
}
|
||||
|
||||
// Sign out
|
||||
function setupSignOut(): void {
|
||||
const signOutBtn = document.getElementById('signOutBtn') as HTMLButtonElement;
|
||||
|
||||
signOutBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
window.location.href = '/signin.html';
|
||||
} catch (err) {
|
||||
console.error('Sign out failed:', err);
|
||||
window.location.href = '/signin.html';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
async function init(): Promise<void> {
|
||||
const isAuthenticated = await checkAuth();
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
setupSearch();
|
||||
setupFilters();
|
||||
setupVoiceSearch();
|
||||
setupSignOut();
|
||||
|
||||
await fetchJobs();
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
}
|
||||
|
||||
// Start
|
||||
init();
|
||||
@@ -0,0 +1,129 @@
|
||||
// job-cache.ts
|
||||
var DB_NAME = "JobInfoCache";
|
||||
var DB_VERSION = 2;
|
||||
var JOBS_STORE = "jobs";
|
||||
var META_STORE = "metadata";
|
||||
var CACHE_TTL = 5 * 60 * 1000;
|
||||
var PAGE_SIZE = 500;
|
||||
|
||||
class JobCache {
|
||||
db = null;
|
||||
async initialize() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
resolve();
|
||||
};
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains(JOBS_STORE)) {
|
||||
db.createObjectStore(JOBS_STORE, { keyPath: "Job_Number" });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(META_STORE)) {
|
||||
db.createObjectStore(META_STORE);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
async getCachedJobs() {
|
||||
if (!this.db)
|
||||
throw new Error("Database not initialized");
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([JOBS_STORE], "readonly");
|
||||
const store = transaction.objectStore(JOBS_STORE);
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
async isCacheValid() {
|
||||
if (!this.db)
|
||||
return false;
|
||||
try {
|
||||
const metadata = await this.getMetadata();
|
||||
if (!metadata || !metadata.lastUpdated)
|
||||
return false;
|
||||
const age = Date.now() - metadata.lastUpdated;
|
||||
return age < CACHE_TTL;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async saveJobs(jobs) {
|
||||
if (!this.db)
|
||||
throw new Error("Database not initialized");
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([JOBS_STORE, META_STORE], "readwrite");
|
||||
const jobsStore = transaction.objectStore(JOBS_STORE);
|
||||
const metaStore = transaction.objectStore(META_STORE);
|
||||
jobsStore.clear();
|
||||
jobs.forEach((job) => jobsStore.add(job));
|
||||
const metadata = {
|
||||
lastUpdated: Date.now(),
|
||||
version: DB_VERSION
|
||||
};
|
||||
metaStore.put(metadata, "main");
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
async refreshJobs() {
|
||||
try {
|
||||
let page = 1;
|
||||
let all = [];
|
||||
let totalPages = 1;
|
||||
while (page <= totalPages) {
|
||||
const url = `/api/jobs?page=${page}&perPage=${PAGE_SIZE}&sort=-Job_Number`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch jobs (status ${response.status})`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const items = data.items || [];
|
||||
const reportedTotalPages = data.totalPages || 1;
|
||||
all = all.concat(items);
|
||||
totalPages = Math.max(totalPages, reportedTotalPages);
|
||||
page += 1;
|
||||
}
|
||||
await this.saveJobs(all);
|
||||
return all;
|
||||
} catch (err) {
|
||||
console.error("Failed to refresh jobs cache:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
async getMetadata() {
|
||||
if (!this.db)
|
||||
return null;
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([META_STORE], "readonly");
|
||||
const store = transaction.objectStore(META_STORE);
|
||||
const request = store.get("main");
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
async clearCache() {
|
||||
if (!this.db)
|
||||
throw new Error("Database not initialized");
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([JOBS_STORE, META_STORE], "readwrite");
|
||||
transaction.objectStore(JOBS_STORE).clear();
|
||||
transaction.objectStore(META_STORE).clear();
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
async getCacheAge() {
|
||||
const metadata = await this.getMetadata();
|
||||
if (!metadata || !metadata.lastUpdated)
|
||||
return null;
|
||||
return Date.now() - metadata.lastUpdated;
|
||||
}
|
||||
}
|
||||
var jobCache = new JobCache;
|
||||
export {
|
||||
jobCache
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
// PERMANENT: IndexedDB cache for jobs data
|
||||
// Provides lightning-fast job listing with zero-delay loading
|
||||
//
|
||||
// Strategy:
|
||||
// - Display cached jobs instantly on page load (0ms delay)
|
||||
// - Fetch fresh data in background after displaying cache
|
||||
// - Cache TTL: 5 minutes (configurable)
|
||||
// - Stores minimal fields for card display only
|
||||
//
|
||||
// Fields cached: Job_Number, Job_Name, Division, Active, Status, Estimator
|
||||
//
|
||||
// Usage:
|
||||
// await jobCache.initialize();
|
||||
// const jobs = await jobCache.getCachedJobs(); // Instant
|
||||
// await jobCache.refreshJobs(); // Background refresh
|
||||
|
||||
export interface JobCard {
|
||||
Job_Number: string;
|
||||
Job_Name: string;
|
||||
Division: string;
|
||||
Active: string;
|
||||
Status: string;
|
||||
Estimator: string;
|
||||
}
|
||||
|
||||
interface CacheMetadata {
|
||||
lastUpdated: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
const DB_NAME = 'JobInfoCache';
|
||||
const DB_VERSION = 2; // bump to invalidate old minimal-field cache
|
||||
const JOBS_STORE = 'jobs';
|
||||
const META_STORE = 'metadata';
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
const PAGE_SIZE = 500; // match Mode1 bulk fetch
|
||||
|
||||
class JobCache {
|
||||
private db: IDBDatabase | null = null;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
// Create jobs store with Job_Number as key
|
||||
if (!db.objectStoreNames.contains(JOBS_STORE)) {
|
||||
db.createObjectStore(JOBS_STORE, { keyPath: 'Job_Number' });
|
||||
}
|
||||
|
||||
// Create metadata store
|
||||
if (!db.objectStoreNames.contains(META_STORE)) {
|
||||
db.createObjectStore(META_STORE);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getCachedJobs(): Promise<JobCard[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction([JOBS_STORE], 'readonly');
|
||||
const store = transaction.objectStore(JOBS_STORE);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async isCacheValid(): Promise<boolean> {
|
||||
if (!this.db) return false;
|
||||
|
||||
try {
|
||||
const metadata = await this.getMetadata();
|
||||
if (!metadata || !metadata.lastUpdated) return false;
|
||||
|
||||
const age = Date.now() - metadata.lastUpdated;
|
||||
return age < CACHE_TTL;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async saveJobs(jobs: JobCard[]): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction([JOBS_STORE, META_STORE], 'readwrite');
|
||||
const jobsStore = transaction.objectStore(JOBS_STORE);
|
||||
const metaStore = transaction.objectStore(META_STORE);
|
||||
|
||||
// Clear existing jobs
|
||||
jobsStore.clear();
|
||||
|
||||
// Save new jobs
|
||||
jobs.forEach(job => jobsStore.add(job));
|
||||
|
||||
// Update metadata
|
||||
const metadata: CacheMetadata = {
|
||||
lastUpdated: Date.now(),
|
||||
version: DB_VERSION
|
||||
};
|
||||
metaStore.put(metadata, 'main');
|
||||
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
async refreshJobs(): Promise<JobCard[]> {
|
||||
try {
|
||||
// Fetch fresh data from backend with pagination to mirror Mode1 behavior
|
||||
let page = 1;
|
||||
let all: JobCard[] = [];
|
||||
let totalPages = 1;
|
||||
|
||||
while (page <= totalPages) {
|
||||
const url = `/api/jobs?page=${page}&perPage=${PAGE_SIZE}&sort=-Job_Number`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch jobs (status ${response.status})`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const items: JobCard[] = data.items || [];
|
||||
const reportedTotalPages = data.totalPages || 1;
|
||||
|
||||
all = all.concat(items);
|
||||
totalPages = Math.max(totalPages, reportedTotalPages);
|
||||
page += 1;
|
||||
}
|
||||
|
||||
// Save to cache
|
||||
await this.saveJobs(all);
|
||||
|
||||
return all;
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh jobs cache:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getMetadata(): Promise<CacheMetadata | null> {
|
||||
if (!this.db) return null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction([META_STORE], 'readonly');
|
||||
const store = transaction.objectStore(META_STORE);
|
||||
const request = store.get('main');
|
||||
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async clearCache(): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction([JOBS_STORE, META_STORE], 'readwrite');
|
||||
|
||||
transaction.objectStore(JOBS_STORE).clear();
|
||||
transaction.objectStore(META_STORE).clear();
|
||||
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getCacheAge(): Promise<number | null> {
|
||||
const metadata = await this.getMetadata();
|
||||
if (!metadata || !metadata.lastUpdated) return null;
|
||||
|
||||
return Date.now() - metadata.lastUpdated;
|
||||
}
|
||||
}
|
||||
|
||||
export const jobCache = new JobCache();
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Manager</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="font-sans bg-gray-100 p-4">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-4 text-blue-600">Manager</h1>
|
||||
<p class="text-gray-600">Manager view - Under construction</p>
|
||||
<a href="/home.html" class="text-blue-600 underline mt-4 inline-block">← Back to Home</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Navigator</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="font-sans bg-gray-100 p-4">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-4 text-blue-600">Navigator</h1>
|
||||
<p class="text-gray-600">Navigator view - Under construction</p>
|
||||
<a href="/home.html" class="text-blue-600 underline mt-4 inline-block">← Back to Home</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -48,14 +48,23 @@
|
||||
console.log('[Auth] Graph token found:', !!graphToken);
|
||||
|
||||
// Send to backend to store
|
||||
await fetch('/api/auth/login', {
|
||||
const saveResponse = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pbToken: authData.token, graphToken: graphToken || undefined })
|
||||
});
|
||||
|
||||
// Redirect to app immediately
|
||||
window.location.href = '/index.html';
|
||||
if (!saveResponse.ok) {
|
||||
throw new Error('Failed to save tokens to backend');
|
||||
}
|
||||
|
||||
console.log('[Auth] Tokens saved to backend, redirecting to home');
|
||||
|
||||
// Clear PocketBase local storage to avoid conflicts
|
||||
pb.authStore.clear();
|
||||
|
||||
// Redirect to home page
|
||||
window.location.href = '/home.html';
|
||||
} catch (err) {
|
||||
document.getElementById('error').textContent = err.message || 'Login failed';
|
||||
document.getElementById('error').classList.remove('hidden');
|
||||
@@ -64,10 +73,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already logged in
|
||||
if (pb.authStore.isValid) {
|
||||
window.location.href = '/index.html';
|
||||
// Check if already logged in via backend only
|
||||
fetch('/api/auth/status')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.authenticated) {
|
||||
window.location.href = '/home.html';
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Viewer</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="font-sans bg-gray-100 p-4">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-4 text-blue-600">Viewer</h1>
|
||||
<p class="text-gray-600">Viewer view - Under construction</p>
|
||||
<a href="/home.html" class="text-blue-600 underline mt-4 inline-block">← Back to Home</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"pbToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJleHAiOjE4MDAxOTk4MTksImlkIjoiMWhiaWY5ZGJxbmc4Nmg5IiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.CqJgLgGfLOk4Wk9ro16MwtVjSMYG2oY4s9GV_tLBvvc",
|
||||
"pbTokenExpiry": 1769268619806,
|
||||
"graphToken": "eyJ0eXAiOiJKV1QiLCJub25jZSI6IlBQamVpUk5STGVWbkpNWVJSNHlzVENhMEtlZzJTeEhwOTFLbll6NUxYcGciLCJhbGciOiJSUzI1NiIsIng1dCI6IlBjWDk4R1g0MjBUMVg2c0JEa3poUW1xZ3dNVSIsImtpZCI6IlBjWDk4R1g0MjBUMVg2c0JEa3poUW1xZ3dNVSJ9.eyJhdWQiOiIwMDAwMDAwMy0wMDAwLTAwMDAtYzAwMC0wMDAwMDAwMDAwMDAiLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC8zZmQ5N2VhNy1iMTI0LTQxZjEtODU1Zi01MmQ4YWMzYjE2YzcvIiwiaWF0IjoxNzY4NjYzNTE5LCJuYmYiOjE3Njg2NjM1MTksImV4cCI6MTc2ODY2ODg3NSwiYWNjdCI6MCwiYWNyIjoiMSIsImFjcnMiOlsicDEiXSwiYWlvIjoiQVpRQWEvOGJBQUFBeTlyZWNsVzErcHErWE5hNXY0MkVrQUhaTEZwbGJ2SVJxZzFGWFprcWphbTR0eXk2dFdUTVFXZXNobGdJUFg2ZThMUXhoV2hrRE5MRDBuNE9mZW03a2QrcE1PM0ZPQTM5WmNFNFZjcWt1cWlTaUx1aFc0UDYyVkErOGV0ekZOOWFhcFdrMlR1T0ZQbVdpbDJEOEJ5QURaZmxFRTU5QkRtRVdsSTNvRkxZc3lvc2YwK1VtNGMyV2xsUTEwVjJQTFlLIiwiYW1yIjpbInB3ZCIsIm1mYSJdLCJhcHBfZGlzcGxheW5hbWUiOiJKb2JfSW5mb19TeW5jIiwiYXBwaWQiOiIzYzg0NmU3MS05NjA5LTQwZTEtYjQ1OC0wZWI4MDVlMjFiOWYiLCJhcHBpZGFjciI6IjEiLCJmYW1pbHlfbmFtZSI6IkV3aW5nIiwiZ2l2ZW5fbmFtZSI6IkFsZm9uc28iLCJpZHR5cCI6InVzZXIiLCJpcGFkZHIiOiIxNzIuNTkuNzIuMjQ4IiwibmFtZSI6IkFsZm9uc28gRXdpbmciLCJvaWQiOiIxYTZlOWQxMC0xMzhhLTQzZTQtYTA5ZS0xZjUwNDYzMTQ4YzkiLCJwbGF0ZiI6IjMiLCJwdWlkIjoiMTAwMzIwMDUxRkVCOEFDMCIsInJoIjoiMS5BVFVBcDM3WlB5U3g4VUdGWDFMWXJEc1d4d01BQUFBQUFBQUF3QUFBQUFBQUFBQmxBVWsxQUEuIiwic2NwIjoiQ2hhbm5lbC5SZWFkQmFzaWMuQWxsIENoYW5uZWxNZW1iZXIuUmVhZC5BbGwgQ2hhbm5lbE1lbWJlci5SZWFkV3JpdGUuQWxsIENoYW5uZWxNZXNzYWdlLkVkaXQgQ2hhbm5lbE1lc3NhZ2UuUmVhZC5BbGwgQ2hhbm5lbE1lc3NhZ2UuUmVhZFdyaXRlIENoYW5uZWxNZXNzYWdlLlNlbmQgQ2hhbm5lbFNldHRpbmdzLlJlYWQuQWxsIENoYW5uZWxTZXR0aW5ncy5SZWFkV3JpdGUuQWxsIENoYXRNZXNzYWdlLlJlYWQgQ2hhdE1lc3NhZ2UuU2VuZCBEaXJlY3RvcnkuQWNjZXNzQXNVc2VyLkFsbCBEaXJlY3RvcnkuUmVhZC5BbGwgRGlyZWN0b3J5LlJlYWRXcml0ZS5BbGwgZW1haWwgRmlsZXMuUmVhZFdyaXRlLkFsbCBHcm91cC5SZWFkLkFsbCBHcm91cC5SZWFkV3JpdGUuQWxsIEdyb3VwLUNvbnZlcnNhdGlvbi5SZWFkLkFsbCBHcm91cC1Db252ZXJzYXRpb24uUmVhZFdyaXRlLkFsbCBHcm91cE1lbWJlci5SZWFkLkFsbCBHcm91cE1lbWJlci5SZWFkV3JpdGUuQWxsIEdyb3VwU2V0dGluZ3MuUmVhZC5BbGwgR3JvdXBTZXR0aW5ncy5SZWFkV3JpdGUuQWxsIHByb2ZpbGUgU2l0ZXMuUmVhZFdyaXRlLkFsbCBUYXNrcy5SZWFkIFRhc2tzLlJlYWQuU2hhcmVkIFRhc2tzLlJlYWRXcml0ZSBUYXNrcy5SZWFkV3JpdGUuU2hhcmVkIFRlYW1zQWN0aXZpdHkuUmVhZCBUZWFtU2V0dGluZ3MuUmVhZC5BbGwgVGVhbVNldHRpbmdzLlJlYWRXcml0ZS5BbGwgVGVhbXNQb2xpY3lVc2VyQXNzaWduLlJlYWRXcml0ZS5BbGwgVXNlci5SZWFkIG9wZW5pZCIsInNpZCI6IjAwMTAzNDhhLTdkYWItNzBlZi1mODdmLWYyNGJhM2M5OTY2YyIsInNpZ25pbl9zdGF0ZSI6WyJrbXNpIl0sInN1YiI6IkFzVnNHelNzVE5hVm5SYk1sQ1p5VnlrWTJYejRIcktEaUVsdXlFWll2U3ciLCJ0ZW5hbnRfcmVnaW9uX3Njb3BlIjoiTkEiLCJ0aWQiOiIzZmQ5N2VhNy1iMTI0LTQxZjEtODU1Zi01MmQ4YWMzYjE2YzciLCJ1bmlxdWVfbmFtZSI6ImFld2luZ0BjYXJkb3phLmNvbnN0cnVjdGlvbiIsInVwbiI6ImFld2luZ0BjYXJkb3phLmNvbnN0cnVjdGlvbiIsInV0aSI6IjhadmQ0dkhBODA2Wmx1dnpJYkhaQUEiLCJ2ZXIiOiIxLjAiLCJ3aWRzIjpbImZlOTMwYmU3LTVlNjItNDdkYi05MWFmLTk4YzNhNDlhMzhiMSIsIjYyZTkwMzk0LTY5ZjUtNDIzNy05MTkwLTAxMjE3NzE0NWUxMCIsIjI5MjMyY2RmLTkzMjMtNDJmZC1hZGUyLTFkMDk3YWYzZTRkZSIsIjY5MDkxMjQ2LTIwZTgtNGE1Ni1hYTRkLTA2NjA3NWIyYTdhOCIsImI3OWZiZjRkLTNlZjktNDY4OS04MTQzLTc2YjE5NGU4NTUwOSJdLCJ4bXNfYWNkIjoxNzYzNDA1ODY0LCJ4bXNfYWN0X2ZjdCI6IjkgMyIsInhtc19mdGQiOiJvV0dWXzgxcHlJQksxWVlOYTJ6ck1sYlJsSENobmg2T1JtZVUwY1Y2d0ZBQmRYTjNaWE4wTXkxa2MyMXoiLCJ4bXNfaWRyZWwiOiIxIDQiLCJ4bXNfc3QiOnsic3ViIjoiQUhmNlFEcUV0ZHZEeXptWjB5TDJ0ZmRJLUltWVJxRlRJUWFMczdiSnZQYyJ9LCJ4bXNfc3ViX2ZjdCI6IjMgMiIsInhtc190Y2R0IjoxNTU4NTQ2MDA3LCJ4bXNfdG50X2ZjdCI6IjMgMiJ9.Dxx8iCgvn4kSLmXSdSKOwCB188jCQsLd-w5XBqNLuxlZXfzp8_DH_XVvZysGcffDrKkquSJ6oYOh4IjMy1ZLBl6lTz44Q3UoWiNZwZChsfNpyfk7lgI9ZnrLJizNFi9hpKOS28_pncT1ZRWJY6pIhs-coLO4_08CqynT3Vq_6LISwm1-rBOWr5A1_cP1An2Rnuu_3PEW8hmFHmMTvbXyIW9xxsExiNlTqr6w5h-dBMcOuzhuqRiDMhKplR8PaZrTdD8TizXAfaCepTyZPPa3rTKEn8jqiKmerit3mcx1H5RCFIgmxygdoTJVxXbEMx1SUfDb2t_M6r_yVjpNXfyqgQ",
|
||||
"graphTokenExpiry": 1768667419806,
|
||||
"lastRefresh": 1768663819806
|
||||
"pbToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJleHAiOjE4MDAyMTY5MjgsImlkIjoiMWhiaWY5ZGJxbmc4Nmg5IiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.PCwm9Ac67h_n-lLv1BdzrqXDCsjfkEcWqTcgnwT5cGQ",
|
||||
"pbTokenExpiry": 1769285728680,
|
||||
"graphToken": "eyJ0eXAiOiJKV1QiLCJub25jZSI6InRSOVQyM1h4RXhKRHN1YXJuSkdmcmYzd2FDS094TF9xWUkwd2dLUjhMVTQiLCJhbGciOiJSUzI1NiIsIng1dCI6IlBjWDk4R1g0MjBUMVg2c0JEa3poUW1xZ3dNVSIsImtpZCI6IlBjWDk4R1g0MjBUMVg2c0JEa3poUW1xZ3dNVSJ9.eyJhdWQiOiIwMDAwMDAwMy0wMDAwLTAwMDAtYzAwMC0wMDAwMDAwMDAwMDAiLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC8zZmQ5N2VhNy1iMTI0LTQxZjEtODU1Zi01MmQ4YWMzYjE2YzcvIiwiaWF0IjoxNzY4Njc4Mjk1LCJuYmYiOjE3Njg2NzgyOTUsImV4cCI6MTc2ODY4MjIxMSwiYWNjdCI6MCwiYWNyIjoiMSIsImFjcnMiOlsicDEiXSwiYWlvIjoiQWRRQUsvOGJBQUFBMW1BM04rN1pMMFU5OURDcUtsZ2JiOE1pSlhYRXZOT0dMQ2FjVllmdWZMTVM3b2N1emlvdkVmd1dZQnRBV2VyK0U5bGZxUU9FZ1lVZnBOODAxK2srMXBqOW5NRHlqdHhuRWVkaWI5Q1hZbThTMmFLeVNmZWFaQTFFZVdOa2VqWkduN2ZRQSsxLzE5ckVKcWlqMlRRRWZBMm9RTTlDamwxSE5QbGpGQ05iTHZwcmtETFIrT29CVlM4VlBXZi8zTU00Q1pzQ2ZiZnNRb3FuSGpzUHBUTzdFMzk1Qk5UQVhveXoxL1lRTkFucVhmNHRGajVaMUZVWW9jY1hES0NubEc0NXBmTVMxSHMreE1ZWVdnT3h0MkhJeVE9PSIsImFtciI6WyJyc2EiLCJtZmEiXSwiYXBwX2Rpc3BsYXluYW1lIjoiSm9iX0luZm9fU3luYyIsImFwcGlkIjoiM2M4NDZlNzEtOTYwOS00MGUxLWI0NTgtMGViODA1ZTIxYjlmIiwiYXBwaWRhY3IiOiIxIiwiZGV2aWNlaWQiOiJkZjdmZmUyOC04NDQ2LTQ5NjUtOWNlMC1jNDJiNjIxZmFiYjciLCJmYW1pbHlfbmFtZSI6IkV3aW5nIiwiZ2l2ZW5fbmFtZSI6IkFsZm9uc28iLCJpZHR5cCI6InVzZXIiLCJpcGFkZHIiOiIxNDMuMTA1LjI0Ny4xNDYiLCJuYW1lIjoiQWxmb25zbyBFd2luZyIsIm9pZCI6IjFhNmU5ZDEwLTEzOGEtNDNlNC1hMDllLTFmNTA0NjMxNDhjOSIsInBsYXRmIjoiMyIsInB1aWQiOiIxMDAzMjAwNTFGRUI4QUMwIiwicmgiOiIxLkFUVUFwMzdaUHlTeDhVR0ZYMUxZckRzV3h3TUFBQUFBQUFBQXdBQUFBQUFBQUFCbEFVazFBQS4iLCJzY3AiOiJDaGFubmVsLlJlYWRCYXNpYy5BbGwgQ2hhbm5lbE1lbWJlci5SZWFkLkFsbCBDaGFubmVsTWVtYmVyLlJlYWRXcml0ZS5BbGwgQ2hhbm5lbE1lc3NhZ2UuRWRpdCBDaGFubmVsTWVzc2FnZS5SZWFkLkFsbCBDaGFubmVsTWVzc2FnZS5SZWFkV3JpdGUgQ2hhbm5lbE1lc3NhZ2UuU2VuZCBDaGFubmVsU2V0dGluZ3MuUmVhZC5BbGwgQ2hhbm5lbFNldHRpbmdzLlJlYWRXcml0ZS5BbGwgQ2hhdE1lc3NhZ2UuUmVhZCBDaGF0TWVzc2FnZS5TZW5kIERpcmVjdG9yeS5BY2Nlc3NBc1VzZXIuQWxsIERpcmVjdG9yeS5SZWFkLkFsbCBEaXJlY3RvcnkuUmVhZFdyaXRlLkFsbCBlbWFpbCBGaWxlcy5SZWFkV3JpdGUuQWxsIEdyb3VwLlJlYWQuQWxsIEdyb3VwLlJlYWRXcml0ZS5BbGwgR3JvdXAtQ29udmVyc2F0aW9uLlJlYWQuQWxsIEdyb3VwLUNvbnZlcnNhdGlvbi5SZWFkV3JpdGUuQWxsIEdyb3VwTWVtYmVyLlJlYWQuQWxsIEdyb3VwTWVtYmVyLlJlYWRXcml0ZS5BbGwgR3JvdXBTZXR0aW5ncy5SZWFkLkFsbCBHcm91cFNldHRpbmdzLlJlYWRXcml0ZS5BbGwgcHJvZmlsZSBTaXRlcy5SZWFkV3JpdGUuQWxsIFRhc2tzLlJlYWQgVGFza3MuUmVhZC5TaGFyZWQgVGFza3MuUmVhZFdyaXRlIFRhc2tzLlJlYWRXcml0ZS5TaGFyZWQgVGVhbXNBY3Rpdml0eS5SZWFkIFRlYW1TZXR0aW5ncy5SZWFkLkFsbCBUZWFtU2V0dGluZ3MuUmVhZFdyaXRlLkFsbCBUZWFtc1BvbGljeVVzZXJBc3NpZ24uUmVhZFdyaXRlLkFsbCBVc2VyLlJlYWQgb3BlbmlkIiwic2lkIjoiMDA4ZDJjYjktODU5MS1jMDdkLTVhMjQtYzk2MjhkYzc1ZDIzIiwic2lnbmluX3N0YXRlIjpbImttc2kiXSwic3ViIjoiQXNWc0d6U3NUTmFWblJiTWxDWnlWeWtZMlh6NEhyS0RpRWx1eUVaWXZTdyIsInRlbmFudF9yZWdpb25fc2NvcGUiOiJOQSIsInRpZCI6IjNmZDk3ZWE3LWIxMjQtNDFmMS04NTVmLTUyZDhhYzNiMTZjNyIsInVuaXF1ZV9uYW1lIjoiYWV3aW5nQGNhcmRvemEuY29uc3RydWN0aW9uIiwidXBuIjoiYWV3aW5nQGNhcmRvemEuY29uc3RydWN0aW9uIiwidXRpIjoiVDljb0FWV3ZXVTJTbTBGLUFqS3lBQSIsInZlciI6IjEuMCIsIndpZHMiOlsiZmU5MzBiZTctNWU2Mi00N2RiLTkxYWYtOThjM2E0OWEzOGIxIiwiNjJlOTAzOTQtNjlmNS00MjM3LTkxOTAtMDEyMTc3MTQ1ZTEwIiwiMjkyMzJjZGYtOTMyMy00MmZkLWFkZTItMWQwOTdhZjNlNGRlIiwiNjkwOTEyNDYtMjBlOC00YTU2LWFhNGQtMDY2MDc1YjJhN2E4IiwiYjc5ZmJmNGQtM2VmOS00Njg5LTgxNDMtNzZiMTk0ZTg1NTA5Il0sInhtc19hY2QiOjE3NjM0MDU4NjQsInhtc19hY3RfZmN0IjoiOSAzIiwieG1zX2Z0ZCI6IkYyUXJpNWN3ZEhLQzVuZzFrYjdsU1huX3BlWXFaR3JUT21KeDYyN21LZUVCZFhOM1pYTjBNeTFrYzIxeiIsInhtc19pZHJlbCI6IjggMSIsInhtc19zdCI6eyJzdWIiOiJBSGY2UURxRXRkdkR5em1aMHlMMnRmZEktSW1ZUnFGVElRYUxzN2JKdlBjIn0sInhtc19zdWJfZmN0IjoiMyA2IiwieG1zX3RjZHQiOjE1NTg1NDYwMDcsInhtc190bnRfZmN0IjoiNiAzIn0.UqBTFe_GkrUqp9oHgjO93KtXckuDH91RQ03fV8ZzgUit4Vre1EXctVC-Zm5j5kfSM_6pb99VKasBPAolbIa4U-_J1yGidc81sghJj3Haz048v9_074Ky4Qzl0czNQ7OYTn2N1xKuYeBEzXCMt6Cc8gpGJv372seP0CVTs6ZxFTAfRXw2PvHsrJdiEJQMpMoSLBRC_bF9s223o6PiN3v1XgGbAqwP3WI-0GhtyK5Tnul0WJIH5i9G2sA3DlDAmxFm51RwxZtaeqhYPGNEkHrUQqBkNHeAiZ_VqvmGHyuj-D7E-XHOGkF8bQPCHWVXeLdZJNkOxxBEgqYNBpp2QWBWVg",
|
||||
"graphTokenExpiry": 1768684528680,
|
||||
"lastRefresh": 1768680928680
|
||||
}
|
||||
+4465
-15
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user