25 Commits

Author SHA1 Message Date
3dtours f91fc7c5ac feat: tính năng offline cho upload ảnh và tải bản đồ 2026-06-28 15:18:13 +07:00
3dtours 2f5ef424c8 fix: sửa UI photoview hiển thị ở public 2026-06-28 14:55:25 +07:00
3dtours 41c8e67229 fix: photo edit by admin 2026-06-28 11:00:28 +07:00
3dtours 9f60efeb6d fix: remove success notifications and parse map coordinates safely 2026-06-27 22:28:13 +07:00
3dtours 0152e7014d feat: use Capacitor Camera API to select/capture photos to preserve EXIF location on Android app 2026-06-27 22:21:28 +07:00
3dtours 964a72514f feat: use Capacitor Geolocation to prompt and get native location on Android 2026-06-27 22:15:18 +07:00
3dtours 7f426c8e46 feat: replace manual binary EXIF parser with robust exifr library on frontend 2026-06-27 22:04:21 +07:00
3dtours 414fac3e72 feat: add ACCESS_MEDIA_LOCATION permission to AndroidManifest.xml to enable EXIF GPS reading on Android 10+ 2026-06-27 21:59:08 +07:00
3dtours e5606e64e5 feat: enforce 4-level coordinates resolution priority for tour photo uploads in AddPhotoModal.tsx 2026-06-27 21:53:52 +07:00
3dtours 464a4019f1 chore: mount Android app-debug.apk directly as a docker volume in backend container 2026-06-27 21:45:57 +07:00
3dtours a1bf0d2c08 feat: implement imageProcessor.ts and fix fullscreen photo lightbox alignment 2026-06-27 21:42:27 +07:00
3dtours 277f647e40 fix: copy public folder to production docker image stage to serve APK downloads 2026-06-27 21:17:33 +07:00
3dtours f39750af72 feat: implement 4-level priority coordinates resolution on photo upload 2026-06-27 21:08:42 +07:00
3dtours ff4dc9bb48 fix: restrict AppDownloadBanner to mobile browsers only using Capacitor.isNativePlatform() 2026-06-27 20:54:47 +07:00
3dtours 175668da35 feat: restore and integrate AppDownloadBanner under Top-Bar 2026-06-27 20:50:00 +07:00
3dtours 7ab1342690 fix: close login modal automatically on successful login in LandingPage 2026-06-27 20:41:40 +07:00
3dtours 2f4b24c41c fix: add androidClientId matching serverClientId in capacitor.config.ts 2026-06-27 20:36:26 +07:00
3dtours 1417f40dde fix: restore GoogleAuth.initialize on native to prevent startup NullPointerException crash 2026-06-27 20:32:59 +07:00
3dtours eaf79eaf8f fix: call GoogleAuth.initialize only on web to resolve Native OAuth error 2026-06-27 20:29:01 +07:00
3dtours 3fa80b69bf fix: android dropdown menu button 2026-06-27 20:27:44 +07:00
admin a7e569d9b4 merge: gộp thành công phần code sửa trước từ stash 2026-06-27 19:42:45 +07:00
admin 3bc7c8a160 fix: menu on android 2026-06-27 19:38:05 +07:00
admin 9fae6afaac fix: phần code sửa sau trên máy B 2026-06-27 17:52:42 +07:00
admin 614054f35d fix: phần code build 2026-06-27 17:51:19 +07:00
admin 796487ef76 fix: phần code build android 2026-06-27 17:46:23 +07:00
41 changed files with 2345 additions and 826 deletions
-201
View File
@@ -1,201 +0,0 @@
# 🗺️ Hướng Dẫn Đóng Gói & Kiểm Thử Ứng Dụng Android (YoTrip)
Tài liệu này hướng dẫn chi tiết quy trình thiết lập môi trường máy tính Windows Local để biên dịch, kiểm thử dự án Frontend (React + Vite) qua Capacitor, kết nối tới hệ thống Docker Production (`https://yotrip.labz.io.vn`) và chuẩn bị phát hành lên Google Play Store.
---
## 📋 1. Điều Kiện Tiên Quyết (Môi Trường Windows)
Trước khi chạy lệnh, đảm bảo máy tính local đã cài đặt và cấu hình đầy đủ các công cụ sau:
* **Node.js:** Phiên bản v18 hoặc v20+.
* **Java JDK:** Phiên bản 17 hoặc 21 (Temurin hoặc Microsoft OpenJDK).
* Biến môi trường hệ thống: `JAVA_HOME` trỏ tới thư mục cài đặt JDK.
* Biến `Path` hệ thống: Bổ sung `%JAVA_HOME%\bin`.
* **Android Studio:** * Đã cài đặt **Android SDK**, **Android SDK Command-line Tools**.
* Biến môi trường hệ thống: `ANDROID_HOME` trỏ tới `AppData\Local\Android\Sdk`.
* Đã khởi tạo 1 thiết bị ảo (Android Simulator) qua *Virtual Device Manager*.
---
## ⚙️ 2. Cấu Hình Mã Nguồn Frontend (Local)
### 2.1 Cấu hình file `.env.production`
Tạo hoặc cập nhật file `.env.production` nằm tại thư mục gốc của `frontend/`:
```text
VITE_BACKEND_URL=[https://yotrip.labz.io.vn](https://yotrip.labz.io.vn)
### 2.2 Cấu hình Axios / API Instance (src/api/axios.ts)
Cập nhật logic baseURL để tự động phân tách môi trường chạy Web Dev (sử dụng Proxy của Vite) và môi trường chạy App Native (gọi trực tiếp URL tuyệt đối):
import axios from 'axios';
import { Capacitor } from '@capacitor/core';
const API = axios.create({
baseURL: Capacitor.isNativePlatform()
? import.meta.env.VITE_BACKEND_URL
: '',
timeout: 15000,
headers: {
'Content-Type': 'application/json',
},
});
export default API;
## 3. Cấu Hình Nền Tảng Android Native
### 3.1 Thiết lập Biểu tượng Ứng dụng (App Icon)
Để sử dụng frontend/public/favicon.ico làm icon của app trên Android, chúng ta cần chuyển đổi nó sang định dạng .png độ phân giải cao và sử dụng công cụ của Capacitor để tự động tạo các kích thước cần thiết cho Android.
Chuẩn bị ảnh: Chuyển đổi file favicon.ico của bạn thành file .png (khuyên dùng độ phân giải ít nhất 1024x1024 pixel để có chất lượng tốt nhất trên các thiết bị đời mới) và lưu tên là icon-only.png.
Cài đặt công cụ: Chạy lệnh sau tại thư mục frontend/ để cài đặt công cụ quản lý tài nguyên của Capacitor:
Bash
npm install @capacitor/assets --save-dev
Khởi tạo thư mục: Tạo thư mục assets ở thư mục gốc của frontend/ (cùng cấp với src) và đặt file icon-only.png vào đó.
Bash
mkdir assets
# Sau đó di chuyển file icon-only.png của bạn vào thư mục assets/
Tạo Icon: Chạy lệnh sau để tự động tạo và đặt các icon vào đúng vị trí trong dự án Android:
Bash
npx capacitor-assets generate --android
### 3.2 File capacitor.config.json
Định danh chính xác gói ứng dụng (App ID) dùng để đăng ký trên Google Play Console:
{
"appId": "com.yotrip.app",
"appName": "YoTrip",
"webDir": "dist",
"plugins": {
"SplashScreen": {
"launchShowDuration": 2000
}
}
}
### 3.3 File capacitor.config.json
Định danh chính xác gói ứng dụng (App ID) dùng để đăng ký trên Google Play Console:
JSON
{
"appId": "com.yotrip.app",
"appName": "YoTrip",
"webDir": "dist",
"plugins": {
"SplashScreen": {
"launchShowDuration": 2000
}
}
}
### 3.4 Cấu hình quyền trong AndroidManifest.xml
Mở đường dẫn android/app/src/main/AndroidManifest.xml, thêm các quyền truy cập Internet, định vị GPS, và các quyền cần thiết cho tính năng chụp ảnh và lưu ảnh vào bộ nhớ máy:
XML
<manifest xmlns:android="http://schemas.microsoft.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-feature android:name="android.hardware.location.gps" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<queries>
<intent>
<action android:name="android.media.action.IMAGE_CAPTURE" />
</intent>
</queries>
<application
...
android:usesCleartextTraffic="true"> ...
</application>
</manifest>
## 4. Tích Hợp Tính Năng Chụp Ảnh và Lưu Ảnh (React Code)
Sử dụng Plugin của Capacitor để tích hợp trực tiếp vào code React của bạn.
Cài đặt Plugin Camera: Chạy lệnh sau tại thư mục frontend/:
Bash
npm install @capacitor/camera
npx cap update
Ví dụ code: Dưới đây là cách implement tính năng chụp ảnh và tự động lưu ảnh gốc vào thư viện ảnh của điện thoại trong một React component (ví dụ src/components/PhotoTaker.tsx):
JavaScript
import React, { useState } from 'react';
import { IonButton, IonIcon, IonContent, IonPage } from '@ionic/react';
import { camera } from 'ionicons/icons';
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
const PhotoTaker: React.FC = () => {
const [photoUri, setPhotoUri] = useState<string | undefined>();
const takeAndSavePhoto = async () => {
try {
const image = await Camera.getPhoto({
quality: 90,
allowEditing: false, // Giữ nguyên ảnh gốc, không qua chỉnh sửa
resultType: CameraResultType.Uri,
source: CameraSource.Camera, // Mở camera trực tiếp
saveToGallery: true, // YÊU CẦU MỚI: Tự động lưu ảnh gốc vào thư viện điện thoại
});
// Bạn có thể sử dụng image.webPath để hiển thị xem trước
setPhotoUri(image.webPath);
console.log('Ảnh đã được chụp và lưu tại:', image.path);
} catch (error) {
console.error('Lỗi khi chụp hoặc lưu ảnh:', error);
}
};
return (
<IonPage>
<IonContent className="ion-padding">
<div className="flex flex-col items-center gap-4">
<h1 className="text-xl font-bold">Tính năng Chụp ảnh</h1>
<IonButton onClick={takeAndSavePhoto} color="primary">
<IonIcon slot="start" icon={camera}></IonIcon>
Chụp và Lưu Ảnh Gốc
</IonButton>
{photoUri && (
<div className="mt-4 border p-2">
<p>Xem trước ảnh vừa chụp:</p>
<img src={photoUri} alt="Xem trước ảnh chụp" className="max-w-xs mt-2" />
</div>
)}
</div>
</IonContent>
</IonPage>
);
};
export default PhotoTaker;
## 5. Quy Trình Biên Dịch & Kiểm Thử (Simulator)
Mỗi lần cập nhật code giao diện ở máy local, chạy chuỗi lệnh sau tại Terminal của VS Code để đẩy app lên máy ảo:
# Bước 1: Cài đặt các thư viện phụ thuộc tại local
npm install
# Bước 2: Build code React + Vite thành file tĩnh
npm run build
# Bước 3: Đồng bộ mã nguồn tĩnh vào thư mục Android mã nguồn mở
npx cap sync
# Bước 4: Khởi chạy máy ảo và nạp ứng dụng tự động
npx cap run android
-126
View File
@@ -1,126 +0,0 @@
# To AI Agent: Implement Smart Date-Based Accordion Auto-Expansion and Smooth Viewport Auto-Scroll
## 1. Context & UI/UX Requirements
We are enhancing the routing UX between `MemberDashboard.tsx` and `ItineraryTimeline.tsx` based on `image_cc31a4.png`.
### Functional Specifications:
1. **Date-Matching Evaluation:** When a user clicks "Chi tiết hành trình" on a tour card, calculate if the current user system local date falls inside any Stage/Leg timeline block.
2. **Auto-Expansion State:** On timeline page load, the matched Leg accordion must be expanded by default (`expandedStageId === leg.id`).
3. **Smart Auto-Scroll Layout:** The viewport must smoothly auto-scroll so that the expanded Leg's title bar lands **exactly below the sticky navigation tab bar** ("Lộ trình", "Chi phí", etc.), making it fully visible at the top of the viewport without layout clipping.
---
## 2. Technical Architecture & Layout Constraints
- **The Sticky Header Challenge:** Since the top navigation tab bar uses sticky/fixed positioning, a naive `scrollIntoView({ block: 'start' })` will cause the Leg's title to crawl *underneath* the tabs, hiding it.
- **The Solution:** We will inject a dynamic CSS scroll-margin-top parameter (`scroll-mt-[70px]` or matching header height) on each Leg container, and trigger a minor delayed layout effect pool using a React `setTimeout` to wait for the DOM accordion expansion reflow before pulling the scroll trigger.
---
## 3. Code Refactoring Blueprint
### Step 1: Update Navigation Payload Handler in Dashboard Component
Ensure the `MemberDashboard.tsx` accurately packages the matched target identifier into the client route state bucket:
```typescript
import { useNavigate } from 'react-router-dom';
const navigate = useNavigate();
const handleNavigateToItinerary = (tour: any) => {
let targetLegId = null;
if (tour?.legs && tour.legs.length > 0) {
const today = new Date();
today.setHours(0, 0, 0, 0);
for (const leg of tour.legs) {
const rawStart = leg.startDate || leg.plannedStart || leg.date;
const rawEnd = leg.endDate || leg.plannedEnd || leg.date;
if (rawStart) {
const startBound = new Date(rawStart);
startBound.setHours(0, 0, 0, 0);
const endBound = rawEnd ? new Date(rawEnd) : new Date(rawStart);
endBound.setHours(23, 59, 59, 999);
if (today >= startBound && today <= endBound) {
targetLegId = leg.id;
break;
}
}
}
if (!targetLegId) {
targetLegId = tour.legs[0].id; // Fallback to Chặng 1 if out of tour bounds
}
}
navigate(`/tour/${tour.id}`, {
state: { defaultExpandedLegId: targetLegId, shouldScrollToTarget: true }
});
};
### Step 2: Inject Safe Identifiers and Scroll Margin inside ItineraryTimeline
Locate the top-level outer container div of each Leg item inside the timeline loop (currentTour.legs.map). Assign a unique id and a scroll margin class utility:
{currentTour.legs.map((leg: any, legIdx: number) => (
<div
key={leg.id}
id={`leg-anchor-node-${leg.id}`}
/* CRITICAL: scroll-mt-[70px] leaves a 70px buffer space at the top.
Adjust '70px' to match the exact height of your white Tour Navigation Tabs bar!
*/
className="scroll-mt-[70px] transition-all w-full mb-4"
>
{/* Accordion Header Title ("Chặng 2: Dạo chơi ở xứ hoa vàng...") */}
<div className="flex items-center justify-between ...">
...
</div>
</div>
))}
### Step 3: Implement Lifecycle Interaction Trigger Engine
Inside ItineraryTimeline.tsx, listen to the passed history route parameters. Set the state, then handle the async smooth scrolling action:
import { useLocation } from 'react-router-dom';
const location = useLocation();
const [expandedStageId, setExpandedStageId] = useState<string | null>(null);
useEffect(() => {
if (location.state?.defaultExpandedLegId) {
const targetId = location.state.defaultExpandedLegId;
// 1. Instantly trigger the accordion state layout expansion
setExpandedStageId(targetId);
// 2. Schedule a deferred macro-task callback to allow DOM re-renders to finish
if (location.state?.shouldScrollToTarget) {
const scrollTimer = setTimeout(() => {
const targetElement = document.getElementById(`leg-anchor-node-${targetId}`);
if (targetElement) {
targetElement.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
// Clear history router state token flags to prevent repeating scroll on subsequent reload shifts
window.history.replaceState({}, document.title);
}, 150); // 150ms ensures smooth accordion height deployment transition finishes
return () => clearTimeout(scrollTimer);
}
} else if (currentTour?.legs && currentTour.legs.length > 0 && !expandedStageId) {
setExpandedStageId(currentTour.legs[0].id);
}
}, [location.state, currentTour]);
## 4. Quality Control & Acceptance Verification
[ ] Date Matching Accuracy: Set today's date context matching a target Leg configuration profile. Tap the transition interface button from dashboard. The page must route directly and expand the target container block node.
[ ] Flush Viewport Ceiling Test: The animated target header segment node must slide upwards smoothly. It must lock positions cleanly right below the lowest baseline layer shadow boundary of the Tab Controller without passing behind it.
[ ] State Cleanliness Check: Refreshing or navigating back and forth within the active Timeline panel after the initial landing should not lock or force layout views to keep jumping scroll heights automatically.
+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.
+181
View File
@@ -0,0 +1,181 @@
# To AI Agent: Fix EXIF GPS Extraction, Implement Client-Side 2K Image Resizing, and Fix Android Fullscreen Lightbox Alignment
## 1. Context & Feature Objectives
We are addressing three crucial image-handling and layout bugs on the mobile/Android web wrapper:
1. **Fix (Missing EXIF Location):** When users upload photos, the system fails to extract the geographic coordinates (Latitude/Longitude) embedded within the image metadata. We need to parse EXIF data completely on the client side before submission.
2. **Feat (Native Save & 2K Downscale):** Whether the user shoots a new photo via the Camera or picks one from the Gallery, the original image must remain safely stored in the phone's native album (handled by native webview permissions). Before uploading the file to our Debian server, the frontend must dynamically resize/downscale the image to a maximum resolution of **2K (2048px on its longest edge)** to optimize network bandwidth and server storage.
3. **Bug (Fullscreen Viewer Displacement):** When clicking an image inside the gallery/photo manager view to preview it in fullscreen mode on an Android device, the image incorrectly aligns to the absolute bottom edge of the viewport instead of centering beautifully.
---
## 2. Technical Execution Strategy
### 2.1. Client-Side EXIF Processing & Metadata Preservation
Standard browser file inputs often strip EXIF headers during dynamic manipulation or fail to parse them natively. We will introduce `exif-js` or use a standard binary array buffer scanner to extract the `GPSLatitude` and `GPSLongitude` headers right before resizing occurs, attaching them to the final multipart upload payload.
### 2.2. Downscaling to 2K via HTML5 Canvas
To achieve hardware-accelerated image scaling on mobile devices without losing core image visibility, the source image will be rendered onto an offscreen `<canvas>` container configured to enforce a `max-dimension` of `2048px`, maintaining the original aspect ratio.
### 2.3. Flexbox/Absolute Centering Fix for Android Lightbox
The bottom-displacement bug is tied to incorrect layout bounds calculations on mobile screens when toolbars or navigation rows shift view heights. We will refactor the Lightbox container modal to use rigid viewport configurations (`fixed inset-0`) along with standard vertical centering mechanics.
---
## 3. Code Refactoring Blueprint
### Step 1: Implement Image Metadata Picker & 2K Resizer Logic (`imageProcessor.ts`)
Create a utility service at `frontend/src/utils/imageProcessor.ts` to handle metadata extraction and canvas downscaling sequentially:
```typescript
import EXIF from 'exif-js';
interface ProcessedImageResult {
file: Blob;
latitude: number | null;
longitude: number | null;
}
// Helper to convert EXIF rational coordinates to standard decimal degrees
const convertDMSToDD = (dms: number[], ref: string): number => {
if (!dms || dms.length < 3) return 0;
const degrees = dms[0] + dms[1] / 60 + dms[2] / 3600;
return ref === 'S' || ref === 'W' ? -degrees : degrees;
};
export const processAndResizeImage = (file: File): Promise<ProcessedImageResult> => {
return new Promise((resolve) => {
let latitude: number | null = null;
let longitude: number | null = null;
// 1. EXTRACT EXIF METADATA BEFORE CANVAS CLEARING
EXIF.getData(file as any, function (this: any) {
const allTags = EXIF.getAllTags(this);
if (allTags.GPSLatitude && allTags.GPSLatitudeRef) {
latitude = convertDMSToDD(allTags.GPSLatitude, allTags.GPSLatitudeRef);
}
if (allTags.GPSLongitude && allTags.GPSLongitudeRef) {
longitude = convertDMSToDD(allTags.GPSLongitude, allTags.GPSLongitudeRef);
}
console.log(`📸 Extracted EXIF Metadata - Lat: ${latitude}, Lng: ${longitude}`);
// Proceed directly to resizing stage
proceedToResize();
});
function proceedToResize() {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target?.result as string;
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
const MAX_SIZE = 2048; // Enforce rigid 2K maximum boundary limit
// Calculate ideal bounding proportions
if (width > height) {
if (width > MAX_SIZE) {
height = Math.round((height * MAX_SIZE) / width);
width = MAX_SIZE;
}
} else {
if (height > MAX_SIZE) {
width = Math.round((width * MAX_SIZE) / height);
height = MAX_SIZE;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return resolve({ file, latitude, longitude });
// Render image onto downscaled dimensions canvas bounding box
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
if (blob) {
resolve({
file: blob,
latitude,
longitude
});
} else {
resolve({ file, latitude, longitude });
}
}, 'image/jpeg', 0.88); // 88% quality compression sweet-spot
};
};
}
});
};
### Step 2: Update Image Upload Handler Layer
Integrate the processor wrapper inside your central upload function (e.g., ImageUploader.tsx or your form submission handler):
import { processAndResizeImage } from '../../utils/imageProcessor';
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const targetFile = event.target.files?.[0];
if (!targetFile) return;
try {
// 1. Process image: Reads EXIF data and forces 2K resizing on device memory
const { file, latitude, longitude } = await processAndResizeImage(targetFile);
const formData = new FormData();
formData.append('image', file, 'yotrip_upload.jpg');
// 2. Append coordinates safely to standard server fields
if (latitude !== null && longitude !== null) {
formData.append('latitude', latitude.toString());
formData.append('longitude', longitude.toString());
}
// 3. Post to API endpoint
const response = await api.post('/media/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
console.log("✅ Media successfully synchronized with server backend:", response.data);
} catch (error) {
console.error("Failed to safely prepare media stream:", error);
}
};
### Step 3: Fix Fullscreen Image Alignment Layout (ImageLightbox.tsx)
Locate your photo viewer overlay or modal drawer component. Overhaul the tailwind utilities to guarantee true vertical and horizontal centering layout balance on Android devices:
{/* ❌ BEFORE: Faulty container pinning images to device bottom edges */}
<div className="fixed inset-0 bg-black flex items-end justify-center">
{/* ✅ AFTER: True viewport overlay centering bounding context */}
<div className="fixed inset-0 bg-black/95 backdrop-blur-sm flex flex-col items-center justify-center z-[999999] overflow-hidden animate-fade-in">
{/* Close Button Top Tracker Bar Container */}
<div className="absolute top-4 right-4 z-50">
<button className="p-2.5 bg-slate-900/60 rounded-full text-white">✕</button>
</div>
{/* Image wrapper frame context forcing clean alignment metrics */}
<div className="w-full h-full flex items-center justify-center p-4">
<img
src={currentImageUrl}
alt="YoTrip Preview"
className="max-w-full max-h-full object-contain select-none pointer-events-auto"
style={{
/* Prevent Android webviews from accidental shifting behaviors */
transform: 'translate3d(0, 0, 0)'
}}
/>
</div>
</div>
## 4. Automated Verification Checklist for AI Agent
[ ] EXIF Validation Verification: Test uploading a photo embedded with active geolocation values. Inspect the API outgoing transmission payload in the network panel; latitude and longitude fields must contain accurate decimal metrics instead of blank string indicators.
[ ] Longest-Edge Constraint Check: Upload a ultra-high resolution image (e.g., 4000px wide). Verify that the processed file size shrinks significantly, and confirm through terminal logging that the generated canvas asset limits width/height strictly to 2048px.
[ ] Android Centering Success: Activate the image preview mode inside the Android Simulator. The image layout must align mathematically dead-center vertically, leaving symmetric padding bars on both the top header and bottom system navigation boundaries.
+151
View File
@@ -0,0 +1,151 @@
# To AI Agent: Restructure Mobile Photo Viewer Layout with Fixed Viewport, Overlay Metadata, and Scrollable Comments
## 1. Context & Architectural UI Refactor
We are redesigning the full-screen mobile photo inspector interface based on the visual layout annotated in `image.png`. The current structure is fragmented, causing layout instability when switching between images.
### Key Refactor Requirements:
1. **Fixed Image Viewport (Blue Zone):** Secure the photo viewport inside a strict, unyielding aspect-locked square container. Regardless of whether the active image is Landscape or Portrait, the container dimension must NOT snap, resize, or cause screen layout jumps.
2. **Geospatial Overlay (Red Arrow Destination):** Completely remove the static location metadata box from the bottom white panel. Relocate and render this location string as a clean, translucent text overlay pinned directly to the **bottom-left corner inside the image viewport**.
3. **Title Transformation (Green Arrow Destination):** Remove the text header segment "Lịch sử ảnh tại vị trí này (...)". In its place, dynamically render the active **Title of the Photo** (Tiêu đề của ảnh), serving as the primary text separator.
4. **Isolated Scrollable Comment Feed:** The entire lower comment zone must be configured to scroll dynamically. When users swipe up to read multiple comments, the layout thread must slide seamlessly underneath the fixed sticky image frame container (`z-20`).
---
## 2. Structural Layer Elevation (`z-index`) Matrix
┌────────────────────────────────────────────────────────┐
│ Tier 4: System Action Row & Closes (z-50) │ ➔ Native buttons (X, Close)
├────────────────────────────────────────────────────────┤
│ Tier 3: Fixed Photo Frame Viewport (z-20 / sticky) │ ➔ Aspect-Square Image Box
│ └─► Sub-Layer: Location & Time Overlay (z-30) │ ➔ Pinned Bottom-Left on Image
├────────────────────────────────────────────────────────┤
│ Tier 2: Scrollable Comments Panel (z-10) │ ➔ Slides BEHIND Tier 3 when swiped
└────────────────────────────────────────────────────────┘
---
## 3. Code Refactoring Blueprint
### Step 1: Overhaul Layout Architecture (`MobilePhotoViewerModal.tsx`)
Update or create the mobile component layout to enforce the absolute layer boundaries and fixed dimensions:
```typescript
import React, { useState } from 'react';
import { X, MapPin, Calendar, Heart, Send } from 'lucide-react';
interface PhotoDetail {
id: string;
url: string;
title: string;
description?: string;
latitude: number;
longitude: number;
locationName: string;
capturedAt: string;
likesCount: number;
}
export const MobilePhotoViewerModal: React.FC<{ photo: PhotoDetail; onClose: () => void }> = ({ photo, onClose }) => {
const [commentInput, setCommentInput] = useState('');
return (
<div className="fixed inset-0 bg-slate-950 z-[999999] flex flex-col overflow-hidden select-none text-white antialiased">
{/* TIER 4: FIXED SYSTEM ACTIONS CONTROL ROW (z-50) */}
<div className="absolute top-4 right-4 z-50">
<button
onClick={onClose}
className="p-2.5 bg-black/60 hover:bg-black/80 rounded-full border border-slate-800 backdrop-blur-md active:scale-95 transition-transform"
>
<X className="w-5 h-5 text-white" />
</button>
</div>
{/* TIER 3: FIXED BLUE ZONE - ASPECT LOCKED PHOTO FRAME (z-20 / sticky) */}
<div className="w-full aspect-square bg-slate-950 flex items-center justify-center sticky top-0 z-20 border-b border-slate-900/60 shadow-2xl shrink-0">
<img
src={photo.url}
alt={photo.title}
className="w-full h-full object-contain select-none pointer-events-none"
/>
{/* TIER 3.1: RELOCATED DYNAMIC LOCATION & TIMESTAMP OVERLAY (z-30) */}
<div className="absolute bottom-4 left-4 right-16 z-30 flex flex-col gap-1 p-2.5 bg-black/50 backdrop-blur-sm rounded-xl border border-white/10 text-[10px] text-slate-200 max-w-[75vw] pointer-events-none">
<div className="flex items-center gap-1.5 font-semibold text-white">
<MapPin className="w-3.5 h-3.5 text-blue-400 shrink-0" />
<span className="truncate">{photo.locationName || "Vị trí không xác định"}</span>
</div>
<div className="flex items-center gap-1.5 text-slate-300 pl-5">
<Calendar className="w-3 h-3 text-slate-400 shrink-0" />
<span>Ngày chụp: {new Date(photo.capturedAt).toLocaleDateString('vi-VN')}</span>
</div>
</div>
{/* Floating Like Badge Counter */}
<div className="absolute bottom-4 right-4 z-30 bg-black/50 backdrop-blur-sm border border-white/10 rounded-xl px-2.5 py-1.5 flex items-center gap-1 text-[11px] font-bold text-rose-500">
<Heart className="w-3.5 h-3.5 fill-rose-500" />
<span>{photo.likesCount}</span>
</div>
</div>
{/* TIER 2: SCROLLABLE CORE CONTENTS & COMMENTS PANEL (z-10) */}
{/* This entire workspace container slides underneath the photo layout on swipe-up */}
<div className="flex-1 overflow-y-auto bg-slate-900/30 relative z-10 flex flex-col min-h-0">
{/* REPLACED HEADER ZONE: Title replaces the old History text strip */}
<div className="px-5 py-4 border-b border-slate-900/80 bg-slate-900/90 backdrop-blur-md sticky top-0 z-10 space-y-1">
<h2 className="text-sm font-bold text-slate-100 tracking-wide">
{photo.title || "Chưa có tiêu đề"}
</h2>
{photo.description && (
<p className="text-[11px] text-slate-400 leading-relaxed font-medium">
{photo.description}
</p>
)}
</div>
{/* DYNAMIC COMMENTS FEED BLOCK */}
<div className="p-4 space-y-3.5 flex-1 overflow-y-visible">
{/* Mock iteration loop representing user chat bubbles */}
{[...Array(5)].map((_, index) => (
<div key={index} className="flex gap-3 items-start text-xs text-slate-300 animate-fade-in">
<div className="w-7 h-7 rounded-full bg-slate-800 font-bold text-[9px] flex items-center justify-center shrink-0 border border-slate-700">U</div>
<div className="flex-1 bg-slate-900/50 border border-slate-800/60 rounded-xl p-3 space-y-1 shadow-sm">
<div className="flex justify-between items-center text-[10px] font-semibold">
<span className="text-slate-200">Thành viên YoTrip</span>
<span className="text-slate-500 font-normal">Vừa xong</span>
</div>
<p className="text-slate-400 leading-relaxed text-[11px]">Góc chụp đẹp quá, bối cảnh nhìn rất thoáng và đầy đủ ánh sáng tự nhiên!</p>
</div>
</div>
))}
</div>
{/* STICKY BOTTOM INPUT SEND TRAY BAR */}
<div className="p-3 bg-slate-950 border-t border-slate-900 flex items-center gap-2 sticky bottom-0 z-20">
<input
type="text"
value={commentInput}
onChange={(e) => setCommentInput(e.target.value)}
placeholder="Viết bình luận công khai..."
className="flex-1 bg-slate-900 border border-slate-800 text-white rounded-xl p-3 text-xs outline-none focus:border-blue-500 transition-colors placeholder-slate-500"
/>
<button className="p-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl transition-colors active:scale-95 shadow-lg">
<Send className="w-4 h-4" />
</button>
</div>
</div>
</div>
);
};
## 4. Quality Verification & Acceptance Criteria for AI Agent
[ ] Dimension Stability Test: Switch back and forth between an ultra-wide panoramic photo and a 4:3 vertical shot. The parent blue container (aspect-square) must remain exactly static on the viewport layout, blocking size shifts.
[ ] Overlay Check Validation: Verify the location marker coordinates box is completely wiped from the lower white profile zone and draws successfully on top of the image canvas at the bottom-left edge.
[ ] Text Swap Alignment: Ensure the string text "Lịch sử ảnh tại vị trí này" is replaced completely by the active image title asset hook.
[ ] Scroll Pass Inspection: Swipe up to read the text inside the comments panel. Verify the message rows pass behind the bottom border line of the image canvas container (z-20), while the close button at the top remains fully accessible.
+1
View File
@@ -33,6 +33,7 @@ COPY package*.json ./
COPY --from=build /usr/src/app/node_modules ./node_modules COPY --from=build /usr/src/app/node_modules ./node_modules
COPY --from=build /usr/src/app/dist ./dist COPY --from=build /usr/src/app/dist ./dist
COPY --from=build /usr/src/app/prisma ./prisma COPY --from=build /usr/src/app/prisma ./prisma
COPY --from=build /usr/src/app/public ./public
EXPOSE 3001 EXPOSE 3001
CMD ["node", "dist/src/main.js"] CMD ["node", "dist/src/main.js"]
Binary file not shown.
+41
View File
@@ -0,0 +1,41 @@
import exifr from 'exifr';
import fs from 'fs';
import path from 'path';
const searchDir = '.';
async function walk(dir) {
let files = [];
const list = fs.readdirSync(dir);
for (const file of list) {
if (file === 'node_modules' || file === '.git' || file === '.vscode') continue;
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
files = files.concat(await walk(fullPath));
} else {
if (['.jpg', '.jpeg', '.png'].includes(path.extname(file).toLowerCase())) {
files.push(fullPath);
}
}
}
return files;
}
async function run() {
const images = await walk(searchDir);
console.log(`Found ${images.length} images to scan...`);
for (const img of images) {
try {
const gps = await exifr.gps(img);
if (gps) {
console.log(`FOUND IMAGE WITH GPS: ${img}`, gps);
}
} catch (e) {
// ignore
}
}
console.log('Scan completed.');
}
run();
+39
View File
@@ -0,0 +1,39 @@
import EXIF from 'exif-js';
import exifr from 'exifr';
import fs from 'fs';
async function test() {
const files = [
'./node_modules/exif-js/example/dsc_09827.jpg',
'./node_modules/exif-js/example/DSCN0614_small.jpg',
'./node_modules/exif-js/example/Bloated-Hero.jpg',
'./node_modules/exif-js/example/Bush-dog.jpg'
];
for (const f of files) {
console.log(`--- Testing file: ${f} ---`);
try {
const gpsExifr = await exifr.gps(f);
console.log(' exifr.gps:', gpsExifr);
} catch (e) {
console.log(' exifr error:', e.message);
}
try {
const data = fs.readFileSync(f);
const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
const parsed = await exifr.parse(arrayBuffer);
console.log(' exifr.parse output:', parsed ? {
latitude: parsed.latitude,
longitude: parsed.longitude,
DateTimeOriginal: parsed.DateTimeOriginal,
CreateDate: parsed.CreateDate,
ModifyDate: parsed.ModifyDate
} : 'null');
} catch (e) {
console.log(' exifr.parse error:', e.message);
}
}
}
test();
+1
View File
@@ -32,6 +32,7 @@ services:
- "3001:3001" - "3001:3001"
volumes: volumes:
- /mnt/storage/yotrip/uploads:/usr/src/app/uploads - /mnt/storage/yotrip/uploads:/usr/src/app/uploads
- ./frontend/android/app/build/outputs/apk/debug/app-debug.apk:/usr/src/app/public/downloads/yotrip-latest.apk
environment: environment:
DATABASE_URL: "postgresql://postgres:password@postgres:5432/travel_db?schema=public" DATABASE_URL: "postgresql://postgres:password@postgres:5432/travel_db?schema=public"
REDIS_URL: "redis://redis:6379" REDIS_URL: "redis://redis:6379"
@@ -40,6 +40,7 @@
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<queries> <queries>
+1
View File
@@ -11,6 +11,7 @@ const config: CapacitorConfig = {
GoogleAuth: { GoogleAuth: {
scopes: ['profile', 'email'], scopes: ['profile', 'email'],
serverClientId: '639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com', serverClientId: '639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com',
androidClientId: '639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com',
forceCodeForRefreshToken: true forceCodeForRefreshToken: true
} }
} }
+14 -8
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <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="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> <script src="https://accounts.google.com/gsi/client" async defer></script>
<title>Travel Planner</title> <title>Travel Planner</title>
@@ -21,16 +27,16 @@
<meta name="twitter:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" /> <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: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" /> <meta name="twitter:image" content="https://yotrip.labz.io.vn/background.avif" />
<script type="module" crossorigin src="/assets/index-CRdl2V_E.js"></script> <script type="module" crossorigin src="/assets/index-1GoR2rZV.js"></script>
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-Cyuzqnbw.js"> <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CMxvf4Kt.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-others-VIU5qAPG.js"> <link rel="modulepreload" crossorigin href="/assets/vendor-others-CNhtyHGs.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-editor-DOXkNaRS.js"> <link rel="modulepreload" crossorigin href="/assets/vendor-editor-BDwQQzB8.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-maps-DY-S_hoR.js"> <link rel="modulepreload" crossorigin href="/assets/vendor-maps-1-B38H26.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-react-BqXL8Z-i.js"> <link rel="modulepreload" crossorigin href="/assets/vendor-react-BF5_05kG.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-pdf-IjT8x9OX.js"> <link rel="modulepreload" crossorigin href="/assets/vendor-pdf-C3XQY6t9.js">
<link rel="stylesheet" crossorigin href="/assets/vendor-maps-CcXJxNtP.css"> <link rel="stylesheet" crossorigin href="/assets/vendor-maps-CcXJxNtP.css">
<link rel="stylesheet" crossorigin href="/assets/vendor-react-CuSj0z1j.css"> <link rel="stylesheet" crossorigin href="/assets/vendor-react-CuSj0z1j.css">
<link rel="stylesheet" crossorigin href="/assets/index-BsXBn1-b.css"> <link rel="stylesheet" crossorigin href="/assets/index-CLipHKhu.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+6
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <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="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> <script src="https://accounts.google.com/gsi/client" async defer></script>
<title>Travel Planner</title> <title>Travel Planner</title>
+1
View File
@@ -18,6 +18,7 @@
"@capacitor/local-notifications": "^8.2.0", "@capacitor/local-notifications": "^8.2.0",
"@codetrix-studio/capacitor-google-auth": "^3.4.0-rc.4", "@codetrix-studio/capacitor-google-auth": "^3.4.0-rc.4",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"exifr": "^7.1.3",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8", "jspdf-autotable": "^5.0.8",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
+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 { LandingPage } from './pages/LandingPage';
import { ExploreMap } from './pages/ExploreMap'; import { ExploreMap } from './pages/ExploreMap';
import { TourDetailPage } from './pages/TourDetailPage'; import { TourDetailPage } from './pages/TourDetailPage';
@@ -13,6 +13,7 @@ import { ConfirmProvider } from './hooks/useConfirm';
import { NotificationProvider, useNotification } from './hooks/useNotification'; import { NotificationProvider, useNotification } from './hooks/useNotification';
import { io } from 'socket.io-client'; import { io } from 'socket.io-client';
import { Capacitor } from '@capacitor/core'; import { Capacitor } from '@capacitor/core';
import { initNetworkStatusListeners } from './utils/backgroundSync';
interface GlobalNotificationListenerProps { interface GlobalNotificationListenerProps {
user: any; user: any;
@@ -141,6 +142,29 @@ function App() {
const [previousPage, setPreviousPage] = useState<'explore' | 'landing'>('explore'); 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); 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(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const viewTourId = params.get('viewTour'); const viewTourId = params.get('viewTour');
+74 -52
View File
@@ -3,7 +3,8 @@ import { X, Upload, Image as ImageIcon, Loader2 } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification'; import { useNotification } from '@/hooks/useNotification';
import { useTourStore } from '@/store/useTourStore'; import { useTourStore } from '@/store/useTourStore';
import { processImageModeration } from '@/hooks/useImageModeration'; import { processImageModeration } from '@/hooks/useImageModeration';
import { compressImage } from '../utils/image'; import { processAndResizeImage } from '../utils/imageProcessor';
import { getDeviceLocation } from '../utils/geolocation';
interface AddPhotoModalProps { interface AddPhotoModalProps {
isOpen: boolean; isOpen: boolean;
@@ -13,8 +14,14 @@ interface AddPhotoModalProps {
isPublicView?: boolean; isPublicView?: boolean;
} }
interface PendingPhoto {
file: File;
latitude: number;
longitude: number;
}
export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, tourId, onSuccess, isPublicView }) => { export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, tourId, onSuccess, isPublicView }) => {
const [selectedFiles, setSelectedFiles] = useState<File[]>([]); const [selectedFiles, setSelectedFiles] = useState<PendingPhoto[]>([]);
const [previews, setPreviews] = useState<string[]>([]); const [previews, setPreviews] = useState<string[]>([]);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
@@ -27,34 +34,36 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) { if (e.target.files) {
const files = Array.from(e.target.files); const files = Array.from(e.target.files);
const newValidFiles: File[] = [];
const newValidPreviews: string[] = [];
setIsProcessing(true); setIsProcessing(true);
notify({ title: 'Đang kiểm duyệt...', message: 'Đang kiểm tra và lọc hình ảnh của bạn...', type: 'info' }); notify({ title: 'Đang kiểm duyệt...', message: 'Đang kiểm tra và lọc hình ảnh của bạn...', type: 'info' });
try { try {
// Fetch device location once to serve as Priority 2 fallback for files without EXIF
const deviceLocation = await getDeviceLocation();
const newValidPhotos: PendingPhoto[] = [];
const newValidPreviews: string[] = [];
for (const file of files) { for (const file of files) {
// 1. Kiểm tra cơ bản: Tệp có dung lượng hay không
if (!file || file.size === 0) { if (!file || file.size === 0) {
notify({ title: 'Lỗi tệp', message: `Tệp ${file.name} trống hoặc không khả dụng.`, type: 'error' }); notify({ title: 'Lỗi tệp', message: `Tệp ${file.name} trống hoặc không khả dụng.`, type: 'error' });
continue; continue;
} }
// Nén ảnh trước // Process image: Reads EXIF data and forces 2K resizing on device memory
const compressedFile = await compressImage(file); const { file: processedFile, latitude: exifLat, longitude: exifLng } = await processAndResizeImage(file);
// 2. Chạy kiểm duyệt hình ảnh // 2. Chạy kiểm duyệt hình ảnh
const moderationResult = await processImageModeration(compressedFile); const moderationResult = await processImageModeration(processedFile);
if (moderationResult.blocked) { if (moderationResult.blocked) {
notify({ title: 'Ảnh bị từ chối', message: `Ảnh ${file.name} chứa nội dung không phù hợp và bị chặn.`, type: 'error' }); notify({ title: 'Ảnh bị từ chối', message: `Ảnh ${file.name} chứa nội dung không phù hợp và bị chặn.`, type: 'error' });
continue; continue;
} }
const processedFile = moderationResult.file; const finalProcessedFile = moderationResult.file;
const previewUrl = URL.createObjectURL(processedFile); const previewUrl = URL.createObjectURL(finalProcessedFile);
// 3. Kiểm tra tính toàn vẹn: Thử load ảnh vào bộ nhớ để xác nhận file "tồn tại" và đọc được // 3. Kiểm tra tính toàn vẹn
const isValidImage = await new Promise<boolean>((resolve) => { const isValidImage = await new Promise<boolean>((resolve) => {
const img = new Image(); const img = new Image();
img.onload = () => resolve(true); img.onload = () => resolve(true);
@@ -63,15 +72,51 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
}); });
if (isValidImage) { if (isValidImage) {
newValidFiles.push(processedFile); // Resolve coordinates based on priority checklist:
let finalLat: number | null = exifLat;
let finalLng: number | null = exifLng;
// Priority 2: Device location
if ((finalLat === null || finalLng === null) && deviceLocation) {
finalLat = deviceLocation.latitude;
finalLng = deviceLocation.longitude;
}
// Priority 3: Map view state
if (finalLat === null || finalLng === null) {
const lastViewStateStr = localStorage.getItem('map_view_state');
if (lastViewStateStr) {
try {
const lastViewState = JSON.parse(lastViewStateStr);
if (lastViewState && Array.isArray(lastViewState.center) && lastViewState.center.length === 2) {
finalLat = Number(lastViewState.center[0]);
finalLng = Number(lastViewState.center[1]);
}
} catch (e) {
console.error('[AddPhotoModal] Error parsing map_view_state:', e);
}
}
}
// Priority 4: Fallback defaults
if (finalLat === null || finalLng === null) {
finalLat = 10.7769;
finalLng = 106.7009;
}
newValidPhotos.push({
file: finalProcessedFile,
latitude: finalLat,
longitude: finalLng
});
newValidPreviews.push(previewUrl); newValidPreviews.push(previewUrl);
} else { } else {
URL.revokeObjectURL(previewUrl); // Thu hồi ngay nếu không hợp lệ URL.revokeObjectURL(previewUrl);
notify({ title: 'Lỗi ảnh', message: `Không thể đọc nội dung ảnh: ${file.name}. Vui lòng kiểm tra lại file.`, type: 'error' }); notify({ title: 'Lỗi ảnh', message: `Không thể đọc nội dung ảnh: ${file.name}. Vui lòng kiểm tra lại file.`, type: 'error' });
} }
} }
setSelectedFiles(prev => [...prev, ...newValidFiles]); setSelectedFiles(prev => [...prev, ...newValidPhotos]);
setPreviews(prev => [...prev, ...newValidPreviews]); setPreviews(prev => [...prev, ...newValidPreviews]);
} catch (err) { } catch (err) {
console.error('File checking error:', err); console.error('File checking error:', err);
@@ -95,47 +140,24 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
setIsUploading(true); setIsUploading(true);
try { try {
// Lấy tọa độ hiện tại của người dùng làm dự phòng nếu ảnh EXIF không có GPS // Upload each photo sequentially so we can attach its specific coordinates
const location = await Promise.race([ for (const item of selectedFiles) {
new Promise<GeolocationPosition | null>((resolve) => { const formData = new FormData();
if (!navigator.geolocation) { formData.append('latitude', item.latitude.toString());
resolve(null); formData.append('longitude', item.longitude.toString());
} else { formData.append('images', item.file);
navigator.geolocation.getCurrentPosition(
(pos) => resolve(pos),
() => resolve(null),
{ timeout: 4000, enableHighAccuracy: true }
);
}
}),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 4500))
]);
const formData = new FormData(); const response = await fetch(`/api/v1/tours/${tourId}/photos`, {
selectedFiles.forEach(file => { method: 'POST',
formData.append('images', file); headers: {
}); 'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: formData
});
if (location) { if (!response.ok) throw new Error('Tải lên ảnh thất bại');
formData.append('latitude', location.coords.latitude.toString());
formData.append('longitude', location.coords.longitude.toString());
} }
const response = await fetch(`/api/v1/tours/${tourId}/photos`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: formData
});
if (!response.ok) throw new Error('Upload failed');
notify({
title: 'Thành công',
message: `Đã tải lên ${selectedFiles.length} ảnh.`,
type: 'success'
});
// Giải phóng bộ nhớ sau khi hoàn tất // Giải phóng bộ nhớ sau khi hoàn tất
previews.forEach(url => URL.revokeObjectURL(url)); previews.forEach(url => URL.revokeObjectURL(url));
+2 -1
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { X, Send, MessageSquare, User, Loader2, Trash2 } from 'lucide-react'; import { X, Send, MessageSquare, User, Loader2, Trash2 } from 'lucide-react';
import { io } from 'socket.io-client'; import { io } from 'socket.io-client';
import { Capacitor } from '@capacitor/core'; import { Capacitor } from '@capacitor/core';
import { BACKEND_URL } from '@/utils/backendEndpoint';
import { useTourStore } from '@/store/useTourStore'; import { useTourStore } from '@/store/useTourStore';
import { ConfirmModal } from './ConfirmModal'; import { ConfirmModal } from './ConfirmModal';
@@ -71,7 +72,7 @@ export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, loc
// Lắng nghe bình luận mới qua Proxy (không cần hardcode URL) // Lắng nghe bình luận mới qua Proxy (không cần hardcode URL)
const socket = Capacitor.isNativePlatform() const socket = Capacitor.isNativePlatform()
? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn') ? io(BACKEND_URL)
: io(); : io();
socket.emit('joinTour', 'global'); // Hoặc logic join cụ thể socket.emit('joinTour', 'global'); // Hoặc logic join cụ thể
+58
View File
@@ -0,0 +1,58 @@
import React from 'react';
import { processMobileImageUpload } from '../utils/imageMetadataProcessor';
// Mock/impl api client using standard fetch to match the exact blueprint signature
const api = {
post: async (url: string, data: FormData, config?: { headers?: Record<string, string> }) => {
const response = await fetch(`/api/v1${url}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`,
...config?.headers,
},
body: data,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const resData = await response.json();
return { data: resData, status: response.status };
}
};
export const useImageUploadController = () => {
const handlePhotoSelection = async (event: React.ChangeEvent<HTMLInputElement>) => {
const rawFile = event.target.files?.[0];
if (!rawFile) return;
try {
// Execute metadata preservation and 2K hardware scaling pipeline sequentially
const { compressedBlob, latitude, longitude, capturedAt } = await processMobileImageUpload(rawFile);
const formData = new FormData();
// Append the compressed file object
formData.append('photo', compressedBlob, 'yotrip_mobile_upload.jpg');
// Append verified structural location and timing parameters
if (latitude !== null && longitude !== null) {
formData.append('latitude', latitude.toString());
formData.append('longitude', longitude.toString());
}
if (capturedAt) {
formData.append('capturedAt', capturedAt);
}
// Send multipart packet securely to the Debian server endpoint
const response = await api.post('/photos/upload-with-meta', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
console.log("✅ Photo and spatial markers deployed seamlessly onto core map layer.", response.data);
} catch (pipelineError) {
console.error("Critical block failure during mobile media processing pipeline:", pipelineError);
}
};
return { handlePhotoSelection };
};
@@ -0,0 +1,86 @@
import React from 'react';
import { Clock, X } from 'lucide-react';
export interface SharedLocationPhoto {
id: string;
url: string;
uploaderName: string;
uploaderAvatar?: string;
isGuest: boolean;
capturedAt: string;
description?: string;
}
interface TimelineProps {
locationName: string;
photos: SharedLocationPhoto[];
onSelectPhoto: (photoId: string) => void;
onClose: () => void;
}
export const LocationTimelineSheet: React.FC<TimelineProps> = ({ locationName, photos, onSelectPhoto, onClose }) => {
// Sort photos chronologically by capture timestamp
const chronologicalPhotos = [...photos].sort(
(a, b) => new Date(b.capturedAt).getTime() - new Date(a.capturedAt).getTime()
);
return (
<div className="fixed bottom-0 left-0 right-0 z-[9999] bg-slate-900 border-t border-slate-800 rounded-t-3xl max-h-[85vh] flex flex-col overflow-hidden text-white text-xs shadow-2xl animate-slide-up">
{/* Dynamic Header Drag/Close Strip */}
<div className="w-full px-5 py-4 border-b border-slate-800/60 flex justify-between items-center bg-slate-900 sticky top-0 z-10">
<div>
<h3 className="font-bold text-sm text-slate-100 truncate max-w-[70vw]">{locationName || "Hành trình tại địa điểm"}</h3>
<p className="text-[10px] text-slate-400">Tổng hợp {photos.length} khoảnh khắc từ cộng đng</p>
</div>
<button type="button" onClick={onClose} className="p-2 bg-slate-800 hover:bg-slate-700 rounded-xl text-slate-350 hover:text-white transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{/* THE CHRONOLOGICAL TIMELINE STREAM CANVAS */}
<div className="flex-1 overflow-y-auto p-5 space-y-6 relative">
{/* Vertical Timeline Track Line */}
<div className="absolute left-[27px] top-6 bottom-6 w-[2px] bg-slate-800" />
{chronologicalPhotos.map((photo) => (
<div key={photo.id} className="flex gap-4 items-start relative group">
{/* Timeline Node Circle Asset Indicator */}
<div className="w-6 h-6 rounded-full bg-blue-600 border-4 border-slate-900 flex items-center justify-center z-10 shrink-0 shadow-md" />
{/* Core Content Card Box */}
<div className="flex-1 bg-slate-950/50 border border-slate-800/80 rounded-2xl p-3 space-y-3 hover:border-slate-700/60 transition-colors">
{/* Meta row identifier */}
<div className="flex justify-between items-center text-[10px] text-slate-400">
<div className="flex items-center gap-1.5">
<div className="w-4 h-4 rounded-full bg-slate-700 flex items-center justify-center font-bold text-[8px] text-white overflow-hidden shrink-0">
{photo.uploaderAvatar ? (
<img src={photo.uploaderAvatar} className="object-cover w-full h-full" />
) : (
photo.uploaderName.charAt(0).toUpperCase()
)}
</div>
<span className="font-medium text-slate-300 truncate max-w-[120px]">{photo.uploaderName}</span>
{photo.isGuest && <span className="bg-slate-800 text-[8px] px-1 py-0.5 rounded text-slate-500 font-bold">Khách</span>}
</div>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3 text-slate-500" />
<span>{new Date(photo.capturedAt).toLocaleDateString('vi-VN')}</span>
</div>
</div>
{/* Clickable Card Thumbnail Container */}
<div
onClick={() => onSelectPhoto(photo.id)}
className="w-full aspect-video rounded-xl overflow-hidden bg-slate-900 relative cursor-pointer active:scale-[0.99] transition-transform"
>
<img src={photo.url} alt="Timeline view" className="w-full h-full object-cover" />
</div>
{photo.description && <p className="text-slate-300 leading-relaxed text-[11px] px-0.5">{photo.description}</p>}
</div>
</div>
))}
</div>
</div>
);
};
@@ -81,9 +81,13 @@ export const MapProfileDropdown: React.FC<MapProfileDropdownProps> = ({
/> />
<div <div
className="fixed bottom-0 left-0 right-0 sm:absolute sm:bottom-auto sm:top-14 sm:right-0 sm:left-auto z-[999999] w-full sm:w-64 bg-slate-900 border-t sm:border border-slate-800 rounded-t-3xl sm:rounded-2xl p-3 sm:p-2 shadow-2xl animate-in slide-in-from-bottom sm:slide-in-from-top-2 duration-300 text-xs text-slate-200" className="absolute right-0 top-14 z-[999999] w-64 bg-slate-900 border border-slate-800 rounded-2xl p-2 shadow-2xl flex flex-col space-y-0.5 text-xs text-slate-200"
style={{
maxHeight: '75vh',
overflowY: 'auto',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.6)'
}}
> >
<div className="w-12 h-1 bg-slate-700 rounded-full mx-auto mb-3 sm:hidden" />
{isAuthenticated ? ( {isAuthenticated ? (
<div className="flex flex-col space-y-1"> <div className="flex flex-col space-y-1">
+280 -322
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit, Heart, Trash2 } from 'lucide-react'; import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit, Heart, Trash2 } from 'lucide-react';
import { io } from 'socket.io-client'; import { io } from 'socket.io-client';
import { Capacitor } from '@capacitor/core'; import { Capacitor } from '@capacitor/core';
import { BACKEND_URL } from '@/utils/backendEndpoint';
import { CoordinateSelectModal } from './CoordinateSelectModal'; import { CoordinateSelectModal } from './CoordinateSelectModal';
import { useTranslation } from '../hooks/useTranslation'; import { useTranslation } from '../hooks/useTranslation';
import { useConfirm } from '../hooks/useConfirm'; import { useConfirm } from '../hooks/useConfirm';
@@ -248,7 +249,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
fetchComments(); fetchComments();
const socket = Capacitor.isNativePlatform() const socket = Capacitor.isNativePlatform()
? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn') ? io(BACKEND_URL)
: io(); : io();
socket.emit('joinPhoto', photo.id); socket.emit('joinPhoto', photo.id);
@@ -426,358 +427,308 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
{/* Left Side: Photo Detail */} {/* ============================================================
<div className="relative w-full md:w-3/5 md:h-full bg-slate-950 flex flex-col overflow-hidden group shrink-0"> TIER 3: FIXED PHOTO FRAME VIEWPORT (z-20 / sticky on mobile)
Aspect-square container. Does NOT resize on image swap.
{/* Photo wrapper for mobile view (handles top overlay name and bottom-right like) */} Desktop: left 3/5 column, absolute-fill with overlay metadata.
<div className="relative w-full flex items-center justify-center md:absolute md:inset-0 md:flex md:items-center md:justify-center bg-slate-950"> ============================================================ */}
{/* Mobile Only: Uploader details overlay */} <div className="w-full aspect-square bg-slate-950 flex items-center justify-center sticky top-0 z-20 shrink-0 shadow-2xl border-b border-slate-900/60 md:relative md:aspect-auto md:w-3/5 md:h-full md:overflow-hidden md:border-b-0 group">
<div className="absolute top-[calc(0.75rem+env(safe-area-inset-top,0px))] left-4 z-40 md:hidden flex items-center gap-2 bg-slate-950/70 backdrop-blur-md px-2.5 py-1.5 rounded-full border border-slate-700/50">
<div className="w-5 h-5 rounded-full bg-emerald-500/20 flex items-center justify-center"> {/* Image fill */}
<User className="w-3 h-3 text-emerald-400" /> <a
</div> href={`${window.location.origin}/api/v1/public-photos/${photo.id}/share`}
<span className="text-xs text-slate-200 max-w-[120px] truncate"> className="absolute inset-0 flex items-center justify-center cursor-zoom-in"
{photo.uploader?.name || 'Ẩn danh'} onClick={(e) => {
</span> e.preventDefault();
setIsFullscreen(true);
}}
>
<img
src={photo.imageUrl}
alt="Public Map Upload"
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
className="w-full h-full object-contain select-none pointer-events-none"
draggable={false}
/>
{(!isAuthorized || !isLoggedIn) && (
<div className="absolute inset-0 bg-transparent select-none z-10" />
)}
</a>
{/* TIER 3.1: LOCATION & TIMESTAMP OVERLAY — bottom-left inside image (z-30) */}
<div className="absolute bottom-3 left-3 z-30 flex flex-col gap-0.5 max-w-[70vw] md:max-w-[45%] pointer-events-none drop-shadow-[0_1px_3px_rgba(0,0,0,0.9)]">
<div className="flex items-center gap-1.5 text-[10px] font-bold text-blue-300">
<MapPin className="w-3.5 h-3.5 text-red-500 shrink-0" />
<span className="truncate leading-tight">{resolvedAddress}</span>
</div>
<div className="flex items-center gap-1.5 text-[9px] text-blue-200/80 pl-5">
<Calendar className="w-2.5 h-2.5 text-blue-300/70 shrink-0" />
<span>{new Date(photo.capturedAt).toLocaleDateString('vi-VN', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
})}</span>
</div> </div>
{/* Like Button Overlay */}
<button
onClick={(e) => {
e.stopPropagation();
handleToggleLike();
}}
className="absolute bottom-4 right-4 z-40 flex items-center gap-1.5 bg-slate-950/70 hover:bg-slate-900/80 border border-slate-700/50 text-slate-200 hover:text-white font-bold py-1.5 px-3 rounded-full transition-all active:scale-95 text-[11px] backdrop-blur-md md:absolute md:top-[calc(1rem+env(safe-area-inset-top,0px))] md:left-4 md:bottom-auto md:right-auto"
title={isLiked ? "Bỏ thích" : "Thích"}
>
<Heart className={`w-4 h-4 transition-colors ${
isLiked ? 'text-rose-500 fill-rose-500 animate-in zoom-in-75' : 'text-slate-350 hover:text-rose-450'
}`} />
<span>{likeCount}</span>
</button>
<a
href={`${window.location.origin}/api/v1/public-photos/${photo.id}/share`}
className="w-full h-auto max-h-[85vh] object-contain cursor-zoom-in md:h-full md:max-h-none flex items-center justify-center relative"
onClick={(e) => {
e.preventDefault();
setIsFullscreen(true);
}}
>
<img
src={photo.imageUrl}
alt="Public Map Upload"
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
className={`w-full h-auto max-h-[85vh] object-contain md:h-full md:max-h-none select-none ${
!isLoggedIn ? 'pointer-events-none' : ''
}`}
draggable={false}
/>
{/* Shield overlay to prevent Save Image As on right-click / long press for non-owners */}
{(!isAuthorized || !isLoggedIn) && (
<div className="absolute inset-0 bg-transparent select-none z-10" />
)}
</a>
</div> </div>
{/* Info & Timeline overlay inside photo panel */} {/* Like button — bottom-right on mobile, top-left on desktop */}
<div className="relative z-20 p-6 bg-slate-900 border-b border-slate-800/60 md:absolute md:bottom-0 md:left-0 md:right-0 md:bg-gradient-to-t md:from-black/60 md:via-black/20 md:to-transparent md:border-b-0 flex flex-col gap-4"> <button
onClick={(e) => {
{/* Timeline scroll */} e.stopPropagation();
handleToggleLike();
}}
className="absolute bottom-3 right-3 z-30 flex items-center gap-1 bg-black/55 backdrop-blur-sm border border-white/10 rounded-xl px-2.5 py-1.5 text-[11px] font-bold transition-all active:scale-95 md:top-[calc(1rem+env(safe-area-inset-top,0px))] md:left-4 md:bottom-auto md:right-auto"
title={isLiked ? "Bỏ thích" : "Thích"}
>
<Heart className={`w-3.5 h-3.5 transition-colors ${
isLiked ? 'text-rose-500 fill-rose-500' : 'text-white'
}`} />
<span className="text-white">{likeCount}</span>
</button>
{/* Uploader pill — top-left on mobile */}
<div className="absolute top-[calc(0.75rem+env(safe-area-inset-top,0px))] left-3 z-30 md:hidden flex items-center gap-1.5 bg-black/55 backdrop-blur-sm px-2.5 py-1 rounded-full border border-white/10">
<div className="w-4 h-4 rounded-full bg-emerald-500/30 flex items-center justify-center">
<User className="w-2.5 h-2.5 text-emerald-300" />
</div>
<span className="text-[10px] text-slate-200 max-w-[110px] truncate font-semibold">
{photo.uploader?.name || 'Ẩn danh'}
</span>
</div>
{/* Desktop: overlay metadata gradient at bottom of photo column */}
<div className="hidden md:flex absolute bottom-0 left-0 right-0 z-20 px-6 pb-5 pt-12 bg-gradient-to-t from-black/70 via-black/20 to-transparent flex-col gap-1 pointer-events-none">
<h4 className="text-sm font-black text-white tracking-tight leading-snug drop-shadow">
{photo.metadata?.title || ''}
</h4>
{photo.metadata?.description && (
<p className="text-xs text-slate-300 leading-snug line-clamp-2 drop-shadow">{photo.metadata.description}</p>
)}
<div className="flex items-center gap-1.5 text-[10px] text-slate-300 mt-1">
<MapPin className="w-3 h-3 text-rose-400 shrink-0" />
<span className="truncate">{resolvedAddress}</span>
</div>
<div className="flex items-center gap-1.5 text-[10px] text-slate-400">
<Calendar className="w-2.5 h-2.5 shrink-0" />
<span>{new Date(photo.capturedAt).toLocaleDateString('vi-VN', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
})}</span>
</div>
</div>
{/* Desktop thumbnail timeline carousel at the bottom */}
{photoGroup && photoGroup.length > 1 && (
<div className="hidden md:flex absolute bottom-0 left-0 right-0 z-20 px-4 pb-3 gap-2 overflow-x-auto no-scrollbar justify-end items-end pointer-events-auto">
{photoGroup.map((p) => {
const isActive = p.id === photo.id;
return (
<button
key={p.id}
onClick={(e) => { e.stopPropagation(); onSelectPhoto?.(p); }}
className={`relative w-10 h-10 rounded-xl overflow-hidden transition-all active:scale-95 shrink-0 ${
isActive ? 'border-2 border-emerald-400 scale-105 shadow-lg' : 'border border-slate-600/50 opacity-70 hover:opacity-100'
}`}
>
<img src={p.imageUrl} alt="thumb" className="w-full h-full object-cover pointer-events-none" />
</button>
);
})}
</div>
)}
</div>
{/* ============================================================
TIER 2: SCROLLABLE COMMENTS PANEL (z-10)
Slides BEHIND the sticky image frame when swiping up on mobile.
Desktop: right 2/5 column with its own overflow-y-auto.
============================================================ */}
<div className="flex-1 overflow-y-auto relative z-10 flex flex-col bg-slate-900 border-t md:border-t-0 md:border-l border-slate-800 md:w-2/5 md:flex-1 md:min-h-0">
{/* ---- TITLE HEADER: replaces old "Lịch sử ảnh tại vị trí này" strip ---- */}
<div className="px-5 pt-4 pb-0 bg-slate-900/95 backdrop-blur-md border-b border-slate-800/60 sticky top-0 z-10 flex flex-col gap-2">
{/* Row 1: Title + Edit button */}
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h2 className="text-sm font-black text-slate-100 tracking-wide leading-snug">
{photo.metadata?.title || 'Chưa có tiêu đề'}
</h2>
{photo.metadata?.description && (
<p className="text-[11px] text-slate-400 leading-relaxed mt-0.5 line-clamp-2">
{photo.metadata.description}
</p>
)}
</div>
{isAuthorized && (
<button
onClick={(e) => { e.stopPropagation(); setIsEditing(true); }}
className="p-1.5 bg-slate-800 hover:bg-slate-700 border border-slate-700/50 rounded-xl text-slate-300 hover:text-white transition-all shrink-0"
title="Chỉnh sửa thông tin"
>
<Edit className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Row 2: Thumbnail timeline strip — below title, mobile only */}
{photoGroup && photoGroup.length > 1 && ( {photoGroup && photoGroup.length > 1 && (
<div className="flex flex-col gap-2 border-b border-slate-800/80 pb-3 relative z-20"> <div className="flex gap-2 overflow-x-auto no-scrollbar pb-3 md:hidden">
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-400 flex items-center gap-1.5"> {photoGroup.map((p) => {
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500 animate-ping"></span> const isActive = p.id === photo.id;
Lịch sử nh tại vị trí này ({photoGroup.length}) const dateObj = new Date(p.capturedAt);
</span> const day = String(dateObj.getDate()).padStart(2, '0');
<div className="flex gap-3 overflow-x-auto no-scrollbar py-1 relative z-20"> const month = String(dateObj.getMonth() + 1).padStart(2, '0');
{photoGroup.map((p) => { return (
const isActive = p.id === photo.id; <button
return ( key={p.id}
<button onClick={(e) => { e.stopPropagation(); onSelectPhoto?.(p); }}
key={p.id} className={`relative w-12 h-12 rounded-2xl overflow-hidden transition-all active:scale-95 shrink-0 ${
onClick={(e) => { isActive
e.stopPropagation(); ? 'border-[3px] border-emerald-500 shadow-lg shadow-emerald-500/25'
onSelectPhoto?.(p); : 'border-2 border-slate-600 hover:border-slate-400'
}} }`}
className={`relative w-12 h-12 rounded-xl overflow-hidden border-2 transition-all active:scale-95 shrink-0 ${ >
isActive ? 'border-emerald-500 scale-110 shadow-lg' : 'border-slate-700 hover:border-slate-500' <img
}`} src={p.imageUrl}
> alt="thumb"
<img onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
src={p.imageUrl} className="w-full h-full object-cover pointer-events-none"
alt="Timeline thumbnail" />
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }} <div className="absolute bottom-0 inset-x-0 bg-slate-950/70 text-[7px] text-center font-black text-slate-200 py-px tracking-wide">
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }} {day}-{month}
className={`w-full h-full object-cover ${ </div>
!isLoggedIn ? 'pointer-events-none' : '' </button>
}`} );
/> })}
<div className="absolute bottom-0 inset-x-0 bg-slate-950/70 text-[8px] text-center font-bold text-slate-300 py-0.5">
{new Date(p.capturedAt).toLocaleDateString('vi-VN', { month: '2-digit', day: '2-digit' })}
</div>
</button>
);
})}
</div>
</div> </div>
)} )}
</div>
{isEditing ? ( {/* Edit form (when editing) */}
{isEditing && (
<div className="px-5 py-4 border-b border-slate-800/60 bg-slate-900/95">
<div className="flex flex-col gap-3 bg-slate-900/95 border border-slate-800 p-4 rounded-2xl animate-in slide-in-from-bottom-2"> <div className="flex flex-col gap-3 bg-slate-900/95 border border-slate-800 p-4 rounded-2xl animate-in slide-in-from-bottom-2">
<h4 className="text-xs font-black uppercase tracking-wider text-emerald-400">Chỉnh sửa thông tin nh</h4> <h4 className="text-xs font-black uppercase tracking-wider text-emerald-400">Chỉnh sửa thông tin nh</h4>
<div className="space-y-2"> <div className="space-y-2">
<div> <div>
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Tiêu đ</label> <label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Tiêu đ</label>
<input <input type="text" value={editTitle} onChange={(e) => setEditTitle(e.target.value)}
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
placeholder="Nhập tiêu đề cho ảnh..." placeholder="Nhập tiêu đề cho ảnh..."
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500" className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500" />
/>
</div> </div>
<div> <div>
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1"> tả</label> <label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1"> tả</label>
<textarea <textarea value={editDescription} onChange={(e) => setEditDescription(e.target.value)}
value={editDescription} placeholder="Mô tả bức ảnh này..." rows={2}
onChange={(e) => setEditDescription(e.target.value)} className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500 resize-none" />
placeholder="Mô tả bức ảnh này..."
rows={2}
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500 resize-none"
/>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1"> đ</label> <label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1"> đ</label>
<input <input type="number" step="any" value={editLat}
type="number"
step="any"
value={editLat}
onChange={(e) => setEditLat(e.target.value === '' ? '' : parseFloat(e.target.value))} onChange={(e) => setEditLat(e.target.value === '' ? '' : parseFloat(e.target.value))}
placeholder="Vĩ độ..." placeholder="Vĩ độ..."
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500" className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500" />
/>
</div> </div>
<div> <div>
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Kinh đ</label> <label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Kinh đ</label>
<input <input type="number" step="any" value={editLng}
type="number"
step="any"
value={editLng}
onChange={(e) => setEditLng(e.target.value === '' ? '' : parseFloat(e.target.value))} onChange={(e) => setEditLng(e.target.value === '' ? '' : parseFloat(e.target.value))}
placeholder="Kinh độ..." placeholder="Kinh độ..."
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500" className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500" />
/>
</div> </div>
</div> </div>
<button type="button" onClick={(e) => { e.stopPropagation(); setIsMapOpen(true); }}
<div className="flex justify-start"> className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-750 border border-slate-700/50 text-slate-300 hover:text-white rounded-xl text-[10px] font-bold transition-all">
<button <MapPin className="w-3.5 h-3.5 text-rose-500" />
type="button" Chọn trên bản đ
onClick={(e) => { </button>
e.stopPropagation();
setIsMapOpen(true);
}}
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-750 border border-slate-700/50 text-slate-300 hover:text-white rounded-xl text-[10px] font-bold transition-all"
>
<MapPin className="w-3.5 h-3.5 text-rose-500" />
Chọn trên bản đ
</button>
</div>
</div> </div>
<div className="flex justify-end gap-2 mt-1">
<div className="flex justify-end gap-2 mt-2"> <button onClick={(e) => { e.stopPropagation(); setIsEditing(false); }} disabled={isSavingEdit}
<button className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-bold transition-all">
onClick={(e) => {
e.stopPropagation();
setIsEditing(false);
}}
disabled={isSavingEdit}
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-bold transition-all"
>
Hủy Hủy
</button> </button>
<button <button onClick={(e) => { e.stopPropagation(); handleSaveEdit(); }} disabled={isSavingEdit}
onClick={(e) => { className="flex items-center gap-1 px-4 py-1.5 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition-all">
e.stopPropagation(); {isSavingEdit ? (<><Loader2 className="w-3 h-3 animate-spin" />Đang lưu...</>) : 'Lưu lại'}
handleSaveEdit();
}}
disabled={isSavingEdit}
className="flex items-center gap-1 px-4 py-1.5 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition-all"
>
{isSavingEdit ? (
<>
<Loader2 className="w-3 h-3 animate-spin" />
Đang lưu...
</>
) : (
'Lưu lại'
)}
</button> </button>
</div> </div>
</div> </div>
) : (
<>
{/* Title & Description display */}
<div className="flex justify-between items-start gap-4">
<div className="flex-1 min-w-0">
{photo.metadata?.title ? (
<h4 className="text-sm font-black text-white tracking-tight leading-snug break-words">
{photo.metadata.title}
</h4>
) : (
<span className="text-[10px] text-slate-500 italic block mb-1">Chưa tiêu đ</span>
)}
{photo.metadata?.description ? (
<p className="text-xs text-slate-300 leading-relaxed mt-1 max-h-20 overflow-y-auto no-scrollbar break-words">
{photo.metadata.description}
</p>
) : (
<span className="text-[10px] text-slate-500 italic block mt-1">Chưa tả</span>
)}
</div>
{isAuthorized && (
<button
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
className="p-1.5 bg-slate-800 hover:bg-slate-700 border border-slate-700/50 rounded-xl text-slate-300 hover:text-white transition-all shrink-0"
title="Chỉnh sửa thông tin"
>
<Edit className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Photo Metadata */}
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-slate-800/80 pt-3 text-xs text-slate-350">
<div className="space-y-1 text-left">
<div className="flex items-center gap-1.5 text-slate-400">
<Calendar className="w-3.5 h-3.5" />
Ngày chụp: {new Date(photo.capturedAt).toLocaleDateString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</div>
<div className="flex items-center gap-1.5 text-slate-400" title={photo.metadata?.lat && photo.metadata?.lng ? `${photo.metadata.lat.toFixed(6)}, ${photo.metadata.lng.toFixed(6)}` : ''}>
<MapPin className="w-3.5 h-3.5 text-rose-500" />
Đa điểm: {resolvedAddress}
</div>
<div className="hidden md:flex items-center gap-1.5 text-xs text-emerald-400">
<User className="w-3.5 h-3.5" />
Người đăng: {photo.uploader?.name || 'Ẩn danh'}
</div>
</div>
{isAuthorized && photo.originalUrl && (
<a
href={photo.originalUrl}
download
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 bg-slate-800/80 hover:bg-slate-700/80 border border-slate-700/50 text-slate-200 hover:text-white font-bold py-1 px-3 rounded-full transition-all active:scale-95 text-[10px]"
>
<Download className="w-3.5 h-3.5 text-emerald-500" />
Tải nh gốc
</a>
)}
</div>
</>
)}
<style>{`
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
`}</style>
</div>
</div>
{/* Right Side: Comments */}
<div className="w-full md:w-2/5 md:flex-1 md:min-h-0 flex flex-col bg-slate-900 border-t md:border-t-0 md:border-l border-slate-800">
{/* Comments Header */}
<div className="hidden md:block p-6 border-b border-slate-800">
<div>
<h3 className="text-lg font-black tracking-tight text-white flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-emerald-500" />
{t('commentSectionTitle')}
</h3>
<p className="text-xs text-slate-400 mt-1">nh chia sẻ công khai trên bản đ</p>
</div> </div>
)}
{/* Download original (authorized) */}
{isAuthorized && photo.originalUrl && (
<div className="px-5 py-2 border-b border-slate-800/40 flex justify-end">
<a href={photo.originalUrl} download target="_blank" rel="noopener noreferrer"
className="flex items-center gap-1.5 bg-slate-800/80 hover:bg-slate-700/80 border border-slate-700/50 text-slate-200 hover:text-white font-bold py-1 px-3 rounded-full transition-all active:scale-95 text-[10px]">
<Download className="w-3.5 h-3.5 text-emerald-500" />
Tải nh gốc
</a>
</div>
)}
{/* ---- COMMENTS HEADER (desktop) ---- */}
<div className="hidden md:block px-6 py-4 border-b border-slate-800">
<h3 className="text-base font-black tracking-tight text-white flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-emerald-500" />
{t('commentSectionTitle')}
</h3>
<p className="text-xs text-slate-400 mt-0.5">nh chia sẻ công khai trên bản đ</p>
</div> </div>
{/* Comments list scroll area */} {/* ---- COMMENT FEED ---- */}
<div className="md:flex-1 md:overflow-y-auto p-6 space-y-4 bg-slate-900/50"> <div className="p-4 space-y-4 flex-1">
{isLoading ? ( {isLoading ? (
<div className="flex flex-col items-center justify-center py-12 text-slate-500 gap-2"> <div className="flex flex-col items-center justify-center py-12 text-slate-500 gap-2">
<Loader2 className="w-8 h-8 animate-spin text-emerald-500" /> <Loader2 className="w-8 h-8 animate-spin text-emerald-500" />
<span className="text-xs font-semibold">{t('loading')}</span> <span className="text-xs font-semibold">{t('loading')}</span>
</div> </div>
) : comments.length === 0 ? ( ) : comments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-500 gap-3"> <div className="flex flex-col items-center justify-center py-12 text-slate-500 gap-3">
<div className="p-4 bg-slate-800/40 rounded-full text-slate-600"> <div className="p-4 bg-slate-800/40 rounded-full text-slate-600">
<MessageSquare className="w-8 h-8" /> <MessageSquare className="w-7 h-7" />
</div> </div>
<span className="text-sm font-semibold italic">Chưa bình luận nào. Hãy bắt đu cuộc trò chuyện!</span> <span className="text-sm font-semibold italic">Chưa bình luận nào. Hãy bắt đu cuộc trò chuyện!</span>
</div> </div>
) : ( ) : (
comments.map((c) => { comments.map((c) => (
return ( <div key={c.id} className="flex gap-3 items-start animate-in fade-in slide-in-from-bottom-2 duration-200">
<div key={c.id} className="flex gap-3 items-start animate-in fade-in slide-in-from-bottom-2 duration-200"> <div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center flex-shrink-0 border border-slate-700/60">
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center flex-shrink-0 border border-slate-700/60"> <User className="w-4 h-4 text-slate-400" />
<User className="w-4.5 h-4.5 text-slate-400" /> </div>
</div> <div className="flex-1 min-w-0">
<div className="flex-1 min-w-0"> <div className="bg-slate-800/55 p-3.5 rounded-2xl rounded-tl-none border border-slate-800 shadow-lg">
<div className="bg-slate-800/55 p-3.5 rounded-2xl rounded-tl-none border border-slate-800 shadow-lg"> <div className="flex justify-between items-center mb-1">
<div className="flex justify-between items-center mb-1"> <span className="text-xs font-black text-slate-200 truncate">{c.userName}</span>
<span className="text-xs font-black text-slate-200 truncate">{c.userName}</span> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <span className="text-[9px] font-medium text-slate-500">
<span className="text-[9px] font-medium text-slate-500"> {new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} </span>
</span> {(currentUser?.isAdmin || currentUser?.id === c.userId ||
{(currentUser?.isAdmin || currentUser?.id === photo.uploaderId || currentUser?.id === photo.uploader?.id) && (
currentUser?.id === c.userId || <button
currentUser?.id === photo.uploaderId || onClick={(e) => { e.stopPropagation(); handleDeleteComment(c.id); }}
currentUser?.id === photo.uploader?.id) && ( className="text-slate-500 hover:text-rose-500 transition-colors p-0.5"
<button title={t('delete') || "Xóa"}
onClick={(e) => { >
e.stopPropagation(); <Trash2 className="w-3.5 h-3.5" />
handleDeleteComment(c.id); </button>
}} )}
className="text-slate-500 hover:text-rose-500 transition-colors p-0.5"
title={t('delete') || "Xóa"}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div> </div>
<p className="text-sm text-slate-300 leading-relaxed break-words">{c.content}</p>
</div> </div>
<p className="text-sm text-slate-300 leading-relaxed break-words">{c.content}</p>
</div> </div>
</div> </div>
); </div>
}) ))
)} )}
<div ref={commentsEndRef} /> <div ref={commentsEndRef} />
</div> </div>
{/* Comment Input Area */} {/* ---- STICKY COMMENT INPUT TRAY BAR (z-20 stays above comments) ---- */}
<div className="sticky bottom-0 md:static p-4 bg-slate-950 md:bg-slate-950/40 border-t border-slate-800/80 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] md:pb-4 z-40"> <div className="sticky bottom-0 z-20 p-4 bg-slate-950 border-t border-slate-800/80 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] md:pb-4">
<div className="relative flex items-center gap-2"> <div className="relative flex items-center gap-2">
<input <input
type="text" type="text"
@@ -789,22 +740,19 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
disabled={isSending} disabled={isSending}
/> />
<button <button
onClick={(e) => { onClick={(e) => { e.stopPropagation(); handleSend(); }}
e.stopPropagation();
handleSend();
}}
disabled={!newComment.trim() || isSending} disabled={!newComment.trim() || isSending}
className="p-3 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-40 text-white rounded-2xl transition-all active:scale-95 shadow-lg shadow-emerald-950/30 flex items-center justify-center" className="p-3 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-40 text-white rounded-2xl transition-all active:scale-95 shadow-lg flex items-center justify-center"
> >
{isSending ? ( {isSending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
<Loader2 className="w-4.5 h-4.5 animate-spin" />
) : (
<Send className="w-4.5 h-4.5" />
)}
</button> </button>
</div> </div>
</div> </div>
<style>{`
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
`}</style>
</div> </div>
</div> </div>
@@ -822,24 +770,34 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
{isFullscreen && ( {isFullscreen && (
<div <div
className="fixed inset-0 z-[9999] bg-black/95 flex items-end sm:items-center justify-center cursor-zoom-out animate-in fade-in duration-200" className="fixed inset-0 bg-black/95 backdrop-blur-sm flex flex-col items-center justify-center z-[999999] overflow-hidden animate-fade-in cursor-zoom-out"
onClick={() => setIsFullscreen(false)} onClick={() => setIsFullscreen(false)}
> >
<button {/* Close Button Top Tracker Bar Container */}
onClick={() => setIsFullscreen(false)} <div className="absolute top-6 right-6 z-50">
className="fixed top-6 right-6 p-3 bg-black/50 hover:bg-black/75 border border-white/10 rounded-full text-white transition-colors z-[10000]" <button
> onClick={() => setIsFullscreen(false)}
<X className="w-6 h-6" /> className="p-3 bg-black/50 hover:bg-black/75 border border-white/10 rounded-full text-white transition-colors cursor-pointer"
</button> >
<img <X className="w-6 h-6" />
src={photo.imageUrl} </button>
alt="Fullscreen photo" </div>
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }} {/* Image wrapper frame context forcing clean alignment metrics */}
className={`max-w-full max-h-full sm:max-w-screen-md object-contain select-none animate-in zoom-in-95 duration-200 ${ <div className="w-full h-full flex items-center justify-center p-4">
!isLoggedIn ? 'pointer-events-none' : '' <img
}`} src={photo.imageUrl}
/> alt="Fullscreen photo"
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
className={`max-w-full max-h-full sm:max-w-screen-md object-contain select-none animate-in zoom-in-95 duration-200 ${
!isLoggedIn ? 'pointer-events-none' : ''
}`}
style={{
transform: 'translate3d(0, 0, 0)'
}}
/>
</div>
</div> </div>
)} )}
</div> </div>
+2 -1
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { io, Socket } from 'socket.io-client'; import { io, Socket } from 'socket.io-client';
import { Capacitor } from '@capacitor/core'; import { Capacitor } from '@capacitor/core';
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
import { BACKEND_URL } from '@/utils/backendEndpoint';
import { Send, MessageSquare, Loader2, Image as ImageIcon, MapPin, Download, X } from 'lucide-react'; import { Send, MessageSquare, Loader2, Image as ImageIcon, MapPin, Download, X } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification'; import { useNotification } from '@/hooks/useNotification';
@@ -182,7 +183,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId, embedded = false })
// Connect to socket and listen for tour messages // Connect to socket and listen for tour messages
useEffect(() => { useEffect(() => {
const socket = Capacitor.isNativePlatform() const socket = Capacitor.isNativePlatform()
? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn') ? io(BACKEND_URL)
: io(); : io();
socketRef.current = socket; socketRef.current = socket;
@@ -0,0 +1,172 @@
import React, { useState } from 'react';
import { X, Loader2 } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
interface PublicPhoto {
id: string;
title: string;
description: string;
latitude: number;
longitude: number;
capturedAt: string;
isFlagged?: boolean;
}
interface AdminPhotoEditModalProps {
photo: PublicPhoto;
onClose: () => void;
onSaveSuccess: (updatedPhoto: any) => void;
}
const toLocalDatetimeString = (isoString: string) => {
if (!isoString) return '';
const d = new Date(isoString);
if (isNaN(d.getTime())) return '';
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
export const AdminPhotoEditModal: React.FC<AdminPhotoEditModalProps> = ({ photo, onClose, onSaveSuccess }) => {
const notify = useNotification();
const [formData, setFormData] = useState({
title: photo.title,
description: photo.description,
latitude: photo.latitude,
longitude: photo.longitude,
capturedAt: toLocalDatetimeString(photo.capturedAt),
isFlagged: !!photo.isFlagged
});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleUpdateSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
const token = localStorage.getItem('token');
const response = await fetch(`/api/v1/admin/photos/${photo.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
title: formData.title,
description: formData.description,
latitude: Number(formData.latitude),
longitude: Number(formData.longitude),
capturedAt: new Date(formData.capturedAt).toISOString(),
isFlagged: formData.isFlagged
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Cập nhật thất bại.');
}
const updatedPhoto = await response.json();
notify({ title: 'Thành công', message: 'Đã cập nhật thông tin ảnh quản trị.', type: 'success' });
onSaveSuccess(updatedPhoto);
onClose();
} catch (error: any) {
console.error("[AdminEdit] Failed to save updated metadata overrides:", error);
notify({ title: 'Lỗi', message: error.message || 'Không thể cập nhật ảnh.', type: 'error' });
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm z-[99999] flex items-center justify-center p-4">
<form onSubmit={handleUpdateSubmit} className="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md p-6 text-white text-xs space-y-4 shadow-2xl animate-in zoom-in-95 duration-200">
<div className="flex justify-between items-center pb-2 border-b border-slate-800">
<h3 className="text-sm font-bold text-blue-400">Quản Trị - Sửa Thông Tin nh Public</h3>
<button type="button" onClick={onClose} className="p-1 text-slate-400 hover:text-white rounded-lg transition-colors">
<X className="w-4 h-4" />
</button>
</div>
<div>
<label className="block text-slate-400 mb-1 font-bold">Tiêu đ nh</label>
<input
type="text"
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-slate-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
value={formData.title}
onChange={e => setFormData({...formData, title: e.target.value})}
/>
</div>
<div>
<label className="block text-slate-400 mb-1 font-bold"> tả</label>
<textarea
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-slate-100 focus:outline-none focus:ring-1 focus:ring-blue-500 h-20 resize-none"
value={formData.description}
onChange={e => setFormData({...formData, description: e.target.value})}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-slate-400 mb-1 font-bold"> đ (Latitude)</label>
<input
type="number"
step="any"
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-slate-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
value={formData.latitude}
onChange={e => setFormData({...formData, latitude: Number(e.target.value)})}
/>
</div>
<div>
<label className="block text-slate-400 mb-1 font-bold">Kinh đ (Longitude)</label>
<input
type="number"
step="any"
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-slate-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
value={formData.longitude}
onChange={e => setFormData({...formData, longitude: Number(e.target.value)})}
/>
</div>
</div>
<div>
<label className="block text-slate-400 mb-1 font-bold">Thời gian chụp (Captured At)</label>
<input
type="datetime-local"
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-slate-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
value={formData.capturedAt}
onChange={e => setFormData({...formData, capturedAt: e.target.value})}
/>
</div>
<div className="flex items-center gap-2 pt-1">
<input
type="checkbox"
id="isFlagged"
className="w-4 h-4 bg-slate-950 border border-slate-800 rounded text-blue-500 focus:ring-0 focus:ring-offset-0"
checked={formData.isFlagged}
onChange={e => setFormData({...formData, isFlagged: e.target.checked})}
/>
<label htmlFor="isFlagged" className="text-slate-350 cursor-pointer select-none font-bold">n / Gắn cờ nh (Flagged status)</label>
</div>
<div className="flex justify-end gap-3 pt-2 border-t border-slate-800">
<button type="button" onClick={onClose} className="px-4 py-2.5 bg-slate-800 hover:bg-slate-700 rounded-xl transition-all font-bold">Hủy</button>
<button type="submit" disabled={isSubmitting} className="px-4 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 rounded-xl font-bold flex items-center gap-1.5 transition-all">
{isSubmitting ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Đang lưu...
</>
) : (
'Lưu thay đổi'
)}
</button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,59 @@
import React, { useState, useEffect } from 'react';
import { X, Download } from 'lucide-react';
import { Capacitor } from '@capacitor/core';
import { BACKEND_URL } from '@/utils/backendEndpoint';
export const AppDownloadBanner: React.FC = () => {
const [isVisible, setIsVisible] = useState(false);
// Force APK download relative to backend endpoint
const APK_DOWNLOAD_URL = `${BACKEND_URL}/downloads/yotrip-latest.apk`;
useEffect(() => {
const userAgent = navigator.userAgent.toLowerCase();
const isAndroidBrowser = userAgent.includes('android') && !Capacitor.isNativePlatform();
const isBannerDismissed = localStorage.getItem('yotrip_apk_banner_dismissed') === 'true';
// Target condition match: User is on Android mobile browser and hasn't closed it yet
if (isAndroidBrowser && !isBannerDismissed) {
setIsVisible(true);
}
}, []);
const handleDismiss = () => {
localStorage.setItem('yotrip_apk_banner_dismissed', 'true');
setIsVisible(false);
};
if (!isVisible) return null;
return (
<div className="w-full bg-gradient-to-r from-blue-900 to-indigo-950 border-b border-blue-800 px-4 py-2.5 flex items-center justify-between text-white text-[11px] font-medium z-40 relative shrink-0">
<div className="flex items-center gap-2.5 flex-1 min-w-0">
<div className="p-1 bg-blue-500/20 rounded-lg text-blue-400 shrink-0">
<Download className="w-3.5 h-3.5" />
</div>
<p className="truncate text-slate-200">
Trải nghiệm mượt hơn với ng dụng <span className="text-white font-bold">YoTrip cho Android</span>!
</p>
</div>
<div className="flex items-center gap-3 ml-2 shrink-0">
<a
href={APK_DOWNLOAD_URL}
download="yotrip.apk"
className="bg-blue-600 hover:bg-blue-500 px-3 py-1 rounded-xl font-bold text-white transition-all shadow-sm active:scale-95 text-[10px]"
>
Tải APK
</a>
<button
onClick={handleDismiss}
className="p-1 hover:bg-slate-800 rounded text-slate-400 hover:text-slate-200 transition-colors"
title="Đóng thông báo"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
};
@@ -39,10 +39,16 @@ export const FriendsManagerModal: React.FC<FriendsManagerModalProps> = ({
const res = await fetch('/api/v1/connections', { headers: getHeaders() }); const res = await fetch('/api/v1/connections', { headers: getHeaders() });
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setConnections(data || []); const list = Array.isArray(data)
? data
: (data && Array.isArray(data.connections) ? data.connections : []);
setConnections(list);
} else {
setConnections([]);
} }
} catch (e) { } catch (e) {
console.error('[FriendsManagerModal] Error fetching connections:', e); console.error('[FriendsManagerModal] Error fetching connections:', e);
setConnections([]);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -170,15 +176,19 @@ export const FriendsManagerModal: React.FC<FriendsManagerModalProps> = ({
if (!isOpen || !user) return null; if (!isOpen || !user) return null;
// Filter lists // Filter lists
const activeFriends = connections.filter((c: any) => c.status === 'ACCEPTED'); const activeFriends = Array.isArray(connections)
? connections.filter((c: any) => c && c.status === 'ACCEPTED')
: [];
// Received pending requests // Received pending requests
const pendingRequests = connections.filter((c: any) => c.status === 'PENDING' && c.targetUser?.id === user?.id); const pendingRequests = Array.isArray(connections)
? connections.filter((c: any) => c && c.status === 'PENDING' && c.targetUser?.id === user?.id)
: [];
const getStatusText = (targetUserId: string) => { const getStatusText = (targetUserId: string) => {
const existing = connections.find( const existing = Array.isArray(connections)
c => c.targetUser?.id === targetUserId || c.requester?.id === targetUserId ? connections.find(c => c && (c.targetUser?.id === targetUserId || c.requester?.id === targetUserId))
); : null;
if (!existing) return null; if (!existing) return null;
if (existing.status === 'ACCEPTED') return 'FRIEND'; if (existing.status === 'ACCEPTED') return 'FRIEND';
if (existing.status === 'PENDING') { if (existing.status === 'PENDING') {
@@ -46,11 +46,17 @@ export const LiveChatModal: React.FC<LiveChatModalProps> = ({
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
// Accepted connections only // Accepted connections only
const activeConns = data.filter((c: any) => c.status === 'ACCEPTED'); const list = Array.isArray(data)
? data
: (data && Array.isArray(data.connections) ? data.connections : []);
const activeConns = list.filter((c: any) => c && c.status === 'ACCEPTED');
setConnections(activeConns); setConnections(activeConns);
} else {
setConnections([]);
} }
} catch (e) { } catch (e) {
console.error('[LiveChatModal] Error fetching connections:', e); console.error('[LiveChatModal] Error fetching connections:', e);
setConnections([]);
} finally { } finally {
setIsLoadingContacts(false); setIsLoadingContacts(false);
} }
@@ -236,10 +242,12 @@ export const LiveChatModal: React.FC<LiveChatModalProps> = ({
if (!isOpen || !user) return null; if (!isOpen || !user) return null;
// Filter connections by search query // Filter connections by search query
const filteredConnections = connections.filter((conn: any) => { const filteredConnections = Array.isArray(connections)
const friend = conn.targetUser?.id === user?.id ? conn.requester : conn.targetUser; ? connections.filter((conn: any) => {
return friend?.name?.toLowerCase().includes(searchQuery.toLowerCase()); const friend = conn.targetUser?.id === user?.id ? conn.requester : conn.targetUser;
}); return friend?.name?.toLowerCase().includes(searchQuery.toLowerCase());
})
: [];
return ( return (
<div className="fixed inset-0 z-[1000000] bg-black/70 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-4 pointer-events-auto"> <div className="fixed inset-0 z-[1000000] bg-black/70 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-4 pointer-events-auto">
+19 -14
View File
@@ -20,6 +20,7 @@ import { MyToursModal } from '../components/modals/MyToursModal';
import { PhotoGalleryModal as MyPhotosModal } from '../components/modals/PhotoGalleryModal'; import { PhotoGalleryModal as MyPhotosModal } from '../components/modals/PhotoGalleryModal';
import { LiveChatModal } from '../components/modals/LiveChatModal'; import { LiveChatModal } from '../components/modals/LiveChatModal';
import { FriendsManagerModal } from '../components/modals/FriendsManagerModal'; import { FriendsManagerModal } from '../components/modals/FriendsManagerModal';
import { AppDownloadBanner } from '../components/layout/AppDownloadBanner';
// Fix lỗi icon mặc định của Leaflet // Fix lỗi icon mặc định của Leaflet
const DefaultIcon = L.icon({ const DefaultIcon = L.icon({
@@ -194,9 +195,9 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onLoginSuccess,
const groups: { [key: string]: any[] } = {}; const groups: { [key: string]: any[] } = {};
filteredPhotos.forEach((photo) => { filteredPhotos.forEach((photo) => {
const lat = photo.metadata?.lat; const lat = typeof photo.metadata?.lat === 'number' ? photo.metadata.lat : parseFloat(photo.metadata?.lat);
const lng = photo.metadata?.lng; const lng = typeof photo.metadata?.lng === 'number' ? photo.metadata.lng : parseFloat(photo.metadata?.lng);
if (typeof lat === 'number' && typeof lng === 'number') { if (typeof lat === 'number' && !isNaN(lat) && typeof lng === 'number' && !isNaN(lng)) {
const key = `${lat.toFixed(5)},${lng.toFixed(5)}`; const key = `${lat.toFixed(5)},${lng.toFixed(5)}`;
if (!groups[key]) { if (!groups[key]) {
groups[key] = []; groups[key] = [];
@@ -628,13 +629,13 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onLoginSuccess,
if (photoId && publicPhotos.length > 0) { if (photoId && publicPhotos.length > 0) {
const foundPhoto = publicPhotos.find((p) => p.id === photoId); const foundPhoto = publicPhotos.find((p) => p.id === photoId);
if (foundPhoto) { if (foundPhoto) {
const lat = foundPhoto.metadata?.lat; const lat = typeof foundPhoto.metadata?.lat === 'number' ? foundPhoto.metadata.lat : parseFloat(foundPhoto.metadata?.lat);
const lng = foundPhoto.metadata?.lng; const lng = typeof foundPhoto.metadata?.lng === 'number' ? foundPhoto.metadata.lng : parseFloat(foundPhoto.metadata?.lng);
if (typeof lat === 'number' && typeof lng === 'number') { if (typeof lat === 'number' && !isNaN(lat) && typeof lng === 'number' && !isNaN(lng)) {
const group = publicPhotos.filter((p) => { const group = publicPhotos.filter((p) => {
const pLat = p.metadata?.lat; const pLat = typeof p.metadata?.lat === 'number' ? p.metadata.lat : parseFloat(p.metadata?.lat);
const pLng = p.metadata?.lng; const pLng = typeof p.metadata?.lng === 'number' ? p.metadata.lng : parseFloat(p.metadata?.lng);
return typeof pLat === 'number' && typeof pLng === 'number' && return typeof pLat === 'number' && !isNaN(pLat) && typeof pLng === 'number' && !isNaN(pLng) &&
Math.abs(pLat - lat) < 0.00001 && Math.abs(pLat - lat) < 0.00001 &&
Math.abs(pLng - lng) < 0.00001; Math.abs(pLng - lng) < 0.00001;
}); });
@@ -654,8 +655,11 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onLoginSuccess,
}, [publicPhotos]); }, [publicPhotos]);
return ( return (
<div className="h-dvh w-full relative overflow-hidden"> <div className="h-dvh w-full flex flex-col overflow-hidden bg-slate-950">
{/* Top Bar Container - Chứa tất cả các nút điều hướng phía trên */} <AppDownloadBanner />
<div className="flex-1 w-full relative min-h-0">
{/* Top Bar Container - Chứa tất cả các nút điều hướng phía trên */}
<div className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] left-4 right-4 z-[1002] flex items-center justify-between pointer-events-none"> <div className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] left-4 right-4 z-[1002] flex items-center justify-between pointer-events-none">
{/* Nhóm bên trái: Quay lại và Thông tin vị trí */} {/* Nhóm bên trái: Quay lại và Thông tin vị trí */}
<div className="flex items-center gap-3 pointer-events-auto"> <div className="flex items-center gap-3 pointer-events-auto">
@@ -965,9 +969,9 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onLoginSuccess,
{groupedPhotos.map((photoGroup) => { {groupedPhotos.map((photoGroup) => {
const latestPhoto = photoGroup[0]; const latestPhoto = photoGroup[0];
const lat = latestPhoto.metadata?.lat; const lat = typeof latestPhoto.metadata?.lat === 'number' ? latestPhoto.metadata.lat : parseFloat(latestPhoto.metadata?.lat);
const lng = latestPhoto.metadata?.lng; const lng = typeof latestPhoto.metadata?.lng === 'number' ? latestPhoto.metadata.lng : parseFloat(latestPhoto.metadata?.lng);
if (typeof lat !== 'number' || typeof lng !== 'number') return null; if (typeof lat !== 'number' || isNaN(lat) || typeof lng !== 'number' || isNaN(lng)) return null;
return ( return (
<Marker <Marker
@@ -1125,6 +1129,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onLoginSuccess,
)} )}
</div> </div>
)} )}
</div>
{/* Admin Modal */} {/* Admin Modal */}
<UserManagementModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} /> <UserManagementModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} />
+178 -45
View File
@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { Compass, Map as MapIcon, Camera, ShieldAlert, Image as ImageIcon, X } from 'lucide-react'; import { Compass, Map as MapIcon, Camera as CameraIcon, ShieldAlert, Image as ImageIcon, X } from 'lucide-react';
import { LoginModal } from '../components/LoginModal'; import { LoginModal } from '../components/LoginModal';
import { ReportBusinessModal } from '../components/ReportBusinessModal'; import { ReportBusinessModal } from '../components/ReportBusinessModal';
import { TagSelectModal } from '../components/TagSelectModal'; import { TagSelectModal } from '../components/TagSelectModal';
@@ -9,10 +9,15 @@ import { MyToursModal } from '../components/modals/MyToursModal';
import { PhotoGalleryModal as MyPhotosModal } from '../components/modals/PhotoGalleryModal'; import { PhotoGalleryModal as MyPhotosModal } from '../components/modals/PhotoGalleryModal';
import { LiveChatModal } from '../components/modals/LiveChatModal'; import { LiveChatModal } from '../components/modals/LiveChatModal';
import { FriendsManagerModal } from '../components/modals/FriendsManagerModal'; import { FriendsManagerModal } from '../components/modals/FriendsManagerModal';
import { AppDownloadBanner } from '../components/layout/AppDownloadBanner';
import { useNotification } from '@/hooks/useNotification'; import { useNotification } from '@/hooks/useNotification';
import { processImageModeration } from '../hooks/useImageModeration'; import { processImageModeration } from '../hooks/useImageModeration';
import { useTranslation } from '../hooks/useTranslation'; import { useTranslation } from '../hooks/useTranslation';
import { compressImage } from '../utils/image'; 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';
interface LandingPageProps { interface LandingPageProps {
onContinue?: () => void; onContinue?: () => void;
@@ -48,7 +53,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({
const cameraInputRef = useRef<HTMLInputElement>(null); const cameraInputRef = useRef<HTMLInputElement>(null);
const galleryInputRef = useRef<HTMLInputElement>(null); const galleryInputRef = useRef<HTMLInputElement>(null);
const [pendingPhotoFile, setPendingPhotoFile] = useState<File | null>(null); const [pendingPhotoFile, setPendingPhotoFile] = useState<File | null>(null);
const [pendingPhotoLocation, setPendingPhotoLocation] = useState<GeolocationPosition | null>(null); const [pendingPhotoLocation, setPendingPhotoLocation] = useState<{ latitude: number; longitude: number } | null>(null);
const [photoPreviewUrl, setPhotoPreviewUrl] = useState<string>(''); const [photoPreviewUrl, setPhotoPreviewUrl] = useState<string>('');
const notify = useNotification(); const notify = useNotification();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -178,6 +183,16 @@ export const LandingPage: React.FC<LandingPageProps> = ({
fetchBlacklist(); 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(() => { useEffect(() => {
if (publicPhotos.length <= 1) return; if (publicPhotos.length <= 1) return;
const interval = setInterval(() => { const interval = setInterval(() => {
@@ -186,60 +201,163 @@ export const LandingPage: React.FC<LandingPageProps> = ({
return () => clearInterval(interval); return () => clearInterval(interval);
}, [publicPhotos]); }, [publicPhotos]);
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const processAndUploadFile = async (file: File) => {
const file = event.target.files?.[0];
if (!file) return;
notify({ title: 'Đang xử lý...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
try { try {
// Nén ảnh trước // Process image: Reads EXIF data and forces 2K resizing on device memory
const compressedFile = await compressImage(file); const { file: processedFile, latitude: exifLat, longitude: exifLng } = await processAndResizeImage(file);
// 0. Kiểm duyệt ảnh // 0. Kiểm duyệt ảnh
const moderationResult = await processImageModeration(compressedFile); const moderationResult = await processImageModeration(processedFile);
if (moderationResult.blocked) { if (moderationResult.blocked) {
notify({ title: 'Ảnh bị từ chối', message: `Ảnh ${file.name} chứa nội dung không phù hợp và bị chặn.`, type: 'error' }); notify({ title: 'Ảnh bị từ chối', message: `Ảnh ${file.name} chứa nội dung không phù hợp và bị chặn.`, type: 'error' });
return; return;
} }
const processedFile = moderationResult.file; const finalProcessedFile = moderationResult.file;
// Lấy tọa độ hiện tại của người dùng với cơ chế chống treo (Promise.race) // Determine image upload coordinates based on priority checklist:
const location = await Promise.race([ let finalLat: number | null = exifLat;
new Promise<GeolocationPosition | null>((resolve) => { let finalLng: number | null = exifLng;
if (!navigator.geolocation) {
resolve(null); if (finalLat !== null && finalLng !== null) {
} else { console.log('[Upload Location] Priority 1: EXIF data coordinates found:', finalLat, finalLng);
navigator.geolocation.getCurrentPosition( }
(pos) => resolve(pos),
() => resolve(null), // 2. Get current mobile/device GPS position of the user
{ timeout: 4000, enableHighAccuracy: true } if (finalLat === null || finalLng === null) {
); try {
const deviceLoc = await getDeviceLocation();
if (deviceLoc) {
finalLat = deviceLoc.latitude;
finalLng = deviceLoc.longitude;
console.log('[Upload Location] Priority 2: GPS coordinates found:', finalLat, finalLng);
} }
}), } catch (e) {
new Promise<null>((resolve) => setTimeout(() => resolve(null), 4500)) console.error('[Upload Location] Error acquiring current GPS:', e);
]); }
}
// Lưu file và location vào state pending, hiển thị modal tags // 3. Get last viewed map viewport center coordinates
setPendingPhotoFile(processedFile); if (finalLat === null || finalLng === null) {
setPendingPhotoLocation(location); const lastViewStateStr = localStorage.getItem('map_view_state');
if (lastViewStateStr) {
try {
const lastViewState = JSON.parse(lastViewStateStr);
if (lastViewState && Array.isArray(lastViewState.center) && lastViewState.center.length === 2) {
finalLat = Number(lastViewState.center[0]);
finalLng = Number(lastViewState.center[1]);
console.log('[Upload Location] Priority 3: Last viewed map viewport center used:', finalLat, finalLng);
}
} catch (e) {
console.error('[Upload Location] Error parsing map_view_state:', e);
}
}
}
// 4. Default fallback location coordinates
if (finalLat === null || finalLng === null) {
finalLat = 10.7769;
finalLng = 106.7009;
console.log('[Upload Location] Priority 4: Using default fallback coordinates:', finalLat, finalLng);
}
// Save file and resolved coordinates into state
setPendingPhotoFile(finalProcessedFile);
setPendingPhotoLocation({ latitude: finalLat, longitude: finalLng });
// Tạo preview URL cho ảnh // Tạo preview URL cho ảnh
const previewUrl = URL.createObjectURL(processedFile); const previewUrl = URL.createObjectURL(finalProcessedFile);
setPhotoPreviewUrl(previewUrl); setPhotoPreviewUrl(previewUrl);
setIsTagsModalOpen(true); setIsTagsModalOpen(true);
} catch (error: any) { } catch (error: any) {
notify({ title: 'Lỗi', message: error.message, type: 'error' }); notify({ title: 'Lỗi', message: error.message, type: 'error' });
} finally {
// Reset input để có thể chọn lại cùng 1 file
if (event.target) event.target.value = '';
} }
}; };
const handleNativePhotoPick = async (source: CameraSource) => {
try {
const image = await Camera.getPhoto({
quality: 90,
allowEditing: false,
resultType: CameraResultType.Uri,
source: source,
saveToGallery: source === CameraSource.Camera // Tự động lưu ảnh gốc vào thư viện nếu chụp bằng Camera
});
if (image && image.webPath) {
notify({ title: 'Đang xử lý...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
// Convert Capacitor webPath resource back to standard File instance
const response = await fetch(image.webPath);
const blob = await response.blob();
const originalName = `photo-${Date.now()}.${image.format}`;
const file = new File([blob], originalName, { type: `image/${image.format}` });
// Process this file using our standard handler
await processAndUploadFile(file);
}
} catch (error: any) {
console.error('Lỗi chọn ảnh native:', error);
if (error?.message !== 'User cancelled photos app' && error?.message !== 'User cancelled camera') {
notify({
title: 'Lỗi',
message: 'Không thể truy cập máy ảnh hoặc thư viện ảnh.',
type: 'error'
});
}
}
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
await processAndUploadFile(file);
if (event.target) event.target.value = '';
};
const handleConfirmTags = async (selectedTags: string[]) => { const handleConfirmTags = async (selectedTags: string[]) => {
if (!pendingPhotoFile) return; if (!pendingPhotoFile) return;
setIsTagsModalOpen(false); 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' }); notify({ title: 'Đang tải lên...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
try { try {
@@ -269,15 +387,15 @@ export const LandingPage: React.FC<LandingPageProps> = ({
// 3. Tải ảnh lên // 3. Tải ảnh lên
const formData = new FormData(); const formData = new FormData();
formData.append('images', pendingPhotoFile);
if (pendingPhotoLocation) { if (pendingPhotoLocation) {
formData.append('latitude', pendingPhotoLocation.coords.latitude.toString()); formData.append('latitude', pendingPhotoLocation.latitude.toString());
formData.append('longitude', pendingPhotoLocation.coords.longitude.toString()); formData.append('longitude', pendingPhotoLocation.longitude.toString());
} }
// Thêm tags vào formData // Thêm tags vào formData
if (selectedTags.length > 0) { if (selectedTags.length > 0) {
formData.append('tags', JSON.stringify(selectedTags)); formData.append('tags', JSON.stringify(selectedTags));
} }
formData.append('images', pendingPhotoFile);
let uploadRes = await fetch('/api/v1/photos/upload-anonymous', { let uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
method: 'POST', method: 'POST',
@@ -310,8 +428,6 @@ export const LandingPage: React.FC<LandingPageProps> = ({
throw new Error(errorData.message || 'Tải ảnh thất bại.'); throw new Error(errorData.message || 'Tải ảnh thất bại.');
} }
notify({ title: 'Thành công!', message: 'Ảnh của bạn đã được tải lên.', type: 'success' });
// Xóa pendingInviteToken và parameter token khỏi URL để tránh tự động chuyển sang JoinTourPage // Xóa pendingInviteToken và parameter token khỏi URL để tránh tự động chuyển sang JoinTourPage
localStorage.removeItem('pendingInviteToken'); localStorage.removeItem('pendingInviteToken');
const url = new URL(window.location.href); const url = new URL(window.location.href);
@@ -343,7 +459,10 @@ export const LandingPage: React.FC<LandingPageProps> = ({
return ( return (
<div className="h-dvh w-full overflow-hidden font-sans bg-[var(--background)] relative"> <div className="h-dvh w-full flex flex-col overflow-hidden font-sans bg-[var(--background)]">
<AppDownloadBanner />
<div className="flex-1 w-full relative min-h-0">
{/* Background Image with Horizontal Panning */} {/* Background Image with Horizontal Panning */}
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0">
{/* Active image for panning */} {/* Active image for panning */}
@@ -640,7 +759,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({
onClick={() => setIsPhotoSourceModalOpen(true)} onClick={() => setIsPhotoSourceModalOpen(true)}
className="flex-1 flex items-center justify-center gap-2 bg-white/20 backdrop-blur-lg hover:bg-white/30 text-white font-bold py-3.5 rounded-2xl transition-all shadow-2xl border border-white/30 active:scale-95 text-xs sm:text-sm whitespace-nowrap" className="flex-1 flex items-center justify-center gap-2 bg-white/20 backdrop-blur-lg hover:bg-white/30 text-white font-bold py-3.5 rounded-2xl transition-all shadow-2xl border border-white/30 active:scale-95 text-xs sm:text-sm whitespace-nowrap"
> >
<Camera className="w-4.5 h-4.5" /> <CameraIcon className="w-4.5 h-4.5" />
<span>{t('shortCamera') || 'Chụp ảnh'}</span> <span>{t('shortCamera') || 'Chụp ảnh'}</span>
</button> </button>
</div> </div>
@@ -655,13 +774,19 @@ export const LandingPage: React.FC<LandingPageProps> = ({
} }
`}</style> `}</style>
</div> </div>
</div>
{/* Login Modal Component */} {/* Login Modal Component */}
<LoginModal <LoginModal
isOpen={isLoginModalOpen} isOpen={isLoginModalOpen}
onClose={() => setIsLoginModalOpen(false)} onClose={() => setIsLoginModalOpen(false)}
onSwitchToSignup={onGoToSignup} onSwitchToSignup={onGoToSignup}
onLoginSuccess={onLoginSuccess} onLoginSuccess={(loggedInUser) => {
setIsLoginModalOpen(false);
if (onLoginSuccess) {
onLoginSuccess(loggedInUser);
}
}}
/> />
{/* Report Business Modal */} {/* Report Business Modal */}
@@ -707,12 +832,16 @@ export const LandingPage: React.FC<LandingPageProps> = ({
<button <button
onClick={() => { onClick={() => {
setIsPhotoSourceModalOpen(false); setIsPhotoSourceModalOpen(false);
cameraInputRef.current?.click(); if (Capacitor.isNativePlatform()) {
handleNativePhotoPick(CameraSource.Camera);
} else {
cameraInputRef.current?.click();
}
}} }}
className="w-full flex items-center gap-4 p-4 bg-[var(--background)] hover:bg-[var(--background-alt)] border border-[var(--border)] rounded-2xl transition-all active:scale-95" className="w-full flex items-center gap-4 p-4 bg-[var(--background)] hover:bg-[var(--background-alt)] border border-[var(--border)] rounded-2xl transition-all active:scale-95"
> >
<div className="flex-shrink-0 p-3 bg-blue-100 rounded-full"> <div className="flex-shrink-0 p-3 bg-blue-100 rounded-full">
<Camera className="w-6 h-6 text-blue-600" /> <CameraIcon className="w-6 h-6 text-blue-600" />
</div> </div>
<div className="flex-1 text-left"> <div className="flex-1 text-left">
<div className="font-bold text-[var(--text-primary)]">Chụp nh bằng camera</div> <div className="font-bold text-[var(--text-primary)]">Chụp nh bằng camera</div>
@@ -724,7 +853,11 @@ export const LandingPage: React.FC<LandingPageProps> = ({
<button <button
onClick={() => { onClick={() => {
setIsPhotoSourceModalOpen(false); setIsPhotoSourceModalOpen(false);
galleryInputRef.current?.click(); if (Capacitor.isNativePlatform()) {
handleNativePhotoPick(CameraSource.Photos);
} else {
galleryInputRef.current?.click();
}
}} }}
className="w-full flex items-center gap-4 p-4 bg-[var(--background)] hover:bg-[var(--background-alt)] border border-[var(--border)] rounded-2xl transition-all active:scale-95" className="w-full flex items-center gap-4 p-4 bg-[var(--background)] hover:bg-[var(--background-alt)] border border-[var(--border)] rounded-2xl transition-all active:scale-95"
> >
+77 -29
View File
@@ -1,4 +1,8 @@
import React, { useEffect, useState, useRef } from 'react'; 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 { import {
Compass, Compass,
Users, Users,
@@ -97,6 +101,7 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
const [chatMessages, setChatMessages] = useState<any[]>([]); const [chatMessages, setChatMessages] = useState<any[]>([]);
const [newMessage, setNewMessage] = useState(''); const [newMessage, setNewMessage] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const socketRef = useRef<Socket | null>(null);
useEffect(() => { useEffect(() => {
@@ -283,6 +288,37 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
} }
setIsLocating(false); 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 // Upload photo to tour
setIsUploading(true); setIsUploading(true);
try { try {
@@ -530,14 +566,25 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
muteNotificationsRef.current = muteNotifications; muteNotificationsRef.current = muteNotifications;
}); });
// Socket connection for realtime messaging - changed to listen to global socket events // Socket connection for realtime messaging
useEffect(() => { useEffect(() => {
if (!user?.id) return; if (!user?.id) return;
const handleMessageReceived = (e: Event) => { // Connect to WebSocket: absolute URL on native, relative (Vite proxy) on web
const message = (e as CustomEvent).detail; const socket = Capacitor.isNativePlatform()
// If we are actively chatting with the sender of this message ? io(BACKEND_URL)
if (activeChatUser && (message.senderId === activeChatUser.id || message.receiverId === activeChatUser.id)) { : io();
socketRef.current = socket;
socket.on('connect', () => {
console.log('[WS] MemberDashboard connected:', socket.id);
socket.emit('joinUser', user.id);
});
const handleMessageReceived = (message: any) => {
// Check against window-global activeChatUserId so stale closure doesn't block updates
const activeChatUserId = (window as any).activeChatUserId;
if (activeChatUserId && (message.senderId === activeChatUserId || message.receiverId === activeChatUserId)) {
setChatMessages(prev => [...prev, message]); setChatMessages(prev => [...prev, message]);
} else { } else {
// Add to unread states // Add to unread states
@@ -548,31 +595,24 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
} }
}; };
const handleConnectionAccepted = () => { socket.on('messageReceived', handleMessageReceived);
socket.on('connectionAccepted', () => {
fetchConnectionsRef.current(); fetchConnectionsRef.current();
}; });
const handleTourMessageNotification = (e: Event) => { socket.on('tourMessageNotification', (data: any) => {
const data = (e as CustomEvent).detail;
setUnreadTourChats(prev => prev.includes(data.tourId) ? prev : [...prev, data.tourId]); setUnreadTourChats(prev => prev.includes(data.tourId) ? prev : [...prev, data.tourId]);
}; });
const handleJoinRequestAccepted = () => { socket.on('joinRequestAccepted', () => {
fetchPublicToursRef.current(); fetchPublicToursRef.current();
}; });
window.addEventListener('app:messageReceived', handleMessageReceived);
window.addEventListener('app:connectionAccepted', handleConnectionAccepted);
window.addEventListener('app:tourMessageNotification', handleTourMessageNotification);
window.addEventListener('app:joinRequestAccepted', handleJoinRequestAccepted);
return () => { return () => {
window.removeEventListener('app:messageReceived', handleMessageReceived); socket.disconnect();
window.removeEventListener('app:connectionAccepted', handleConnectionAccepted);
window.removeEventListener('app:tourMessageNotification', handleTourMessageNotification);
window.removeEventListener('app:joinRequestAccepted', handleJoinRequestAccepted);
}; };
}, [user?.id, activeChatUser]); }, [user?.id]);
// Autoscroll chat to bottom // Autoscroll chat to bottom
useEffect(() => { useEffect(() => {
@@ -589,12 +629,17 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
const res = await fetch('/api/v1/connections', { headers: getHeaders() }); const res = await fetch('/api/v1/connections', { headers: getHeaders() });
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setConnections(data.connections || []); // Defensive Array.isArray guards — API may return objects or null on error
setReceivedRequests(data.receivedRequests || []); setConnections(Array.isArray(data.connections) ? data.connections : []);
setSentRequests(data.sentRequests || []); setReceivedRequests(Array.isArray(data.receivedRequests) ? data.receivedRequests : []);
setSentRequests(Array.isArray(data.sentRequests) ? data.sentRequests : []);
} }
} catch (err) { } catch (err) {
console.error('Lỗi khi tải danh sách kết nối:', err); console.error('Lỗi khi tải danh sách kết nối:', err);
// Ensure state is always a clean array even on network failures
setConnections([]);
setReceivedRequests([]);
setSentRequests([]);
} }
}; };
@@ -603,10 +648,12 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
const res = await fetch('/api/v1/users/me/photos', { headers: getHeaders() }); const res = await fetch('/api/v1/users/me/photos', { headers: getHeaders() });
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setPhotos(data || []); // Defensive guard — API may return { photos: [] } or null
setPhotos(Array.isArray(data) ? data : (Array.isArray(data?.photos) ? data.photos : []));
} }
} catch (err) { } catch (err) {
console.error('Lỗi tải ảnh cá nhân:', err); console.error('Lỗi tải ảnh cá nhân:', err);
setPhotos([]);
} }
}; };
@@ -629,7 +676,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
const res = await fetch(`/api/v1/users?q=${encodeURIComponent(searchQuery)}`, { headers: getHeaders() }); const res = await fetch(`/api/v1/users?q=${encodeURIComponent(searchQuery)}`, { headers: getHeaders() });
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setSearchResults(data || []); // Defensive guard — search endpoint may return { users: [] } or plain array
setSearchResults(Array.isArray(data) ? data : (Array.isArray(data?.users) ? data.users : []));
} }
} catch (err) { } catch (err) {
console.error('Lỗi tìm kiếm thành viên:', err); console.error('Lỗi tìm kiếm thành viên:', err);
@@ -946,7 +994,7 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
</button> </button>
<a <a
href={`${import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn'}/downloads/yotrip-latest.apk`} href={`${BACKEND_URL}/downloads/yotrip-latest.apk`}
download="yotrip.apk" download="yotrip.apk"
className="w-full py-3 px-4 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-2xl font-bold flex items-center justify-center gap-2 shadow-md active:scale-95 transition-all text-xs text-center" className="w-full py-3 px-4 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-2xl font-bold flex items-center justify-center gap-2 shadow-md active:scale-95 transition-all text-xs text-center"
> >
@@ -1206,7 +1254,7 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
</button> </button>
<a <a
href={`${import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn'}/downloads/yotrip-latest.apk`} href={`${BACKEND_URL}/downloads/yotrip-latest.apk`}
download="yotrip.apk" download="yotrip.apk"
className="w-full py-2.5 px-4 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-xl font-bold flex items-center justify-center gap-2 shadow-md active:scale-95 transition-all duration-150 text-xs" className="w-full py-2.5 px-4 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-xl font-bold flex items-center justify-center gap-2 shadow-md active:scale-95 transition-all duration-150 text-xs"
> >
+2 -1
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useMemo, useRef } from 'react'; import React, { useState, useEffect, useMemo, useRef } from 'react';
import { io } from 'socket.io-client'; import { io } from 'socket.io-client';
import { Capacitor } from '@capacitor/core'; import { Capacitor } from '@capacitor/core';
import { BACKEND_URL } from '@/utils/backendEndpoint';
import { jsPDF } from 'jspdf'; import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable'; import autoTable from 'jspdf-autotable';
import { robotoBase64, robotoBoldBase64 } from '../utils/pdfFont'; import { robotoBase64, robotoBoldBase64 } from '../utils/pdfFont';
@@ -1573,7 +1574,7 @@ export const TourDetailPage = ({
if (!currentTour) return; if (!currentTour) return;
const socket = Capacitor.isNativePlatform() const socket = Capacitor.isNativePlatform()
? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn') ? io(BACKEND_URL)
: io(); // Kết nối qua Proxy của Vite (cùng origin) : io(); // Kết nối qua Proxy của Vite (cùng origin)
socket.on('connect', () => { socket.on('connect', () => {
+46
View File
@@ -0,0 +1,46 @@
/**
* Smart runtime backend URL resolver.
*
* Eliminates the need to manually update `.env` files when switching between:
* - Docker/Linux Production Web Server uses window.location.origin
* - Android APK (Capacitor WebView) forces absolute production domain
*
* Priority order:
* 1. Explicit VITE_BACKEND_URL env variable (if set at build time)
* 2. Android/Capacitor app detection https://yotrip.labz.io.vn
* 3. Production web server window.location.origin
*/
const PRODUCTION_DOMAIN = 'https://yotrip.labz.io.vn';
const resolveBackendEndpoint = (): string => {
// 1. Honour explicit build-time env override first
const envUrl = import.meta.env.VITE_BACKEND_URL as string | undefined;
if (envUrl) {
return envUrl;
}
const origin = window.location.origin;
const userAgent = navigator.userAgent.toLowerCase();
// 2. Detect Android APK / Capacitor WebView environment
// These shells run on local/file:// origins, not the production domain.
const isAndroidApp =
origin.startsWith('http://localhost') ||
origin.startsWith('capacitor://') ||
origin.startsWith('file://') ||
(userAgent.includes('android') && !origin.includes('yotrip.labz.io.vn'));
if (isAndroidApp) {
console.log('[Backend] 📱 Android/Capacitor environment detected — using production domain.');
return PRODUCTION_DOMAIN;
}
// 3. Web production server: the origin IS the backend (same Docker host)
return origin;
};
/** Resolved backend base URL for the current runtime environment. */
export const BACKEND_URL: string = resolveBackendEndpoint();
console.log(`[Backend] 🌐 Active endpoint: ${BACKEND_URL}`);
+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);
};
};
+54
View File
@@ -0,0 +1,54 @@
import { Geolocation } from '@capacitor/geolocation';
export interface DeviceLocation {
latitude: number;
longitude: number;
}
export async function getDeviceLocation(): Promise<DeviceLocation | null> {
try {
// Request permission at native level (essential for Android app packaging)
const permissionStatus = await Geolocation.requestPermissions();
if (permissionStatus.location === 'granted' || permissionStatus.coarseLocation === 'granted') {
const position = await Geolocation.getCurrentPosition({
enableHighAccuracy: true,
timeout: 5000
});
if (position && position.coords) {
console.log('[Geolocation] Acquired coordinates via Capacitor Geolocation:', position.coords.latitude, position.coords.longitude);
return {
latitude: position.coords.latitude,
longitude: position.coords.longitude
};
}
}
} catch (error) {
console.warn('[Geolocation] Capacitor plugin failed/unavailable, falling back to browser Geolocation:', error);
}
// Fallback to standard web browser Geolocation API
try {
const position = await new Promise<GeolocationPosition | null>((resolve) => {
if (!navigator.geolocation) {
resolve(null);
} else {
navigator.geolocation.getCurrentPosition(
(p) => resolve(p),
() => resolve(null),
{ timeout: 4000, enableHighAccuracy: true }
);
}
});
if (position && position.coords) {
console.log('[Geolocation] Acquired coordinates via Web Geolocation:', position.coords.latitude, position.coords.longitude);
return {
latitude: position.coords.latitude,
longitude: position.coords.longitude
};
}
} catch (e) {
console.error('[Geolocation] Browser Geolocation failed:', e);
}
return null;
}
@@ -0,0 +1,89 @@
import exifr from 'exifr';
interface AdvancedUploadPayload {
compressedBlob: Blob;
latitude: number | null;
longitude: number | null;
capturedAt: string | null; // ISO Timestamp or raw EXIF date string
}
export const processMobileImageUpload = (file: File): Promise<AdvancedUploadPayload> => {
return new Promise((resolve) => {
const reader = new FileReader();
let latitude: number | null = null;
let longitude: number | null = null;
let capturedAt: string | null = null;
// Read as ArrayBuffer to lock raw binary metadata blocks safely from being stripped
reader.readAsArrayBuffer(file);
reader.onload = async (event) => {
const buffer = event.target?.result as ArrayBuffer;
try {
const parsed = await exifr.parse(buffer);
if (parsed) {
if (typeof parsed.latitude === 'number' && typeof parsed.longitude === 'number') {
latitude = parsed.latitude;
longitude = parsed.longitude;
}
const dateObj = parsed.DateTimeOriginal || parsed.CreateDate || parsed.ModifyDate;
if (dateObj) {
capturedAt = dateObj instanceof Date ? dateObj.toISOString() : new Date(dateObj).toISOString();
}
}
console.log(`📊 Metadata Parsed (exifr) - Lat: ${latitude}, Lng: ${longitude}, Date: ${capturedAt}`);
} catch (exifError) {
console.error("Failed to parse EXIF via exifr from binary buffer stream:", exifError);
}
// 3. PROCEED TO RE-RENDER AND 2K PRE-COMPRESSION
const blobUrl = URL.createObjectURL(file);
const img = new Image();
img.src = blobUrl;
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
const MAX_EDGE = 2048; // Rigid 2K production specification limit
if (width > height) {
if (width > MAX_EDGE) {
height = Math.round((height * MAX_EDGE) / width);
width = MAX_EDGE;
}
} else {
if (height > MAX_EDGE) {
width = Math.round((width * MAX_EDGE) / height);
height = MAX_EDGE;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
URL.revokeObjectURL(blobUrl);
return resolve({ compressedBlob: file, latitude, longitude, capturedAt });
}
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((finalBlob) => {
URL.revokeObjectURL(blobUrl);
resolve({
compressedBlob: finalBlob || file,
latitude,
longitude,
capturedAt
});
}, 'image/jpeg', 0.85); // 85% JPEG compression tier
};
img.onerror = () => {
URL.revokeObjectURL(blobUrl);
resolve({ compressedBlob: file, latitude, longitude, capturedAt });
};
};
reader.onerror = () => {
resolve({ compressedBlob: file, latitude, longitude, capturedAt });
};
});
};
+92
View File
@@ -0,0 +1,92 @@
import exifr from 'exifr';
interface ProcessedImageResult {
file: File;
latitude: number | null;
longitude: number | null;
}
export const processAndResizeImage = async (file: File): Promise<ProcessedImageResult> => {
// 1. Read EXIF coordinates first from the original file using exifr via ArrayBuffer
let latitude: number | null = null;
let longitude: number | null = null;
try {
const buffer = await new Promise<ArrayBuffer | null>((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target?.result as ArrayBuffer);
reader.onerror = () => resolve(null);
reader.readAsArrayBuffer(file);
});
if (buffer) {
const gps = await exifr.gps(buffer);
if (gps && typeof gps.latitude === 'number' && typeof gps.longitude === 'number') {
latitude = gps.latitude;
longitude = gps.longitude;
console.log('[imageProcessor] Successfully parsed EXIF coordinates via exifr ArrayBuffer:', latitude, longitude);
}
}
} catch (e) {
console.error('[imageProcessor] Failed to read EXIF GPS using exifr ArrayBuffer:', e);
}
// 2. Perform resizing to maximum 2048px on its longest edge
return new Promise((resolve) => {
if (!file.type.startsWith('image/')) {
return resolve({ file, latitude, longitude });
}
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target?.result as string;
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
const MAX_SIZE = 2048;
if (width > MAX_SIZE || height > MAX_SIZE) {
if (width > height) {
height = Math.round((height * MAX_SIZE) / width);
width = MAX_SIZE;
} else {
width = Math.round((width * MAX_SIZE) / height);
height = MAX_SIZE;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
return resolve({ file, latitude, longitude });
}
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
if (blob) {
const newName = file.name.replace(/\.[^/.]+$/, "") + ".jpg";
const resizedFile = new File([blob], newName, {
type: 'image/jpeg',
lastModified: Date.now()
});
resolve({
file: resizedFile,
latitude,
longitude
});
} else {
resolve({ file, latitude, longitude });
}
}, 'image/jpeg', 0.88); // 88% quality sweet-spot
};
img.onerror = () => {
resolve({ file, latitude, longitude });
};
};
reader.onerror = () => {
resolve({ file, latitude, longitude });
};
});
};
+14 -12
View File
@@ -1,6 +1,7 @@
import { Capacitor } from '@capacitor/core'; import { Capacitor } from '@capacitor/core';
import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth'; import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth';
import { LocalNotifications } from '@capacitor/local-notifications'; import { LocalNotifications } from '@capacitor/local-notifications';
import { BACKEND_URL } from './backendEndpoint';
function rewriteUrls(obj: any, backendUrl: string): any { function rewriteUrls(obj: any, backendUrl: string): any {
if (obj === null || obj === undefined) return obj; if (obj === null || obj === undefined) return obj;
@@ -25,18 +26,19 @@ function rewriteUrls(obj: any, backendUrl: string): any {
return obj; return obj;
} }
// Initialize Google Login client to prevent NullPointerException crashes on Android
try {
GoogleAuth.initialize({
clientId: import.meta.env.VITE_GOOGLE_CLIENT_ID || '639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com',
scopes: ['profile', 'email'],
grantOfflineAccess: true,
});
console.log('[OAuth] GoogleAuth initialized successfully.');
} catch (e) {
console.error('[OAuth] Failed to initialize GoogleAuth client:', e);
}
if (Capacitor.isNativePlatform()) { if (Capacitor.isNativePlatform()) {
// Initialize native Google Login client to prevent NullPointerException crashes
try {
GoogleAuth.initialize({
clientId: import.meta.env.VITE_GOOGLE_CLIENT_ID || '639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com',
scopes: ['profile', 'email'],
grantOfflineAccess: true,
});
console.log('[Native OAuth] GoogleAuth initialized successfully.');
} catch (e) {
console.error('[Native OAuth] Failed to initialize GoogleAuth client:', e);
}
// Request native local notification permission on startup // Request native local notification permission on startup
try { try {
@@ -47,7 +49,7 @@ if (Capacitor.isNativePlatform()) {
console.error('[LocalNotifications] Failed to request permissions:', e); console.error('[LocalNotifications] Failed to request permissions:', e);
} }
const backendUrl = import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn'; const backendUrl = BACKEND_URL;
const originalFetch = window.fetch; const originalFetch = window.fetch;
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
+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);
});
};
+1
View File
@@ -208,6 +208,7 @@
"@capacitor/local-notifications": "^8.2.0", "@capacitor/local-notifications": "^8.2.0",
"@codetrix-studio/capacitor-google-auth": "^3.4.0-rc.4", "@codetrix-studio/capacitor-google-auth": "^3.4.0-rc.4",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"exifr": "^7.1.3",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8", "jspdf-autotable": "^5.0.8",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",