feat: tính năng offline cho upload ảnh và tải bản đồ
This commit is contained in:
@@ -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.
|
||||
|
||||
Vendored
+7
-1
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user