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 { Hono } from 'hono';
|
||||||
import type { Context } from 'hono';
|
import type { Context } from 'hono';
|
||||||
import { getTokens, saveTokens, clearTokens, hasValidPbToken } from './token-service';
|
import { tokenService } from './token-service';
|
||||||
|
|
||||||
// Authentication API routes
|
// Authentication API routes
|
||||||
// Endpoints for frontend to check auth status, get tokens, logout
|
// Endpoints for frontend to check auth status, get tokens, logout
|
||||||
|
// Depends on: tokenService singleton (centralized token management)
|
||||||
|
|
||||||
export function createAuthRoutes(): Hono {
|
export function createAuthRoutes(): Hono {
|
||||||
const router = new Hono();
|
const router = new Hono();
|
||||||
@@ -11,11 +12,13 @@ export function createAuthRoutes(): Hono {
|
|||||||
// PERMANENT: Check if user is authenticated
|
// PERMANENT: Check if user is authenticated
|
||||||
// Returns current auth status and which tokens are available
|
// Returns current auth status and which tokens are available
|
||||||
router.get('/status', async (c: Context) => {
|
router.get('/status', async (c: Context) => {
|
||||||
const hasValid = await hasValidPbToken();
|
const tokens = await tokenService.getTokens();
|
||||||
|
const hasToken = !!tokens?.pbToken;
|
||||||
return c.json({
|
return c.json({
|
||||||
authenticated: hasValid,
|
authenticated: hasToken,
|
||||||
pbTokenValid: hasValid,
|
pbTokenValid: hasToken,
|
||||||
message: hasValid ? 'User authenticated' : 'User not authenticated'
|
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
|
// Frontend/backend can call this to get fresh token state
|
||||||
// Only returns tokens if PB token is valid (auth required)
|
// Only returns tokens if PB token is valid (auth required)
|
||||||
router.get('/tokens', async (c: Context) => {
|
router.get('/tokens', async (c: Context) => {
|
||||||
const hasValid = await hasValidPbToken();
|
const tokens = await tokenService.getTokens();
|
||||||
if (!hasValid) {
|
|
||||||
return c.json({ error: 'Not authenticated' }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens = await getTokens();
|
|
||||||
return c.json({
|
return c.json({
|
||||||
pbToken: tokens?.pbToken || null,
|
pbToken: tokens?.pbToken || null,
|
||||||
graphToken: tokens?.graphToken || null,
|
graphToken: tokens?.graphToken || null,
|
||||||
pbTokenExpiry: tokens?.pbTokenExpiry || 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);
|
return c.json({ error: 'Missing pbToken' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveTokens(pbToken, graphToken);
|
await tokenService.saveTokens(pbToken, graphToken);
|
||||||
return c.json({
|
return c.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Login successful',
|
message: 'Login successful',
|
||||||
@@ -66,7 +66,7 @@ export function createAuthRoutes(): Hono {
|
|||||||
// Called when user clicks logout
|
// Called when user clicks logout
|
||||||
router.post('/logout', async (c: Context) => {
|
router.post('/logout', async (c: Context) => {
|
||||||
try {
|
try {
|
||||||
await clearTokens();
|
await tokenService.clearTokens();
|
||||||
return c.json({
|
return c.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Logged out',
|
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 { Hono } from 'hono';
|
||||||
import { serveStatic } from 'hono/bun';
|
import { serveStatic } from 'hono/bun';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { initializeTokenService, startBackgroundRefresh } from './token-service';
|
import { tokenService } from './token-service';
|
||||||
import { createAuthRoutes } from './auth-routes';
|
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
|
// PERMANENT: Initialize token service and start background refresh
|
||||||
// Loads existing tokens from file if available
|
// Loads existing tokens from file if available
|
||||||
// Starts 5-minute background refresh cycle
|
// Starts 5-minute background refresh cycle
|
||||||
initializeTokenService();
|
tokenService.initialize();
|
||||||
startBackgroundRefresh();
|
tokenService.startBackgroundRefresh();
|
||||||
|
|
||||||
// Create Hono app
|
// Create Hono app
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -16,6 +27,9 @@ const app = new Hono();
|
|||||||
// Mount auth routes
|
// Mount auth routes
|
||||||
app.route('/api/auth', createAuthRoutes());
|
app.route('/api/auth', createAuthRoutes());
|
||||||
|
|
||||||
|
// Mount jobs routes
|
||||||
|
app.route('/api/jobs', createJobsRoutes());
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
app.get('/health', (c) => {
|
app.get('/health', (c) => {
|
||||||
return c.text('OK');
|
return c.text('OK');
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
// Token storage and refresh management
|
// PERMANENT: Token Service Module
|
||||||
// Stores both PocketBase and Graph tokens locally (tokens.json)
|
// Singleton module for centralized token acquisition, storage, and refresh
|
||||||
// Background refresh keeps both tokens fresh without user intervention
|
// 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 {
|
interface TokenData {
|
||||||
pbToken: string;
|
pbToken: string;
|
||||||
@@ -14,131 +26,151 @@ interface TokenData {
|
|||||||
|
|
||||||
const tokensFilePath = join(import.meta.dir, '../../tokens.json');
|
const tokensFilePath = join(import.meta.dir, '../../tokens.json');
|
||||||
|
|
||||||
// PERMANENT: Token lifecycle management
|
class TokenService {
|
||||||
// - Tokens stored in tokens.json (persistent across restarts)
|
private backgroundStarted = false;
|
||||||
// - Never delete old token before new one acquired
|
|
||||||
// - Background refresh every 5 minutes
|
|
||||||
// - Startup: if PB token exists, resume refresh; else require login
|
|
||||||
|
|
||||||
export async function initializeTokenService(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
// Load tokens from file if they exist
|
// Load tokens from file if they exist
|
||||||
try {
|
|
||||||
const file = Bun.file(tokensFilePath);
|
|
||||||
if (await file.exists()) {
|
|
||||||
const content = await file.text();
|
|
||||||
const data = JSON.parse(content) as TokenData;
|
|
||||||
console.log('[AuthAndToken] Tokens loaded from file');
|
|
||||||
console.log('[AuthAndToken] PB token valid:', data.pbToken ? 'yes' : 'no');
|
|
||||||
console.log('[AuthAndToken] Graph token valid:', data.graphToken ? 'yes' : 'no');
|
|
||||||
} else {
|
|
||||||
console.log('[AuthAndToken] No tokens.json found - login required on startup');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.log('[AuthAndToken] Token file init error (will create on first login):', (err as Error)?.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
await refreshTokens();
|
const file = Bun.file(tokensFilePath);
|
||||||
} catch (err) {
|
if (await file.exists()) {
|
||||||
console.log('[AuthAndToken] Background refresh error:', (err as Error)?.message);
|
const content = await file.text();
|
||||||
}
|
const data = JSON.parse(content) as TokenData;
|
||||||
}, 5 * 60 * 1000); // 5 minutes
|
console.log('[AuthAndToken] Tokens loaded from file');
|
||||||
|
console.log('[AuthAndToken] PB token valid:', data.pbToken ? 'yes' : 'no');
|
||||||
console.log('[AuthAndToken] Background refresh started (every 5 minutes)');
|
console.log('[AuthAndToken] Graph token valid:', data.graphToken ? 'yes' : 'no');
|
||||||
}
|
} else {
|
||||||
|
console.log('[AuthAndToken] No tokens.json found - login required on startup');
|
||||||
export async function saveTokens(pbToken: string, graphToken?: string): Promise<void> {
|
|
||||||
// PERMANENT: Token persistence
|
|
||||||
// Called after login or successful refresh
|
|
||||||
// Stores both tokens with expiry times
|
|
||||||
|
|
||||||
const data: TokenData = {
|
|
||||||
pbToken,
|
|
||||||
pbTokenExpiry: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days (PB default)
|
|
||||||
graphToken,
|
|
||||||
graphTokenExpiry: graphToken ? Date.now() + 60 * 60 * 1000 : undefined, // 1 hour (Graph default)
|
|
||||||
lastRefresh: Date.now()
|
|
||||||
};
|
|
||||||
|
|
||||||
await Bun.write(tokensFilePath, JSON.stringify(data, null, 2));
|
|
||||||
console.log('[AuthAndToken] Tokens saved');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTokens(): Promise<TokenData | null> {
|
|
||||||
try {
|
|
||||||
const file = Bun.file(tokensFilePath);
|
|
||||||
if (!(await file.exists())) return null;
|
|
||||||
|
|
||||||
const content = await file.text();
|
|
||||||
return JSON.parse(content) as TokenData;
|
|
||||||
} catch (err) {
|
|
||||||
console.log('[AuthAndToken] Error reading tokens:', (err as Error)?.message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearTokens(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const file = Bun.file(tokensFilePath);
|
|
||||||
if (await file.exists()) {
|
|
||||||
await Bun.write(tokensFilePath, '');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.log('[AuthAndToken] Error clearing tokens:', (err as Error)?.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function 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();
|
|
||||||
if (!tokens || !tokens.pbToken) {
|
|
||||||
console.log('[AuthAndToken] No active tokens to refresh');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Refresh via PocketBase endpoint
|
|
||||||
const pbUrl = `${process.env.POCKETBASE_URL}/api/collections/users/auth-refresh`;
|
|
||||||
const response = await fetch(pbUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${tokens.pbToken}`
|
|
||||||
}
|
}
|
||||||
|
} 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;
|
||||||
|
|
||||||
|
// 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 this.refreshTokens();
|
||||||
|
} catch (err) {
|
||||||
|
console.log('[AuthAndToken] Background refresh error:', (err as Error)?.message);
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000); // 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
console.log('[AuthAndToken] Background refresh started (every 5 minutes + immediate)');
|
||||||
console.log('[AuthAndToken] Refresh failed:', response.status);
|
}
|
||||||
|
|
||||||
|
async saveTokens(pbToken: string, graphToken?: string): Promise<void> {
|
||||||
|
// PERMANENT: Token persistence
|
||||||
|
// Called after login or successful refresh
|
||||||
|
// Stores both tokens with expiry times
|
||||||
|
|
||||||
|
const data: TokenData = {
|
||||||
|
pbToken,
|
||||||
|
pbTokenExpiry: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days (PB default)
|
||||||
|
graphToken,
|
||||||
|
graphTokenExpiry: graphToken ? Date.now() + 60 * 60 * 1000 : undefined, // 1 hour (Graph default)
|
||||||
|
lastRefresh: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
await Bun.write(tokensFilePath, JSON.stringify(data, null, 2));
|
||||||
|
console.log('[AuthAndToken] Tokens saved');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTokens(): Promise<TokenData | null> {
|
||||||
|
try {
|
||||||
|
const file = Bun.file(tokensFilePath);
|
||||||
|
if (!(await file.exists())) return null;
|
||||||
|
|
||||||
|
const content = await file.text();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearTokens(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const file = Bun.file(tokensFilePath);
|
||||||
|
if (await file.exists()) {
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
await fs.unlink(tokensFilePath);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log('[AuthAndToken] Error clearing tokens:', (err as Error)?.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 this.getTokens();
|
||||||
|
if (!tokens || !tokens.pbToken) {
|
||||||
|
console.log('[AuthAndToken] No active tokens to refresh');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json() as any;
|
try {
|
||||||
const newPbToken = data?.token;
|
// Refresh via PocketBase endpoint
|
||||||
|
const pbUrl = `${process.env.POCKETBASE_URL}/api/collections/users/auth-refresh`;
|
||||||
|
const response = await fetch(pbUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${tokens.pbToken}`
|
||||||
|
},
|
||||||
|
// @ts-ignore Bun TLS option
|
||||||
|
tls: { rejectUnauthorized: false }
|
||||||
|
});
|
||||||
|
|
||||||
if (!newPbToken) {
|
if (!response.ok) {
|
||||||
console.log('[AuthAndToken] No new PB token in response');
|
console.log('[AuthAndToken] Refresh failed:', response.status);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as any;
|
||||||
|
const newPbToken = data?.token;
|
||||||
|
|
||||||
|
if (!newPbToken) {
|
||||||
|
console.log('[AuthAndToken] No new PB token in response');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tokens with new PB token
|
||||||
|
await this.saveTokens(newPbToken, tokens.graphToken);
|
||||||
|
console.log('[AuthAndToken] Tokens refreshed successfully');
|
||||||
|
} catch (err) {
|
||||||
|
console.log('[AuthAndToken] Refresh error:', (err as Error)?.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update tokens with new PB token
|
|
||||||
await 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> {
|
// PERMANENT: Singleton export
|
||||||
const tokens = await getTokens();
|
// Import as: import { tokenService } from './token-service.ts'
|
||||||
return !!tokens?.pbToken && tokens.pbTokenExpiry > Date.now();
|
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 charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Mode2Test</title>
|
<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>
|
<script>
|
||||||
const pb = new PocketBase('https://pocketbase.ccllc.pro');
|
// Redirect to appropriate page based on auth status
|
||||||
|
async function checkAuthAndRedirect() {
|
||||||
async function checkAuth() {
|
try {
|
||||||
if (!pb.authStore.isValid) {
|
const response = await fetch('/api/auth/status');
|
||||||
// Try to refresh
|
const data = await response.json();
|
||||||
try {
|
|
||||||
await pb.collection('users').authRefresh();
|
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 {
|
} catch {
|
||||||
window.location.href = '/signin.html';
|
window.location.href = '/signin.html';
|
||||||
return;
|
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);
|
console.log('[Auth] Graph token found:', !!graphToken);
|
||||||
|
|
||||||
// Send to backend to store
|
// Send to backend to store
|
||||||
await fetch('/api/auth/login', {
|
const saveResponse = await fetch('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ pbToken: authData.token, graphToken: graphToken || undefined })
|
body: JSON.stringify({ pbToken: authData.token, graphToken: graphToken || undefined })
|
||||||
});
|
});
|
||||||
|
|
||||||
// Redirect to app immediately
|
if (!saveResponse.ok) {
|
||||||
window.location.href = '/index.html';
|
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) {
|
} catch (err) {
|
||||||
document.getElementById('error').textContent = err.message || 'Login failed';
|
document.getElementById('error').textContent = err.message || 'Login failed';
|
||||||
document.getElementById('error').classList.remove('hidden');
|
document.getElementById('error').classList.remove('hidden');
|
||||||
@@ -64,10 +73,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already logged in
|
// Check if already logged in via backend only
|
||||||
if (pb.authStore.isValid) {
|
fetch('/api/auth/status')
|
||||||
window.location.href = '/index.html';
|
.then(res => res.json())
|
||||||
}
|
.then(data => {
|
||||||
|
if (data.authenticated) {
|
||||||
|
window.location.href = '/home.html';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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",
|
"pbToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJleHAiOjE4MDAyMTY5MjgsImlkIjoiMWhiaWY5ZGJxbmc4Nmg5IiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.PCwm9Ac67h_n-lLv1BdzrqXDCsjfkEcWqTcgnwT5cGQ",
|
||||||
"pbTokenExpiry": 1769268619806,
|
"pbTokenExpiry": 1769285728680,
|
||||||
"graphToken": "eyJ0eXAiOiJKV1QiLCJub25jZSI6IlBQamVpUk5STGVWbkpNWVJSNHlzVENhMEtlZzJTeEhwOTFLbll6NUxYcGciLCJhbGciOiJSUzI1NiIsIng1dCI6IlBjWDk4R1g0MjBUMVg2c0JEa3poUW1xZ3dNVSIsImtpZCI6IlBjWDk4R1g0MjBUMVg2c0JEa3poUW1xZ3dNVSJ9.eyJhdWQiOiIwMDAwMDAwMy0wMDAwLTAwMDAtYzAwMC0wMDAwMDAwMDAwMDAiLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC8zZmQ5N2VhNy1iMTI0LTQxZjEtODU1Zi01MmQ4YWMzYjE2YzcvIiwiaWF0IjoxNzY4NjYzNTE5LCJuYmYiOjE3Njg2NjM1MTksImV4cCI6MTc2ODY2ODg3NSwiYWNjdCI6MCwiYWNyIjoiMSIsImFjcnMiOlsicDEiXSwiYWlvIjoiQVpRQWEvOGJBQUFBeTlyZWNsVzErcHErWE5hNXY0MkVrQUhaTEZwbGJ2SVJxZzFGWFprcWphbTR0eXk2dFdUTVFXZXNobGdJUFg2ZThMUXhoV2hrRE5MRDBuNE9mZW03a2QrcE1PM0ZPQTM5WmNFNFZjcWt1cWlTaUx1aFc0UDYyVkErOGV0ekZOOWFhcFdrMlR1T0ZQbVdpbDJEOEJ5QURaZmxFRTU5QkRtRVdsSTNvRkxZc3lvc2YwK1VtNGMyV2xsUTEwVjJQTFlLIiwiYW1yIjpbInB3ZCIsIm1mYSJdLCJhcHBfZGlzcGxheW5hbWUiOiJKb2JfSW5mb19TeW5jIiwiYXBwaWQiOiIzYzg0NmU3MS05NjA5LTQwZTEtYjQ1OC0wZWI4MDVlMjFiOWYiLCJhcHBpZGFjciI6IjEiLCJmYW1pbHlfbmFtZSI6IkV3aW5nIiwiZ2l2ZW5fbmFtZSI6IkFsZm9uc28iLCJpZHR5cCI6InVzZXIiLCJpcGFkZHIiOiIxNzIuNTkuNzIuMjQ4IiwibmFtZSI6IkFsZm9uc28gRXdpbmciLCJvaWQiOiIxYTZlOWQxMC0xMzhhLTQzZTQtYTA5ZS0xZjUwNDYzMTQ4YzkiLCJwbGF0ZiI6IjMiLCJwdWlkIjoiMTAwMzIwMDUxRkVCOEFDMCIsInJoIjoiMS5BVFVBcDM3WlB5U3g4VUdGWDFMWXJEc1d4d01BQUFBQUFBQUF3QUFBQUFBQUFBQmxBVWsxQUEuIiwic2NwIjoiQ2hhbm5lbC5SZWFkQmFzaWMuQWxsIENoYW5uZWxNZW1iZXIuUmVhZC5BbGwgQ2hhbm5lbE1lbWJlci5SZWFkV3JpdGUuQWxsIENoYW5uZWxNZXNzYWdlLkVkaXQgQ2hhbm5lbE1lc3NhZ2UuUmVhZC5BbGwgQ2hhbm5lbE1lc3NhZ2UuUmVhZFdyaXRlIENoYW5uZWxNZXNzYWdlLlNlbmQgQ2hhbm5lbFNldHRpbmdzLlJlYWQuQWxsIENoYW5uZWxTZXR0aW5ncy5SZWFkV3JpdGUuQWxsIENoYXRNZXNzYWdlLlJlYWQgQ2hhdE1lc3NhZ2UuU2VuZCBEaXJlY3RvcnkuQWNjZXNzQXNVc2VyLkFsbCBEaXJlY3RvcnkuUmVhZC5BbGwgRGlyZWN0b3J5LlJlYWRXcml0ZS5BbGwgZW1haWwgRmlsZXMuUmVhZFdyaXRlLkFsbCBHcm91cC5SZWFkLkFsbCBHcm91cC5SZWFkV3JpdGUuQWxsIEdyb3VwLUNvbnZlcnNhdGlvbi5SZWFkLkFsbCBHcm91cC1Db252ZXJzYXRpb24uUmVhZFdyaXRlLkFsbCBHcm91cE1lbWJlci5SZWFkLkFsbCBHcm91cE1lbWJlci5SZWFkV3JpdGUuQWxsIEdyb3VwU2V0dGluZ3MuUmVhZC5BbGwgR3JvdXBTZXR0aW5ncy5SZWFkV3JpdGUuQWxsIHByb2ZpbGUgU2l0ZXMuUmVhZFdyaXRlLkFsbCBUYXNrcy5SZWFkIFRhc2tzLlJlYWQuU2hhcmVkIFRhc2tzLlJlYWRXcml0ZSBUYXNrcy5SZWFkV3JpdGUuU2hhcmVkIFRlYW1zQWN0aXZpdHkuUmVhZCBUZWFtU2V0dGluZ3MuUmVhZC5BbGwgVGVhbVNldHRpbmdzLlJlYWRXcml0ZS5BbGwgVGVhbXNQb2xpY3lVc2VyQXNzaWduLlJlYWRXcml0ZS5BbGwgVXNlci5SZWFkIG9wZW5pZCIsInNpZCI6IjAwMTAzNDhhLTdkYWItNzBlZi1mODdmLWYyNGJhM2M5OTY2YyIsInNpZ25pbl9zdGF0ZSI6WyJrbXNpIl0sInN1YiI6IkFzVnNHelNzVE5hVm5SYk1sQ1p5VnlrWTJYejRIcktEaUVsdXlFWll2U3ciLCJ0ZW5hbnRfcmVnaW9uX3Njb3BlIjoiTkEiLCJ0aWQiOiIzZmQ5N2VhNy1iMTI0LTQxZjEtODU1Zi01MmQ4YWMzYjE2YzciLCJ1bmlxdWVfbmFtZSI6ImFld2luZ0BjYXJkb3phLmNvbnN0cnVjdGlvbiIsInVwbiI6ImFld2luZ0BjYXJkb3phLmNvbnN0cnVjdGlvbiIsInV0aSI6IjhadmQ0dkhBODA2Wmx1dnpJYkhaQUEiLCJ2ZXIiOiIxLjAiLCJ3aWRzIjpbImZlOTMwYmU3LTVlNjItNDdkYi05MWFmLTk4YzNhNDlhMzhiMSIsIjYyZTkwMzk0LTY5ZjUtNDIzNy05MTkwLTAxMjE3NzE0NWUxMCIsIjI5MjMyY2RmLTkzMjMtNDJmZC1hZGUyLTFkMDk3YWYzZTRkZSIsIjY5MDkxMjQ2LTIwZTgtNGE1Ni1hYTRkLTA2NjA3NWIyYTdhOCIsImI3OWZiZjRkLTNlZjktNDY4OS04MTQzLTc2YjE5NGU4NTUwOSJdLCJ4bXNfYWNkIjoxNzYzNDA1ODY0LCJ4bXNfYWN0X2ZjdCI6IjkgMyIsInhtc19mdGQiOiJvV0dWXzgxcHlJQksxWVlOYTJ6ck1sYlJsSENobmg2T1JtZVUwY1Y2d0ZBQmRYTjNaWE4wTXkxa2MyMXoiLCJ4bXNfaWRyZWwiOiIxIDQiLCJ4bXNfc3QiOnsic3ViIjoiQUhmNlFEcUV0ZHZEeXptWjB5TDJ0ZmRJLUltWVJxRlRJUWFMczdiSnZQYyJ9LCJ4bXNfc3ViX2ZjdCI6IjMgMiIsInhtc190Y2R0IjoxNTU4NTQ2MDA3LCJ4bXNfdG50X2ZjdCI6IjMgMiJ9.Dxx8iCgvn4kSLmXSdSKOwCB188jCQsLd-w5XBqNLuxlZXfzp8_DH_XVvZysGcffDrKkquSJ6oYOh4IjMy1ZLBl6lTz44Q3UoWiNZwZChsfNpyfk7lgI9ZnrLJizNFi9hpKOS28_pncT1ZRWJY6pIhs-coLO4_08CqynT3Vq_6LISwm1-rBOWr5A1_cP1An2Rnuu_3PEW8hmFHmMTvbXyIW9xxsExiNlTqr6w5h-dBMcOuzhuqRiDMhKplR8PaZrTdD8TizXAfaCepTyZPPa3rTKEn8jqiKmerit3mcx1H5RCFIgmxygdoTJVxXbEMx1SUfDb2t_M6r_yVjpNXfyqgQ",
|
"graphToken": "eyJ0eXAiOiJKV1QiLCJub25jZSI6InRSOVQyM1h4RXhKRHN1YXJuSkdmcmYzd2FDS094TF9xWUkwd2dLUjhMVTQiLCJhbGciOiJSUzI1NiIsIng1dCI6IlBjWDk4R1g0MjBUMVg2c0JEa3poUW1xZ3dNVSIsImtpZCI6IlBjWDk4R1g0MjBUMVg2c0JEa3poUW1xZ3dNVSJ9.eyJhdWQiOiIwMDAwMDAwMy0wMDAwLTAwMDAtYzAwMC0wMDAwMDAwMDAwMDAiLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC8zZmQ5N2VhNy1iMTI0LTQxZjEtODU1Zi01MmQ4YWMzYjE2YzcvIiwiaWF0IjoxNzY4Njc4Mjk1LCJuYmYiOjE3Njg2NzgyOTUsImV4cCI6MTc2ODY4MjIxMSwiYWNjdCI6MCwiYWNyIjoiMSIsImFjcnMiOlsicDEiXSwiYWlvIjoiQWRRQUsvOGJBQUFBMW1BM04rN1pMMFU5OURDcUtsZ2JiOE1pSlhYRXZOT0dMQ2FjVllmdWZMTVM3b2N1emlvdkVmd1dZQnRBV2VyK0U5bGZxUU9FZ1lVZnBOODAxK2srMXBqOW5NRHlqdHhuRWVkaWI5Q1hZbThTMmFLeVNmZWFaQTFFZVdOa2VqWkduN2ZRQSsxLzE5ckVKcWlqMlRRRWZBMm9RTTlDamwxSE5QbGpGQ05iTHZwcmtETFIrT29CVlM4VlBXZi8zTU00Q1pzQ2ZiZnNRb3FuSGpzUHBUTzdFMzk1Qk5UQVhveXoxL1lRTkFucVhmNHRGajVaMUZVWW9jY1hES0NubEc0NXBmTVMxSHMreE1ZWVdnT3h0MkhJeVE9PSIsImFtciI6WyJyc2EiLCJtZmEiXSwiYXBwX2Rpc3BsYXluYW1lIjoiSm9iX0luZm9fU3luYyIsImFwcGlkIjoiM2M4NDZlNzEtOTYwOS00MGUxLWI0NTgtMGViODA1ZTIxYjlmIiwiYXBwaWRhY3IiOiIxIiwiZGV2aWNlaWQiOiJkZjdmZmUyOC04NDQ2LTQ5NjUtOWNlMC1jNDJiNjIxZmFiYjciLCJmYW1pbHlfbmFtZSI6IkV3aW5nIiwiZ2l2ZW5fbmFtZSI6IkFsZm9uc28iLCJpZHR5cCI6InVzZXIiLCJpcGFkZHIiOiIxNDMuMTA1LjI0Ny4xNDYiLCJuYW1lIjoiQWxmb25zbyBFd2luZyIsIm9pZCI6IjFhNmU5ZDEwLTEzOGEtNDNlNC1hMDllLTFmNTA0NjMxNDhjOSIsInBsYXRmIjoiMyIsInB1aWQiOiIxMDAzMjAwNTFGRUI4QUMwIiwicmgiOiIxLkFUVUFwMzdaUHlTeDhVR0ZYMUxZckRzV3h3TUFBQUFBQUFBQXdBQUFBQUFBQUFCbEFVazFBQS4iLCJzY3AiOiJDaGFubmVsLlJlYWRCYXNpYy5BbGwgQ2hhbm5lbE1lbWJlci5SZWFkLkFsbCBDaGFubmVsTWVtYmVyLlJlYWRXcml0ZS5BbGwgQ2hhbm5lbE1lc3NhZ2UuRWRpdCBDaGFubmVsTWVzc2FnZS5SZWFkLkFsbCBDaGFubmVsTWVzc2FnZS5SZWFkV3JpdGUgQ2hhbm5lbE1lc3NhZ2UuU2VuZCBDaGFubmVsU2V0dGluZ3MuUmVhZC5BbGwgQ2hhbm5lbFNldHRpbmdzLlJlYWRXcml0ZS5BbGwgQ2hhdE1lc3NhZ2UuUmVhZCBDaGF0TWVzc2FnZS5TZW5kIERpcmVjdG9yeS5BY2Nlc3NBc1VzZXIuQWxsIERpcmVjdG9yeS5SZWFkLkFsbCBEaXJlY3RvcnkuUmVhZFdyaXRlLkFsbCBlbWFpbCBGaWxlcy5SZWFkV3JpdGUuQWxsIEdyb3VwLlJlYWQuQWxsIEdyb3VwLlJlYWRXcml0ZS5BbGwgR3JvdXAtQ29udmVyc2F0aW9uLlJlYWQuQWxsIEdyb3VwLUNvbnZlcnNhdGlvbi5SZWFkV3JpdGUuQWxsIEdyb3VwTWVtYmVyLlJlYWQuQWxsIEdyb3VwTWVtYmVyLlJlYWRXcml0ZS5BbGwgR3JvdXBTZXR0aW5ncy5SZWFkLkFsbCBHcm91cFNldHRpbmdzLlJlYWRXcml0ZS5BbGwgcHJvZmlsZSBTaXRlcy5SZWFkV3JpdGUuQWxsIFRhc2tzLlJlYWQgVGFza3MuUmVhZC5TaGFyZWQgVGFza3MuUmVhZFdyaXRlIFRhc2tzLlJlYWRXcml0ZS5TaGFyZWQgVGVhbXNBY3Rpdml0eS5SZWFkIFRlYW1TZXR0aW5ncy5SZWFkLkFsbCBUZWFtU2V0dGluZ3MuUmVhZFdyaXRlLkFsbCBUZWFtc1BvbGljeVVzZXJBc3NpZ24uUmVhZFdyaXRlLkFsbCBVc2VyLlJlYWQgb3BlbmlkIiwic2lkIjoiMDA4ZDJjYjktODU5MS1jMDdkLTVhMjQtYzk2MjhkYzc1ZDIzIiwic2lnbmluX3N0YXRlIjpbImttc2kiXSwic3ViIjoiQXNWc0d6U3NUTmFWblJiTWxDWnlWeWtZMlh6NEhyS0RpRWx1eUVaWXZTdyIsInRlbmFudF9yZWdpb25fc2NvcGUiOiJOQSIsInRpZCI6IjNmZDk3ZWE3LWIxMjQtNDFmMS04NTVmLTUyZDhhYzNiMTZjNyIsInVuaXF1ZV9uYW1lIjoiYWV3aW5nQGNhcmRvemEuY29uc3RydWN0aW9uIiwidXBuIjoiYWV3aW5nQGNhcmRvemEuY29uc3RydWN0aW9uIiwidXRpIjoiVDljb0FWV3ZXVTJTbTBGLUFqS3lBQSIsInZlciI6IjEuMCIsIndpZHMiOlsiZmU5MzBiZTctNWU2Mi00N2RiLTkxYWYtOThjM2E0OWEzOGIxIiwiNjJlOTAzOTQtNjlmNS00MjM3LTkxOTAtMDEyMTc3MTQ1ZTEwIiwiMjkyMzJjZGYtOTMyMy00MmZkLWFkZTItMWQwOTdhZjNlNGRlIiwiNjkwOTEyNDYtMjBlOC00YTU2LWFhNGQtMDY2MDc1YjJhN2E4IiwiYjc5ZmJmNGQtM2VmOS00Njg5LTgxNDMtNzZiMTk0ZTg1NTA5Il0sInhtc19hY2QiOjE3NjM0MDU4NjQsInhtc19hY3RfZmN0IjoiOSAzIiwieG1zX2Z0ZCI6IkYyUXJpNWN3ZEhLQzVuZzFrYjdsU1huX3BlWXFaR3JUT21KeDYyN21LZUVCZFhOM1pYTjBNeTFrYzIxeiIsInhtc19pZHJlbCI6IjggMSIsInhtc19zdCI6eyJzdWIiOiJBSGY2UURxRXRkdkR5em1aMHlMMnRmZEktSW1ZUnFGVElRYUxzN2JKdlBjIn0sInhtc19zdWJfZmN0IjoiMyA2IiwieG1zX3RjZHQiOjE1NTg1NDYwMDcsInhtc190bnRfZmN0IjoiNiAzIn0.UqBTFe_GkrUqp9oHgjO93KtXckuDH91RQ03fV8ZzgUit4Vre1EXctVC-Zm5j5kfSM_6pb99VKasBPAolbIa4U-_J1yGidc81sghJj3Haz048v9_074Ky4Qzl0czNQ7OYTn2N1xKuYeBEzXCMt6Cc8gpGJv372seP0CVTs6ZxFTAfRXw2PvHsrJdiEJQMpMoSLBRC_bF9s223o6PiN3v1XgGbAqwP3WI-0GhtyK5Tnul0WJIH5i9G2sA3DlDAmxFm51RwxZtaeqhYPGNEkHrUQqBkNHeAiZ_VqvmGHyuj-D7E-XHOGkF8bQPCHWVXeLdZJNkOxxBEgqYNBpp2QWBWVg",
|
||||||
"graphTokenExpiry": 1768667419806,
|
"graphTokenExpiry": 1768684528680,
|
||||||
"lastRefresh": 1768663819806
|
"lastRefresh": 1768680928680
|
||||||
}
|
}
|
||||||
+4465
-15
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user