import Dexie, { type Table } from 'dexie'; import type { NostrEvent } from '@nostrify/nostrify'; import type { SkateSpot } from '@/lib/types'; // Define the database schema interface CachedSpot { id: string; // event id pubkey: string; identifier: string; event: NostrEvent; spot: SkateSpot; cachedAt: number; viewedAt: number; } interface PendingPost { id: string; // local uuid kind: number; content: string; tags: string[][]; createdAt: number; status: 'pending' | 'publishing' | 'failed'; retries: number; } interface OfflineSettings { key: string; value: string; } // Create the Dexie database class SkateSpotsCacheDB extends Dexie { spots!: Table; pendingPosts!: Table; settings!: Table; constructor() { super('SkateSpotsCacheDB'); this.version(1).stores({ spots: 'id, pubkey, identifier, [pubkey+identifier], cachedAt, viewedAt', pendingPosts: 'id, kind, status, createdAt', settings: 'key', }); } } // Singleton database instance let db: SkateSpotsCacheDB | null = null; function getDB(): SkateSpotsCacheDB { if (!db) { db = new SkateSpotsCacheDB(); } return db; } // ============ Spot Caching ============ /** * Cache a spot for offline viewing */ export async function cacheSpot(event: NostrEvent, spot: SkateSpot): Promise { const db = getDB(); const now = Date.now(); await db.spots.put({ id: event.id, pubkey: spot.pubkey, identifier: spot.identifier, event, spot, cachedAt: now, viewedAt: now, }); } /** * Cache multiple spots at once */ export async function cacheSpots(spotsWithEvents: Array<{ event: NostrEvent; spot: SkateSpot }>): Promise { const db = getDB(); const now = Date.now(); await db.spots.bulkPut( spotsWithEvents.map(({ event, spot }) => ({ id: event.id, pubkey: spot.pubkey, identifier: spot.identifier, event, spot, cachedAt: now, viewedAt: now, })) ); } /** * Get a cached spot by pubkey and identifier */ export async function getCachedSpot(pubkey: string, identifier: string): Promise { const db = getDB(); const cached = await db.spots .where('[pubkey+identifier]') .equals([pubkey, identifier]) .first(); if (cached) { // Update viewedAt await db.spots.update(cached.id, { viewedAt: Date.now() }); return cached.spot; } return null; } /** * Get all cached spots */ export async function getAllCachedSpots(): Promise { const db = getDB(); const cached = await db.spots.toArray(); return cached.map(c => c.spot); } /** * Get recently viewed spots (for offline browsing) */ export async function getRecentlyViewedSpots(limit: number = 50): Promise { const db = getDB(); const cached = await db.spots .orderBy('viewedAt') .reverse() .limit(limit) .toArray(); return cached.map(c => c.spot); } /** * Get cache statistics */ export async function getCacheStats(): Promise<{ spotCount: number; oldestCachedAt: number | null; newestCachedAt: number | null; totalSizeEstimate: number; }> { const db = getDB(); const spots = await db.spots.toArray(); if (spots.length === 0) { return { spotCount: 0, oldestCachedAt: null, newestCachedAt: null, totalSizeEstimate: 0, }; } const cachedTimes = spots.map(s => s.cachedAt); const totalSize = spots.reduce((acc, s) => acc + JSON.stringify(s).length, 0); return { spotCount: spots.length, oldestCachedAt: Math.min(...cachedTimes), newestCachedAt: Math.max(...cachedTimes), totalSizeEstimate: totalSize, }; } /** * Clear old cached spots (older than maxAge in milliseconds) */ export async function clearOldCache(maxAgeMs: number = 7 * 24 * 60 * 60 * 1000): Promise { const db = getDB(); const cutoff = Date.now() - maxAgeMs; const oldSpots = await db.spots .where('cachedAt') .below(cutoff) .primaryKeys(); await db.spots.bulkDelete(oldSpots); return oldSpots.length; } /** * Clear all cached spots */ export async function clearAllCache(): Promise { const db = getDB(); await db.spots.clear(); } // ============ Pending Posts Queue ============ /** * Add a post to the pending queue (for posting when back online) */ export async function queuePost(kind: number, content: string, tags: string[][]): Promise { const db = getDB(); const id = crypto.randomUUID(); await db.pendingPosts.add({ id, kind, content, tags, createdAt: Date.now(), status: 'pending', retries: 0, }); return id; } /** * Get all pending posts */ export async function getPendingPosts(): Promise { const db = getDB(); return db.pendingPosts .where('status') .equals('pending') .sortBy('createdAt'); } /** * Update a pending post's status */ export async function updatePendingPostStatus( id: string, status: 'pending' | 'publishing' | 'failed', retries?: number ): Promise { const db = getDB(); const update: Partial = { status }; if (retries !== undefined) { update.retries = retries; } await db.pendingPosts.update(id, update); } /** * Remove a pending post (after successful publish) */ export async function removePendingPost(id: string): Promise { const db = getDB(); await db.pendingPosts.delete(id); } /** * Get pending posts count */ export async function getPendingPostsCount(): Promise { const db = getDB(); return db.pendingPosts.where('status').equals('pending').count(); } // ============ Offline Detection ============ /** * Check if the app is currently offline */ export function isOffline(): boolean { return !navigator.onLine; } /** * Listen for online/offline status changes */ export function onConnectionChange(callback: (isOnline: boolean) => void): () => void { const handleOnline = () => callback(true); const handleOffline = () => callback(false); window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; } // ============ Settings ============ /** * Store a setting in the offline cache */ export async function setSetting(key: string, value: string): Promise { const db = getDB(); await db.settings.put({ key, value }); } /** * Get a setting from the offline cache */ export async function getSetting(key: string): Promise { const db = getDB(); const setting = await db.settings.get(key); return setting?.value ?? null; }