feat: tính năng offline cho upload ảnh và tải bản đồ

This commit is contained in:
2026-06-28 15:18:13 +07:00
parent 2f5ef424c8
commit f91fc7c5ac
10 changed files with 664 additions and 2 deletions
+181
View File
@@ -0,0 +1,181 @@
# To AI Agent: Implement Offline-First Architecture for Mobile App & Web Browsers (Offline Photo Upload & Offline Map Routing)
## 1. Context & Architectural Strategy
We are implementing an **Offline-First Capabilities Layer** for both browser environments (Chrome Android, iOS Safari) and mobile application wrappers. The application must remain functional when network connectivity is lost ($Network = 0$).
### Core Requirements:
1. **Offline Photo Upload Queue:** When a user uploads a photo without an active internet connection, the system must not throw a network exception. Instead, it must store the image file (Blob/ArrayBuffer) and its accompanying metadata (GPS, Timestamp) into a browser-native transactional database (**IndexedDB**).
2. **Background Sync Resume:** As soon as the device regains cellular/Wi-Fi data telemetry, a background synchronization worker must automatically trigger, reading the IndexedDB queue and completing the multi-part upload streams to the Debian server sequentially.
3. **Offline Map Navigation Routing:** Cache critical operational Map Layout Tiles and routing geometries via a persistent client-side caching framework (**Service Workers + Cache API**).
---
## 2. Technical Offline System Stack Overview
[Offline User Interaction]
├──➔ (Photo Upload) ──➔ Save Blob & EXIF Metadata ──➔ [ IndexedDB Storage ]
│ │ (Device re-connects)
│ ▼
│ [ Background Sync ] ──➔ POST to Server
└──➔ (Map Routing) ──➔ Request Map Assets ──➔ [ Service Worker Cache API ] ──➔ Render View
## 3. Code Refactoring Blueprint
### Step 1: Implement Offline Storage Queue Service (`offlineQueue.ts`)
Create a localized database management layer at `frontend/src/utils/offlineQueue.ts` leveraging IndexedDB to hold pending image uploads:
```typescript
import { openDB, IDBPDatabase } from 'idb';
const DB_NAME = 'yotrip-offline-db';
const STORE_NAME = 'pending-uploads';
interface OfflinePhoto {
id: string;
fileBlob: Blob;
fileName: string;
latitude: string | null;
longitude: string | null;
capturedAt: string | null;
timestamp: number;
}
// Initialize the local IndexedDB container safely
const getDB = (): Promise<IDBPDatabase> => {
return openDB(DB_NAME, 1, {
upgrade(db) {
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
},
});
};
// 1. STASH UPLOAD METADATA INTO OFFLINE STORAGE
export const queueOfflineUpload = async (photoData: Omit<OfflinePhoto, 'id' | 'timestamp'>) => {
const db = await getDB();
const id = crypto.randomUUID();
const item: OfflinePhoto = {
...photoData,
id,
timestamp: Date.now(),
};
await db.put(STORE_NAME, item);
console.log(`📦 Photo [${id}] stashed safely into local IndexedDB queue for offline background sync.`);
return id;
};
// 2. RETRIEVE ALL PENDING FILES ONCE ONLINE
export const getPendingUploads = async (): Promise<OfflinePhoto[]> => {
const db = await getDB();
return db.getAll(STORE_NAME);
};
// 3. REMOVE COMPLETED TRANSACTION FROM QUEUE
export const removePendingUpload = async (id: string) => {
const db = await getDB();
await db.delete(STORE_NAME, id);
};
### Step 2: Implement Background Synchronization Sync Engine (backgroundSync.ts)
Create an active network monitoring service that intercepts reconnection signals and synchronizes the local database records:
import { getPendingUploads, removePendingUpload } from './offlineQueue';
import { api } from '../services/api';
export const executeBackgroundSyncEngine = async (refreshMapPins: () => void) => {
const pendingItems = await getPendingUploads();
if (pendingItems.length === 0) return;
console.log(`🔄 Internet restored! Syncing [${pendingItems.length}] pending items to yotrip.labz.io.vn...`);
for (const item of pendingItems) {
const formData = new FormData();
formData.append('photo', item.fileBlob, item.fileName);
if (item.latitude && item.longitude) {
formData.append('latitude', item.latitude);
formData.append('longitude', item.longitude);
}
if (item.capturedAt) {
formData.append('capturedAt', item.capturedAt);
}
try {
// Dispatch payload to Debian production server endpoint
const response = await api.post('/photos/upload-with-meta', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (response.status === 200 || response.status === 201) {
// Remove item from local DB once server confirms safe receipt
await removePendingUpload(item.id);
console.log(`✅ Offline photo [${item.id}] synchronized successfully.`);
}
} catch (error) {
console.error(`✖ Failed to synchronize item [${item.id}]. Will retry on next connectivity cycle:`, error);
break; // Stop loop if server goes down mid-transit to protect execution queues
}
}
// Trigger reactive map layer update
refreshMapPins();
};
// Global Connectivity Listener initialization hook
export const initNetworkStatusListeners = (refreshMapPins: () => void) => {
window.addEventListener('online', () => executeBackgroundSyncEngine(refreshMapPins));
// Guard check on application startup in case network recovered while app was closed
if (navigator.onLine) {
executeBackgroundSyncEngine(refreshMapPins);
}
};
### Step 3: Configure Service Worker for Offline Map Tiles Cache (sw.js)
Configure your service worker script block (e.g., public/sw.js) to intercept and cache vector navigation maps or stylesheet assets statically:
const CACHE_NAME = 'yotrip-static-map-v1';
const MAP_TILE_PATTERN = /tiles\.maps\.lincoln|openstreetmap|mapbox/;
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll([
'/',
'/index.html',
'/src/main.tsx',
'/manifest.json',
'/assets/offline-map-placeholder.png' // Fallback image asset
]);
})
);
});
// NETWORK-FIRST FALLBACK TO CACHE STRATEGY FOR OFFLINE ROUTING MAPS
self.addEventListener('fetch', (event) => {
const requestUrl = event.request.url;
if (MAP_TILE_PATTERN.test(requestUrl)) {
event.respondWith(
fetch(event.request)
.then((response) => {
// Clone and update cache with newly acquired dynamic tile data layers
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return response;
})
.catch(() => {
// If offline, serve map tiles seamlessly straight from client-side Cache API
return caches.match(event.request);
})
);
}
});
## 4. Quality Verification & Acceptance Criteria for AI Agent[ ] Airplane Mode Upload Stability: Toggle Airplane mode ($Network = 0$) inside the Android Emulator/iOS Safari responsive inspector. Upload an image. The UI must transition seamlessly, displaying an "Ảnh đã được lưu tạm ngoại tuyến" notification banner without runtime exceptions.[ ] Automatic Queue Flushing Check: Disable Airplane mode. Verify through the browser network inspector tool that a series of asynchronous multi-part POST network requests fire automatically towards https://yotrip.labz.io.vn/api/ without requiring user interaction.[ ] Offline Map Cache Test: Clear your browser network connectivity states. Navigate around the map interface canvas. Previously inspected map sections and routing data blocks must remain fully rendered from the Service Worker cache layer.
+7 -1
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#10b981" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<script src="https://accounts.google.com/gsi/client" async defer></script>
<title>Travel Planner</title>
@@ -21,7 +27,7 @@
<meta name="twitter:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
<meta name="twitter:description" content="Khám phá những hành trình tuyệt vời và chia sẻ khoảnh khắc đẹp với cộng đồng du lịch." />
<meta name="twitter:image" content="https://yotrip.labz.io.vn/background.avif" />
<script type="module" crossorigin src="/assets/index-BtRV7JVM.js"></script>
<script type="module" crossorigin src="/assets/index-1GoR2rZV.js"></script>
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CMxvf4Kt.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-others-CNhtyHGs.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-editor-BDwQQzB8.js">
+6
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#10b981" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<script src="https://accounts.google.com/gsi/client" async defer></script>
<title>Travel Planner</title>
+18
View File
@@ -0,0 +1,18 @@
{
"name": "YoTrip - Khám phá chuyến đi",
"short_name": "YoTrip",
"description": "Chia sẻ ảnh du lịch và khám phá bản đồ cộng đồng",
"start_url": "/",
"display": "standalone",
"background_color": "#020617",
"theme_color": "#10b981",
"orientation": "portrait-primary",
"icons": [
{
"src": "/favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"categories": ["travel", "social", "maps"]
}
+116
View File
@@ -0,0 +1,116 @@
/**
* sw.js — YoTrip Service Worker
* ─────────────────────────────────────────────────────────────────────────────
* Strategy: Network-First with Cache-API fallback.
*
* Caches:
* 1. App Shell (static assets): cached at install time
* 2. OpenStreetMap tiles: cached dynamically on first fetch, served from
* cache when offline — so previously visited map areas remain navigable
*
* Cache names are versioned so old caches get evicted on SW update.
*/
const SHELL_CACHE = 'yotrip-shell-v1';
const MAP_CACHE = 'yotrip-map-tiles-v1';
// App shell files to pre-cache at install
const SHELL_URLS = [
'/',
'/index.html',
'/manifest.json',
'/favicon.ico',
];
// URL patterns for map tile providers
const MAP_TILE_ORIGINS = [
'tile.openstreetmap.org',
'a.tile.openstreetmap.org',
'b.tile.openstreetmap.org',
'c.tile.openstreetmap.org',
'tiles.stadiamaps.com',
'server.arcgisonline.com',
];
// ─── Install ──────────────────────────────────────────────────────────────
self.addEventListener('install', (event) => {
console.log('[SW] Installing yotrip service worker…');
event.waitUntil(
caches.open(SHELL_CACHE).then((cache) => {
return cache.addAll(SHELL_URLS).catch((err) => {
// Non-fatal: some shell files may not exist in dev mode
console.warn('[SW] Shell pre-cache partial failure (non-fatal):', err);
});
})
);
// Activate immediately without waiting for old tabs to close
self.skipWaiting();
});
// ─── Activate ─────────────────────────────────────────────────────────────
self.addEventListener('activate', (event) => {
const CURRENT_CACHES = [SHELL_CACHE, MAP_CACHE];
event.waitUntil(
caches.keys().then((cacheNames) =>
Promise.all(
cacheNames
.filter((name) => !CURRENT_CACHES.includes(name))
.map((name) => {
console.log('[SW] Evicting stale cache:', name);
return caches.delete(name);
})
)
)
);
// Take control of all open clients immediately
self.clients.claim();
});
// ─── Fetch ────────────────────────────────────────────────────────────────
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// 1. Map tile requests — Network-First, fall back to cache
const isMapTile = MAP_TILE_ORIGINS.some((origin) => url.hostname.includes(origin));
if (isMapTile) {
event.respondWith(
fetch(event.request)
.then((response) => {
if (response && response.status === 200) {
const clone = response.clone();
caches.open(MAP_CACHE).then((cache) => {
cache.put(event.request, clone);
});
}
return response;
})
.catch(() => caches.match(event.request))
);
return;
}
// 2. API calls — Network-Only (never cache API responses)
if (url.pathname.startsWith('/api/')) {
return; // Let the browser handle normally
}
// 3. App shell navigation — Cache-First for HTML, then network
if (event.request.mode === 'navigate') {
event.respondWith(
caches.match('/index.html').then((cached) => {
return cached || fetch(event.request);
})
);
return;
}
// 4. Static assets (JS/CSS/images) — Cache-First
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request);
})
);
});
+25 -1
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { LandingPage } from './pages/LandingPage';
import { ExploreMap } from './pages/ExploreMap';
import { TourDetailPage } from './pages/TourDetailPage';
@@ -13,6 +13,7 @@ import { ConfirmProvider } from './hooks/useConfirm';
import { NotificationProvider, useNotification } from './hooks/useNotification';
import { io } from 'socket.io-client';
import { Capacitor } from '@capacitor/core';
import { initNetworkStatusListeners } from './utils/backgroundSync';
interface GlobalNotificationListenerProps {
user: any;
@@ -141,6 +142,29 @@ function App() {
const [previousPage, setPreviousPage] = useState<'explore' | 'landing'>('explore');
const [navigationPayload, setNavigationPayload] = useState<{ tourId: string; origin: { lat: number; lng: number }; destination: { lat: number; lng: number; name: string }; tourTitle: string } | null>(null);
// ── Service Worker registration ─────────────────────────────────────────
useEffect(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('/sw.js', { scope: '/' })
.then((reg) => console.log('[SW] Registered, scope:', reg.scope))
.catch((err) => console.warn('[SW] Registration failed:', err));
}
}, []);
// ── Background sync network listeners ────────────────────────────────────
// refreshLanding is a stable callback that fires fetchPublicPhotos inside LandingPage.
// We use a window CustomEvent as a lightweight cross-component bus.
const triggerMapRefresh = useCallback(() => {
window.dispatchEvent(new CustomEvent('app:offlineSyncComplete'));
}, []);
useEffect(() => {
const cleanup = initNetworkStatusListeners(triggerMapRefresh);
return cleanup;
}, [triggerMapRefresh]);
// ── Auth + routing bootstrap ─────────────────────────────────────────────
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const viewTourId = params.get('viewTour');
+51
View File
@@ -15,6 +15,7 @@ import { processImageModeration } from '../hooks/useImageModeration';
import { useTranslation } from '../hooks/useTranslation';
import { processAndResizeImage } from '../utils/imageProcessor';
import { getDeviceLocation } from '../utils/geolocation';
import { queueOfflineUpload } from '../utils/offlineQueue';
import { Capacitor } from '@capacitor/core';
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
@@ -182,6 +183,16 @@ export const LandingPage: React.FC<LandingPageProps> = ({
fetchBlacklist();
}, []);
// Listen for background-sync completion event dispatched by App.tsx
useEffect(() => {
const handleSyncComplete = () => {
console.log('[LandingPage] Offline sync complete — refreshing public photos…');
fetchPublicPhotos();
};
window.addEventListener('app:offlineSyncComplete', handleSyncComplete);
return () => window.removeEventListener('app:offlineSyncComplete', handleSyncComplete);
}, []);
useEffect(() => {
if (publicPhotos.length <= 1) return;
const interval = setInterval(() => {
@@ -307,6 +318,46 @@ export const LandingPage: React.FC<LandingPageProps> = ({
if (!pendingPhotoFile) return;
setIsTagsModalOpen(false);
// ── OFFLINE GUARD ────────────────────────────────────────────────────────────
if (!navigator.onLine) {
try {
const token = localStorage.getItem('token') || localStorage.getItem('guest_token') || '';
const formFields: Record<string, string> = {};
if (pendingPhotoLocation) {
formFields['latitude'] = pendingPhotoLocation.latitude.toString();
formFields['longitude'] = pendingPhotoLocation.longitude.toString();
}
if (selectedTags.length > 0) {
formFields['tags'] = JSON.stringify(selectedTags);
}
await queueOfflineUpload({
endpoint: 'https://yotrip.labz.io.vn/api/v1/photos/upload-anonymous',
authToken: token,
fileBlob: pendingPhotoFile,
fileName: pendingPhotoFile.name,
formFields,
});
notify({
title: 'Ảnh đã được lưu tạm ngoại tuyến 📦',
message: 'Kết nối lại mạng, ảnh sẽ tự động được tải lên bản đồ.',
type: 'info',
});
} catch (err) {
notify({ title: 'Lỗi', message: 'Không thể lưu ảnh ngoại tuyến.', type: 'error' });
} finally {
setPendingPhotoFile(null);
setPendingPhotoLocation(null);
if (photoPreviewUrl) {
URL.revokeObjectURL(photoPreviewUrl);
setPhotoPreviewUrl('');
}
}
return;
}
// ── ONLINE PATH (original logic) ────────────────────────────────────────────────
notify({ title: 'Đang tải lên...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
try {
+32
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useState, useRef } from 'react';
import { io, Socket } from 'socket.io-client';
import { Capacitor } from '@capacitor/core';
import { BACKEND_URL } from '@/utils/backendEndpoint';
import { queueOfflineUpload } from '@/utils/offlineQueue';
import {
Compass,
Users,
@@ -287,6 +288,37 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
}
setIsLocating(false);
// ── OFFLINE GUARD ──────────────────────────────────────────────────────────
if (!navigator.onLine) {
try {
const token = localStorage.getItem('token') || '';
const formFields: Record<string, string> = {};
if (attachedLocation) {
formFields['latitude'] = attachedLocation.latitude.toString();
formFields['longitude'] = attachedLocation.longitude.toString();
}
await queueOfflineUpload({
endpoint: `${BACKEND_URL}/api/v1/tours/${tourId}/photos`,
authToken: token,
fileBlob: file,
fileName: file.name,
formFields,
});
notify({
title: 'Ảnh đã được lưu tạm ngoại tuyến 📦',
message: 'Kết nối lại mạng, ảnh sẽ tự động được tải lên tour.',
type: 'info',
});
} catch (err) {
notify({ title: 'Lỗi', message: 'Không thể lưu ảnh ngoại tuyến.', type: 'error' });
} finally {
if (cameraInputRef.current) cameraInputRef.current.value = '';
}
return;
}
// ── ONLINE PATH ───────────────────────────────────────────────────────────────
// Upload photo to tour
setIsUploading(true);
try {
+113
View File
@@ -0,0 +1,113 @@
/**
* backgroundSync.ts
*
* Background sync engine:
* Reads all pending items from IndexedDB when connectivity is restored
* Replays each upload as a multipart/form-data POST
* Removes the item from the queue on server 2xx acknowledgement
* Calls the optional `onSynced` callback after all items are flushed
* (so the map/photo list can refresh reactively)
*
* Also exports `initNetworkStatusListeners` to wire everything up once at
* application startup.
*/
import { getPendingUploads, getPendingCount, removePendingUpload } from './offlineQueue';
let isSyncing = false; // guard against concurrent sync runs
// ─── Core sync engine ───────────────────────────────────────────────────────
export const executeBackgroundSyncEngine = async (
onSynced?: () => void
): Promise<void> => {
if (isSyncing) return; // already running
if (!navigator.onLine) return;
const pending = await getPendingUploads();
if (pending.length === 0) return;
isSyncing = true;
console.log(
`🔄 [BackgroundSync] Internet restored — syncing ${pending.length} pending upload(s) to server…`
);
let syncedCount = 0;
for (const item of pending) {
try {
const formData = new FormData();
// Re-attach the image file
formData.append('images', item.fileBlob, item.fileName);
// Re-attach all serialised form fields (lat, lng, tags, capturedAt …)
for (const [key, value] of Object.entries(item.formFields)) {
formData.append(key, value);
}
const response = await fetch(item.endpoint, {
method: 'POST',
headers: { Authorization: `Bearer ${item.authToken}` },
body: formData,
});
if (response.ok) {
await removePendingUpload(item.id);
syncedCount++;
console.log(`✅ [BackgroundSync] Photo "${item.fileName}" (${item.id}) synced.`);
} else {
const body = await response.text();
console.warn(
`⚠️ [BackgroundSync] Server rejected ${item.id} (${response.status}): ${body}`
);
// Don't break — try remaining items; server-rejected items stay in queue
}
} catch (error) {
console.error(
`✖ [BackgroundSync] Network error on ${item.id} — will retry on next reconnect:`,
error
);
// Network error mid-sync: stop and wait for the next 'online' event
break;
}
}
isSyncing = false;
if (syncedCount > 0 && onSynced) {
console.log(`🗺 [BackgroundSync] ${syncedCount} photo(s) synced — refreshing map pins…`);
onSynced();
}
};
// ─── Pending count helper (for optional UI badge) ───────────────────────────
export const getOfflinePendingCount = getPendingCount;
// ─── App startup hook ───────────────────────────────────────────────────────
/**
* Call once during App mount.
* Attaches the `window.online` listener AND does an immediate
* flush in case the device was offline while the app was closed.
*/
export const initNetworkStatusListeners = (onSynced?: () => void): (() => void) => {
const handleOnline = () => {
console.log('🌐 [BackgroundSync] Network restored — triggering sync…');
executeBackgroundSyncEngine(onSynced);
};
window.addEventListener('online', handleOnline);
// Startup guard: if we're already online, attempt an immediate flush
if (navigator.onLine) {
// Defer slightly so React tree is fully mounted before map refreshes
setTimeout(() => executeBackgroundSyncEngine(onSynced), 2000);
}
// Return cleanup function for useEffect
return () => {
window.removeEventListener('online', handleOnline);
};
};
+115
View File
@@ -0,0 +1,115 @@
/**
* offlineQueue.ts
*
* Offline photo upload queue backed by native IndexedDB.
* No external libraries works in all modern browsers and Capacitor WebViews.
*
* Stored schema per item:
* id UUID generated at queue time
* endpoint full upload URL (e.g. https://yotrip.labz.io.vn/api/v1/photos/upload-anonymous)
* authToken Bearer token captured at queue time
* fileBlob raw Blob of the processed image
* fileName original filename (e.g. "photo-1718000000.jpg")
* formFields key-value pairs: latitude, longitude, capturedAt, tags
* timestamp epoch ms when the item was queued
*/
const DB_NAME = 'yotrip-offline-db';
const DB_VERSION = 1;
const STORE_NAME = 'pending-uploads';
export interface OfflineUploadItem {
id: string;
endpoint: string;
authToken: string;
fileBlob: Blob;
fileName: string;
formFields: Record<string, string>;
timestamp: number;
}
// ─── Internal DB bootstrap ──────────────────────────────────────────────────
const getDB = (): Promise<IDBDatabase> =>
new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
// ─── Public API ─────────────────────────────────────────────────────────────
/**
* Stash a failed upload into the local IndexedDB queue.
* Returns the generated item id.
*/
export const queueOfflineUpload = async (
data: Omit<OfflineUploadItem, 'id' | 'timestamp'>
): Promise<string> => {
const db = await getDB();
const id = crypto.randomUUID();
const item: OfflineUploadItem = { ...data, id, timestamp: Date.now() };
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const req = store.put(item);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
console.log(`📦 [OfflineQueue] Photo "${item.fileName}" (${id}) queued for background sync.`);
return id;
};
/**
* Retrieve all pending items ordered by timestamp (oldest first).
*/
export const getPendingUploads = async (): Promise<OfflineUploadItem[]> => {
const db = await getDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const req = store.getAll();
req.onsuccess = () =>
resolve((req.result as OfflineUploadItem[]).sort((a, b) => a.timestamp - b.timestamp));
req.onerror = () => reject(req.error);
});
};
/**
* Get the total count of pending items (for UI badge).
*/
export const getPendingCount = async (): Promise<number> => {
const db = await getDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const req = store.count();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
};
/**
* Remove a successfully synced item from the queue.
*/
export const removePendingUpload = async (id: string): Promise<void> => {
const db = await getDB();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const req = store.delete(id);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
};