SkateSpots/src/lib/offlineCache.ts
shakespeare.diy e29d35f1e4 fix: restore full skate-spots source tree from misplaced src/src/ copy
The entire skate-spots codebase was accidentally copied into src/src/ and
public/public/ instead of src/ and public/ directly. This left the real
src/ directory as blank MKStack template stubs, explaining why the landing
page showed the default template instead of SkateSpots.

Changes made:
- Copied all files from src/src/ up to src/ (App.tsx, AppRouter.tsx, main.tsx,
  all pages, components, hooks, contexts, lib, i18n, test)
- Copied all files from public/public/ up to public/ (images, manifest, etc.)
- Deleted src/src/ and public/public/ nested directories
- Removed HelloWorld.tsx template stub from pages/
- Retained the previously fixed index.css (Tailwind v3 directives)

Full skate-spots feature set is now active:
- All 30+ routes (map, events, spots, coaches, marketplace, wallet, etc.)
- NWCProvider, LanguageProvider, PWAInstallPrompt, AmberCallbackHandler
- Skate community relays (goskatespots.nostr1.com, wotr.relatr.xyz, etc.)
- Outfit Variable font, Amber pre-check in main.tsx
- All hooks (useSkateSpots, useCoaches, useZaps, etc.)
- i18n support (en/es)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-15 22:18:28 +07:00

301 lines
6.6 KiB
TypeScript

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<CachedSpot, string>;
pendingPosts!: Table<PendingPost, string>;
settings!: Table<OfflineSettings, string>;
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<void> {
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<void> {
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<SkateSpot | null> {
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<SkateSpot[]> {
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<SkateSpot[]> {
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<number> {
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<void> {
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<string> {
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<PendingPost[]> {
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<void> {
const db = getDB();
const update: Partial<PendingPost> = { 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<void> {
const db = getDB();
await db.pendingPosts.delete(id);
}
/**
* Get pending posts count
*/
export async function getPendingPostsCount(): Promise<number> {
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<void> {
const db = getDB();
await db.settings.put({ key, value });
}
/**
* Get a setting from the offline cache
*/
export async function getSetting(key: string): Promise<string | null> {
const db = getDB();
const setting = await db.settings.get(key);
return setting?.value ?? null;
}