Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41c8e67229 | |||
| 9f60efeb6d | |||
| 0152e7014d | |||
| 964a72514f | |||
| 7f426c8e46 | |||
| 414fac3e72 | |||
| e5606e64e5 | |||
| 464a4019f1 | |||
| a1bf0d2c08 | |||
| 277f647e40 | |||
| f39750af72 | |||
| ff4dc9bb48 | |||
| 175668da35 | |||
| 7ab1342690 | |||
| 2f4b24c41c | |||
| 1417f40dde | |||
| eaf79eaf8f | |||
| 3fa80b69bf |
-107
@@ -1,107 +0,0 @@
|
||||
# To AI Agent: Fix Mobile Dropdown Layout Displacement and Eliminate `.filter is not a function` Runtime Crashes
|
||||
|
||||
## 1. Bug Analysis & Context
|
||||
We are resolving three critical layout and runtime bugs observed during Android Simulator testing (`image.png`):
|
||||
|
||||
1. **Dropdown Menu Displacement:** The menu drawer/dropdown container is losing its absolute coordinate anchor pool. Instead of floating gracefully on top of the map layer as a neat dropdown card, it wraps below the viewport grid, conflicting with the lower navigation action buttons ("Khám phá", "Chụp ảnh") and community photo streams.
|
||||
2. **`g.filter is not a function` (Friends List Crash):** Triggering the Friend List components crashes the UI thread into a White Screen of Death (WSoD).
|
||||
3. **`Error fetching connections: TypeError: ...filter is not a function` (LiveChat Crash):** Opening the Live Chat view displays the exact same array mutation failure.
|
||||
- *Root Cause for 2 & 3:* The asynchronous API network response payload or socket stream data returned from the server backend is **not a clean Array structure**. It is likely either wrapped inside an object wrapper (e.g., `{ success: true, friends: [] }`) or returns `null`/`undefined` due to network delays. Invoking `.filter()` directly on a non-array object instantly freezes the React rendering lifecycle.
|
||||
|
||||
---
|
||||
|
||||
## 2. Refactoring Strategy
|
||||
|
||||
### 2.1. Fix Dropdown Layout Context for Mobile/Android Viewports
|
||||
On mobile viewports, traditional hover/click absolute dropdown boxes overflow or clip out. We must force the dropdown menu container inside `MapProfileDropdown.tsx` to act as a structured **Sticky Floating Overlay** or a dedicated **Mobile Bottom-Sheet Box** with a precise `z-index`.
|
||||
|
||||
### 2.2. Implement Defensive Array Architecture (Fix Crashes 2 & 3)
|
||||
We must implement a protective array fallback guard across all data mapping blocks (`friends.filter`, `connections.filter`) using **`Array.isArray()`** validation, coupled with unified error boundaries.
|
||||
|
||||
---
|
||||
|
||||
## 3. Code Refactoring Blueprint
|
||||
|
||||
### Step 1: Overhaul Dropdown Styling for Mobile Boundaries (`MapProfileDropdown.tsx`)
|
||||
Ensure the menu card isolates itself perfectly above the underlying view map layout and handles sizing dimensions cleanly:
|
||||
|
||||
```jsx
|
||||
{/* ✅ RESPONSIVE FIX: Mobile-optimized absolute floating panel structure */}
|
||||
<div
|
||||
className="absolute right-0 top-14 w-[280px] bg-slate-900/95 backdrop-blur-md border border-slate-800 rounded-2xl p-2 shadow-2xl flex flex-col space-y-0.5 text-xs text-slate-200 z-[999999]"
|
||||
style={{
|
||||
/* Tight mobile screen safety rails layout positioning guards */
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.5)'
|
||||
}}
|
||||
>
|
||||
{/* Menu list rows (Tạo tour, Hành trình, Thư viện ảnh...) populate cleanly here */}
|
||||
<button className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800/80 rounded-xl text-left">...</button>
|
||||
</div>
|
||||
|
||||
### Step 2: Fix Friend List Array Mutation Crash (FriendsManagerModal.tsx)
|
||||
Locate where the backend data hook is consumed. Inject an explicit defensive array verification check:
|
||||
|
||||
// ❌ OLD CRASH-PRONE CODE:
|
||||
// const activeFriends = data.filter(f => f.status === 'active');
|
||||
|
||||
// ✅ NEW DEFENSIVE RAY WRAPPER:
|
||||
const [friendsList, setFriendsList] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/friends/connections')
|
||||
.then((res) => {
|
||||
// Deconstruct and verify input type carefully before committing to state
|
||||
const rawPayload = res.data;
|
||||
|
||||
if (rawPayload && Array.isArray(rawPayload)) {
|
||||
setFriendsList(rawPayload);
|
||||
} else if (rawPayload && Array.isArray(rawPayload.data)) {
|
||||
setFriendsList(rawPayload.data); // Fallback unpacker matching nested API responses
|
||||
} else {
|
||||
console.error("⚠️ Expected array structure but received:", rawPayload);
|
||||
setFriendsList([]); // Secure fallback to empty array initialization to protect .filter loops
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to compile connections list stream safely:", err);
|
||||
setFriendsList([]); // Fallback safety initialization
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Secure conditional processing guard rail
|
||||
const activeFriends = Array.isArray(friendsList)
|
||||
? friendsList.filter((f: any) => f && f.status === 'active')
|
||||
: [];
|
||||
|
||||
### Step 3: Fix Live Chat Connections Filtering Failure (LiveChatModal.tsx)
|
||||
Locate the data fetcher inside your real-time socket listener or chat state parser module and re-engineer it with identical fallback shields:
|
||||
|
||||
const fetchUserConnections = async () => {
|
||||
try {
|
||||
const response = await api.get('/chat/connections');
|
||||
const resultData = response.data;
|
||||
|
||||
// Direct defensive validation shield check
|
||||
const verifiedConnectionsArray = Array.isArray(resultData)
|
||||
? resultData
|
||||
: (resultData && Array.isArray(resultData.connections) ? resultData.connections : []);
|
||||
|
||||
/* ✅ CRITICAL FIX: Running .filter on verified guaranteed array structure only */
|
||||
const onlineConnections = verifiedConnectionsArray.filter((conn: any) => conn && conn.isOnline === true);
|
||||
|
||||
setConnections(verifiedConnectionsArray);
|
||||
|
||||
} catch (error) {
|
||||
console.error("[LiveChatModal] Error fetching connections cleanly:", error);
|
||||
setConnections([]); // Force secure array context initialization on intercepting crash exceptions
|
||||
}
|
||||
};
|
||||
|
||||
## 4. Verification & Quality Acceptance Criteria for AI Agent
|
||||
[ ] Dropdown Layout Verification: Open the avatar menu on the Android Simulator. The profile dropdown modal panel must lock floating positions right below the top header profile cluster, without displacing the community images ribbon or bleeding into the screen bottom.
|
||||
|
||||
[ ] WSoD Prevention Validation: Simulate an empty or broken server endpoint response (500 or blank text). Confirm that the component prints the error safely to the console tracker log while the screen continues to render placeholder empty-states beautifully without turning into a white void.
|
||||
|
||||
[ ] Array Guard Robustness: Run a global textual scan over modified view sheets. Confirm all custom list operations on fetched database schemas verify array validity using Array.isArray() wrappers prior to calling processing filters.
|
||||
@@ -1,69 +0,0 @@
|
||||
# To AI Agent: Implement Smart Runtime Environment Detection for Backend URL (Eliminate Dynamic .env Conflicts)
|
||||
|
||||
## 1. Context & Objective
|
||||
We want to modify the frontend backend-URL resolution engine **exactly once** so that pulling code to the Linux Debian Server (Docker build) or a Windows PC (Android build) requires zero manual `.env` updates.
|
||||
|
||||
- **The Strategy:** Instead of hardcoding `VITE_BACKEND_URL` in a static `.env` file, we will write a smart runtime detector inside the code.
|
||||
- **The Logic:**
|
||||
- If the app runs on a standard web browser (Docker Server), `window.location.origin` automatically resolves to `https://yotrip.labz.io.vn`.
|
||||
- If the app runs inside an Android APK wrapper, the origin defaults to `http://localhost`, `capacitor://`, or `file://`. The code will detect this and automatically inject the absolute server production URL.
|
||||
|
||||
---
|
||||
|
||||
## 2. Refactoring Blueprint
|
||||
|
||||
### Step 1: Overhaul `socketService.ts` (or your central API configuration file)
|
||||
Replace the current environment variable lookup with this smart dynamic runtime resolver:
|
||||
|
||||
```typescript
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
|
||||
const resolveBackendEndpoint = (): string => {
|
||||
// 1. Keep a fallback for explicit env overrides if deliberately set
|
||||
if (import.meta.env.VITE_BACKEND_URL) {
|
||||
return import.meta.env.VITE_BACKEND_URL;
|
||||
}
|
||||
|
||||
const origin = window.location.origin;
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
|
||||
/* 2. DETECT ANDROID APK WRAPPER ENVIRONMENT
|
||||
Android webviews/hybrid shells run on custom local hosts or file protocols
|
||||
*/
|
||||
const isAndroidApp =
|
||||
origin.includes('localhost') ||
|
||||
origin.includes('capacitor://') ||
|
||||
origin.startsWith('file://') ||
|
||||
(userAgent.includes('android') && !origin.includes('yotrip.labz.io.vn'));
|
||||
|
||||
if (isAndroidApp) {
|
||||
console.log("📱 Android App environment detected. Forcing absolute production domain mapping.");
|
||||
return '[https://yotrip.labz.io.vn](https://yotrip.labz.io.vn)';
|
||||
}
|
||||
|
||||
// 3. Default fallback for Production Web (Docker server automatically yields its own domain)
|
||||
return origin;
|
||||
};
|
||||
|
||||
export const SOCKET_URL = resolveBackendEndpoint();
|
||||
console.log(`🌐 Active Network Base Endpoint: ${SOCKET_URL}`);
|
||||
|
||||
### Step 2: Sync API Axios Client Configuration (api.ts / axiosClient.ts)
|
||||
Ensure your API client hooks directly into the newly created SOCKET_URL variable to keep them unified:
|
||||
|
||||
import axios from 'axios';
|
||||
import { SOCKET_URL } from './socketService';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: SOCKET_URL, // Dynamically synchronizes across both Docker and Android build pipelines
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
## 3. Verification Checklist for AI Agent
|
||||
[ ] Eradicate .env Dependency: Ensure that clearing out VITE_BACKEND_URL from local .env files does not cause code compilation failures.
|
||||
|
||||
[ ] Web Browser Verification: When deployed via Docker on the server, verify the network tab calls requests relatively to the running host origin.
|
||||
|
||||
[ ] Android Simulation Success: When compiled into an APK on Windows, confirm all API/Socket requests direct straight to https://yotrip.labz.io.vn, avoiding any localhost 404 blockages.
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
# To AI Agent: Restore Smart Android App Download Banner Below Top-Bar Header
|
||||
|
||||
## 1. Context & Feature Objective
|
||||
Previously, the codebase had a promotional banner encouraging mobile users to download the native Android `.apk` file. However, during the recent overhaul of the Top-Bar header and Member Dropdown Menu configurations, this banner component was accidentally unmounted or hidden.
|
||||
|
||||
**Objective:** Restore and refactor this promotional frame (`AppDownloadBanner.tsx`). It must **ONLY** appear when a user accesses the web application via a **Mobile Android Browser** (Chrome, Samsung Internet, Opera Mobile, etc.). It must be positioned dynamically as a small, clean horizontal frame pinned directly underneath the main Top-Bar header layout, without conflicting with the new absolute Profile Dropdown Menu.
|
||||
|
||||
---
|
||||
|
||||
## 2. Visual & Behavioral Layout Specifications
|
||||
- **Placement Context:** Directly underneath the Top-Bar layer, shifting the core page content (Map Canvas or Landing Hero) down proportionally (`relative` or `sticky` stack). It must not overlay or block map navigation tools.
|
||||
- **Conditional Trigger Logic:** The banner must evaluate `navigator.userAgent`. If the user agent includes `'android'` **AND** the user is running inside a standard web browser (not inside the compiled wrapper app itself), display the banner.
|
||||
- **Dismissible Interaction:** Include a small close button (`X`). Clicking it should temporarily store a flag in `sessionStorage` or `localStorage` to prevent the banner from bugging the user repeatedly during their session.
|
||||
|
||||
---
|
||||
|
||||
## 3. Technical Implementation Blueprint
|
||||
|
||||
### Step 1: Create the Responsive Banner Component (`AppDownloadBanner.tsx`)
|
||||
Create or re-engineer the banner layout within `frontend/src/components/layout/AppDownloadBanner.tsx`:
|
||||
|
||||
```typescript
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Download } from 'lucide-react';
|
||||
|
||||
export const AppDownloadBanner: React.FC = () => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const APK_DOWNLOAD_URL = `${import.meta.env.VITE_BACKEND_URL || window.location.origin}/downloads/yotrip-latest.apk`;
|
||||
|
||||
useEffect(() => {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
const isAndroidBrowser = userAgent.includes('android') && !window.location.origin.includes('capacitor://') && !window.location.origin.includes('localhost:80');
|
||||
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 flex items-center justify-between text-white text-[11px] font-medium z-40 relative animate-fade-in 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 mà 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">
|
||||
{/* Direct Download Trigger Target Link */}
|
||||
<a
|
||||
href={APK_DOWNLOAD_URL}
|
||||
download="yotrip.apk"
|
||||
className="bg-blue-600 hover:bg-blue-500 px-2.5 py-1 rounded font-bold text-white transition-colors shadow-sm active:scale-95"
|
||||
>
|
||||
Tải APK
|
||||
</a>
|
||||
|
||||
{/* Close Button Trigger */}
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
### Step 2: Integrate into Layout Hierarchies (LandingPage.tsx & ExplorerMap.tsx)
|
||||
Mount the verified download banner right below your primary header or top-bar row item array context:
|
||||
|
||||
{/* ❌ BEFORE STRUCTURAL INTEGRATION */}
|
||||
<div className="w-screen h-screen flex flex-col">
|
||||
<TopBarHeader />
|
||||
<MapCanvasWorkspace />
|
||||
</div>
|
||||
|
||||
{/* ✅ AFTER STRUCTURAL INTEGRATION: Stacked relative context */}
|
||||
<div className="w-screen h-screen flex flex-col overflow-hidden">
|
||||
{/* 1. Main Application Header */}
|
||||
<TopBarHeader />
|
||||
|
||||
{/* 2. THE RESTORED ANDROID APP PROMOTIONAL BANNER */}
|
||||
<AppDownloadBanner />
|
||||
|
||||
{/* 3. Primary Workspace Area - Shifts down cleanly when banner initializes */}
|
||||
<div className="flex-1 relative min-h-0 w-full">
|
||||
<MapCanvasWorkspace />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## 4. Verification & Quality Acceptance Criteria for AI Agent
|
||||
[ ] Targeted UserAgent Isolation: Emulate a desktop display width (iPhone or Desktop layouts). Verify the banner remains completely hidden. Toggle the network responsive preview device model to Android (e.g., Pixel 7) and refresh. Confirm the download bar pops up seamlessly.
|
||||
|
||||
[ ] Absolute Dropdown Overlay Preservation: Expand the Member Avatar menu row dropdown list while the banner is visible. Confirm that the dropdown box displays layered ON TOP of the banner context, without layout shifting or text clipping.
|
||||
|
||||
[ ] State Dismissal Memory: Click the X button on the banner, then refresh the browser session. Confirm the banner remains hidden and respects the localStorage condition toggle.
|
||||
+181
@@ -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.
|
||||
@@ -33,6 +33,7 @@ COPY package*.json ./
|
||||
COPY --from=build /usr/src/app/node_modules ./node_modules
|
||||
COPY --from=build /usr/src/app/dist ./dist
|
||||
COPY --from=build /usr/src/app/prisma ./prisma
|
||||
COPY --from=build /usr/src/app/public ./public
|
||||
|
||||
EXPOSE 3001
|
||||
CMD ["node", "dist/src/main.js"]
|
||||
|
||||
Binary file not shown.
@@ -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();
|
||||
@@ -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();
|
||||
@@ -32,6 +32,7 @@ services:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- /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:
|
||||
DATABASE_URL: "postgresql://postgres:password@postgres:5432/travel_db?schema=public"
|
||||
REDIS_URL: "redis://redis:6379"
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
<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" />
|
||||
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<queries>
|
||||
|
||||
@@ -11,6 +11,7 @@ const config: CapacitorConfig = {
|
||||
GoogleAuth: {
|
||||
scopes: ['profile', 'email'],
|
||||
serverClientId: '639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com',
|
||||
androidClientId: '639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com',
|
||||
forceCodeForRefreshToken: true
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+34
-80
@@ -1,84 +1,38 @@
|
||||
<<<<<<< HEAD
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||
<title>Travel Planner</title>
|
||||
|
||||
<!-- Open Graph Meta Tags for Social Media Sharing -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://yotrip.labz.io.vn" />
|
||||
<meta property="og:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
|
||||
<meta property="og: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 property="og:image" content="https://yotrip.labz.io.vn/background.avif" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
|
||||
<meta name="twitter:description" content="Khám phá những hành trình tuyệt vời và chia sẻ khoảnh khắc đẹp với cộng đồng du lịch." />
|
||||
<meta name="twitter:image" content="https://yotrip.labz.io.vn/background.avif" />
|
||||
<script type="module" crossorigin src="/assets/index-eQcX5WJf.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-Cyuzqnbw.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-others-VIU5qAPG.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-editor-DOXkNaRS.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-maps-DY-S_hoR.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-react-BqXL8Z-i.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-pdf-IjT8x9OX.js">
|
||||
<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/index-BsXBn1-b.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||
<title>Travel Planner</title>
|
||||
|
||||
<!-- Open Graph Meta Tags for Social Media Sharing -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://yotrip.labz.io.vn" />
|
||||
<meta property="og:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
|
||||
<meta property="og: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 property="og:image" content="https://yotrip.labz.io.vn/background.avif" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
|
||||
<meta name="twitter:description"
|
||||
content="Khám phá những hành trình tuyệt vời và chia sẻ khoảnh khắc đẹp với cộng đồng du lịch." />
|
||||
<meta name="twitter:image" content="https://yotrip.labz.io.vn/background.avif" />
|
||||
<script type="module" crossorigin src="/assets/index-BximWk33.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-Cyuzqnbw.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-others-VIU5qAPG.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-editor-DOXkNaRS.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-maps-DY-S_hoR.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-react-BqXL8Z-i.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-pdf-IjT8x9OX.js">
|
||||
<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/index-BsXBn1-b.css">
|
||||
</head>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||
<title>Travel Planner</title>
|
||||
|
||||
<!-- Open Graph Meta Tags for Social Media Sharing -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://yotrip.labz.io.vn" />
|
||||
<meta property="og:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
|
||||
<meta property="og: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 property="og:image" content="https://yotrip.labz.io.vn/background.avif" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
|
||||
<meta name="twitter:description" content="Khám phá những hành trình tuyệt vời và chia sẻ khoảnh khắc đẹp với cộng đồng du lịch." />
|
||||
<meta name="twitter:image" content="https://yotrip.labz.io.vn/background.avif" />
|
||||
<script type="module" crossorigin src="/assets/index-CSiax1SI.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CMxvf4Kt.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-others-CNhtyHGs.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-editor-BDwQQzB8.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-maps-1-B38H26.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-react-BF5_05kG.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-react-CuSj0z1j.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CIipVipb.css">
|
||||
</head>
|
||||
<body>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -18,6 +18,7 @@
|
||||
"@capacitor/local-notifications": "^8.2.0",
|
||||
"@codetrix-studio/capacitor-google-auth": "^3.4.0-rc.4",
|
||||
"date-fns": "^4.4.0",
|
||||
"exifr": "^7.1.3",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.8",
|
||||
"leaflet": "^1.9.4",
|
||||
|
||||
@@ -3,7 +3,8 @@ import { X, Upload, Image as ImageIcon, Loader2 } from 'lucide-react';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
import { processImageModeration } from '@/hooks/useImageModeration';
|
||||
import { compressImage } from '../utils/image';
|
||||
import { processAndResizeImage } from '../utils/imageProcessor';
|
||||
import { getDeviceLocation } from '../utils/geolocation';
|
||||
|
||||
interface AddPhotoModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -13,8 +14,14 @@ interface AddPhotoModalProps {
|
||||
isPublicView?: boolean;
|
||||
}
|
||||
|
||||
interface PendingPhoto {
|
||||
file: File;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
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 [isUploading, setIsUploading] = 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>) => {
|
||||
if (e.target.files) {
|
||||
const files = Array.from(e.target.files);
|
||||
const newValidFiles: File[] = [];
|
||||
const newValidPreviews: string[] = [];
|
||||
|
||||
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' });
|
||||
|
||||
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) {
|
||||
// 1. Kiểm tra cơ bản: Tệp có dung lượng hay không
|
||||
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' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Nén ảnh trước
|
||||
const compressedFile = await compressImage(file);
|
||||
// Process image: Reads EXIF data and forces 2K resizing on device memory
|
||||
const { file: processedFile, latitude: exifLat, longitude: exifLng } = await processAndResizeImage(file);
|
||||
|
||||
// 2. Chạy kiểm duyệt hình ảnh
|
||||
const moderationResult = await processImageModeration(compressedFile);
|
||||
const moderationResult = await processImageModeration(processedFile);
|
||||
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' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const processedFile = moderationResult.file;
|
||||
const previewUrl = URL.createObjectURL(processedFile);
|
||||
const finalProcessedFile = moderationResult.file;
|
||||
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 img = new Image();
|
||||
img.onload = () => resolve(true);
|
||||
@@ -63,15 +72,51 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
|
||||
});
|
||||
|
||||
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);
|
||||
} 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' });
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedFiles(prev => [...prev, ...newValidFiles]);
|
||||
setSelectedFiles(prev => [...prev, ...newValidPhotos]);
|
||||
setPreviews(prev => [...prev, ...newValidPreviews]);
|
||||
} catch (err) {
|
||||
console.error('File checking error:', err);
|
||||
@@ -95,47 +140,24 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
|
||||
|
||||
setIsUploading(true);
|
||||
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
|
||||
const location = await Promise.race([
|
||||
new Promise<GeolocationPosition | null>((resolve) => {
|
||||
if (!navigator.geolocation) {
|
||||
resolve(null);
|
||||
} else {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => resolve(pos),
|
||||
() => resolve(null),
|
||||
{ timeout: 4000, enableHighAccuracy: true }
|
||||
);
|
||||
}
|
||||
}),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 4500))
|
||||
]);
|
||||
// Upload each photo sequentially so we can attach its specific coordinates
|
||||
for (const item of selectedFiles) {
|
||||
const formData = new FormData();
|
||||
formData.append('latitude', item.latitude.toString());
|
||||
formData.append('longitude', item.longitude.toString());
|
||||
formData.append('images', item.file);
|
||||
|
||||
const formData = new FormData();
|
||||
selectedFiles.forEach(file => {
|
||||
formData.append('images', file);
|
||||
});
|
||||
const response = await fetch(`/api/v1/tours/${tourId}/photos`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (location) {
|
||||
formData.append('latitude', location.coords.latitude.toString());
|
||||
formData.append('longitude', location.coords.longitude.toString());
|
||||
if (!response.ok) throw new Error('Tải lên ảnh thất bại');
|
||||
}
|
||||
|
||||
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
|
||||
previews.forEach(url => URL.revokeObjectURL(url));
|
||||
|
||||
@@ -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,14 +81,13 @@ export const MapProfileDropdown: React.FC<MapProfileDropdownProps> = ({
|
||||
/>
|
||||
|
||||
<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: '80vh',
|
||||
maxHeight: '75vh',
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.4)'
|
||||
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 ? (
|
||||
<div className="flex flex-col space-y-1">
|
||||
|
||||
@@ -823,24 +823,34 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
|
||||
{isFullscreen && (
|
||||
<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)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
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]"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
<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' : ''
|
||||
}`}
|
||||
/>
|
||||
{/* Close Button Top Tracker Bar Container */}
|
||||
<div className="absolute top-6 right-6 z-50">
|
||||
<button
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
className="p-3 bg-black/50 hover:bg-black/75 border border-white/10 rounded-full text-white transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</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={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>
|
||||
|
||||
@@ -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">Mô 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">Vĩ độ (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 mà 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() });
|
||||
if (res.ok) {
|
||||
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) {
|
||||
console.error('[FriendsManagerModal] Error fetching connections:', e);
|
||||
setConnections([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -170,15 +176,19 @@ export const FriendsManagerModal: React.FC<FriendsManagerModalProps> = ({
|
||||
if (!isOpen || !user) return null;
|
||||
|
||||
// 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
|
||||
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 existing = connections.find(
|
||||
c => c.targetUser?.id === targetUserId || c.requester?.id === targetUserId
|
||||
);
|
||||
const existing = Array.isArray(connections)
|
||||
? connections.find(c => c && (c.targetUser?.id === targetUserId || c.requester?.id === targetUserId))
|
||||
: null;
|
||||
if (!existing) return null;
|
||||
if (existing.status === 'ACCEPTED') return 'FRIEND';
|
||||
if (existing.status === 'PENDING') {
|
||||
|
||||
@@ -46,11 +46,17 @@ export const LiveChatModal: React.FC<LiveChatModalProps> = ({
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// 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);
|
||||
} else {
|
||||
setConnections([]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[LiveChatModal] Error fetching connections:', e);
|
||||
setConnections([]);
|
||||
} finally {
|
||||
setIsLoadingContacts(false);
|
||||
}
|
||||
@@ -236,10 +242,12 @@ export const LiveChatModal: React.FC<LiveChatModalProps> = ({
|
||||
if (!isOpen || !user) return null;
|
||||
|
||||
// Filter connections by search query
|
||||
const filteredConnections = connections.filter((conn: any) => {
|
||||
const friend = conn.targetUser?.id === user?.id ? conn.requester : conn.targetUser;
|
||||
return friend?.name?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
});
|
||||
const filteredConnections = Array.isArray(connections)
|
||||
? connections.filter((conn: any) => {
|
||||
const friend = conn.targetUser?.id === user?.id ? conn.requester : conn.targetUser;
|
||||
return friend?.name?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
})
|
||||
: [];
|
||||
|
||||
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">
|
||||
|
||||
@@ -20,6 +20,7 @@ import { MyToursModal } from '../components/modals/MyToursModal';
|
||||
import { PhotoGalleryModal as MyPhotosModal } from '../components/modals/PhotoGalleryModal';
|
||||
import { LiveChatModal } from '../components/modals/LiveChatModal';
|
||||
import { FriendsManagerModal } from '../components/modals/FriendsManagerModal';
|
||||
import { AppDownloadBanner } from '../components/layout/AppDownloadBanner';
|
||||
|
||||
// Fix lỗi icon mặc định của Leaflet
|
||||
const DefaultIcon = L.icon({
|
||||
@@ -194,9 +195,9 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onLoginSuccess,
|
||||
|
||||
const groups: { [key: string]: any[] } = {};
|
||||
filteredPhotos.forEach((photo) => {
|
||||
const lat = photo.metadata?.lat;
|
||||
const lng = photo.metadata?.lng;
|
||||
if (typeof lat === 'number' && typeof lng === 'number') {
|
||||
const lat = typeof photo.metadata?.lat === 'number' ? photo.metadata.lat : parseFloat(photo.metadata?.lat);
|
||||
const lng = typeof photo.metadata?.lng === 'number' ? photo.metadata.lng : parseFloat(photo.metadata?.lng);
|
||||
if (typeof lat === 'number' && !isNaN(lat) && typeof lng === 'number' && !isNaN(lng)) {
|
||||
const key = `${lat.toFixed(5)},${lng.toFixed(5)}`;
|
||||
if (!groups[key]) {
|
||||
groups[key] = [];
|
||||
@@ -628,13 +629,13 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onLoginSuccess,
|
||||
if (photoId && publicPhotos.length > 0) {
|
||||
const foundPhoto = publicPhotos.find((p) => p.id === photoId);
|
||||
if (foundPhoto) {
|
||||
const lat = foundPhoto.metadata?.lat;
|
||||
const lng = foundPhoto.metadata?.lng;
|
||||
if (typeof lat === 'number' && typeof lng === 'number') {
|
||||
const lat = typeof foundPhoto.metadata?.lat === 'number' ? foundPhoto.metadata.lat : parseFloat(foundPhoto.metadata?.lat);
|
||||
const lng = typeof foundPhoto.metadata?.lng === 'number' ? foundPhoto.metadata.lng : parseFloat(foundPhoto.metadata?.lng);
|
||||
if (typeof lat === 'number' && !isNaN(lat) && typeof lng === 'number' && !isNaN(lng)) {
|
||||
const group = publicPhotos.filter((p) => {
|
||||
const pLat = p.metadata?.lat;
|
||||
const pLng = p.metadata?.lng;
|
||||
return typeof pLat === 'number' && typeof pLng === 'number' &&
|
||||
const pLat = typeof p.metadata?.lat === 'number' ? p.metadata.lat : parseFloat(p.metadata?.lat);
|
||||
const pLng = typeof p.metadata?.lng === 'number' ? p.metadata.lng : parseFloat(p.metadata?.lng);
|
||||
return typeof pLat === 'number' && !isNaN(pLat) && typeof pLng === 'number' && !isNaN(pLng) &&
|
||||
Math.abs(pLat - lat) < 0.00001 &&
|
||||
Math.abs(pLng - lng) < 0.00001;
|
||||
});
|
||||
@@ -654,8 +655,11 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onLoginSuccess,
|
||||
}, [publicPhotos]);
|
||||
|
||||
return (
|
||||
<div className="h-dvh w-full relative overflow-hidden">
|
||||
{/* Top Bar Container - Chứa tất cả các nút điều hướng phía trên */}
|
||||
<div className="h-dvh w-full flex flex-col overflow-hidden bg-slate-950">
|
||||
<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">
|
||||
{/* 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">
|
||||
@@ -965,9 +969,9 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onLoginSuccess,
|
||||
|
||||
{groupedPhotos.map((photoGroup) => {
|
||||
const latestPhoto = photoGroup[0];
|
||||
const lat = latestPhoto.metadata?.lat;
|
||||
const lng = latestPhoto.metadata?.lng;
|
||||
if (typeof lat !== 'number' || typeof lng !== 'number') return null;
|
||||
const lat = typeof latestPhoto.metadata?.lat === 'number' ? latestPhoto.metadata.lat : parseFloat(latestPhoto.metadata?.lat);
|
||||
const lng = typeof latestPhoto.metadata?.lng === 'number' ? latestPhoto.metadata.lng : parseFloat(latestPhoto.metadata?.lng);
|
||||
if (typeof lat !== 'number' || isNaN(lat) || typeof lng !== 'number' || isNaN(lng)) return null;
|
||||
|
||||
return (
|
||||
<Marker
|
||||
@@ -1125,6 +1129,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onLoginSuccess,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Admin Modal */}
|
||||
<UserManagementModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { ReportBusinessModal } from '../components/ReportBusinessModal';
|
||||
import { TagSelectModal } from '../components/TagSelectModal';
|
||||
@@ -9,10 +9,14 @@ import { MyToursModal } from '../components/modals/MyToursModal';
|
||||
import { PhotoGalleryModal as MyPhotosModal } from '../components/modals/PhotoGalleryModal';
|
||||
import { LiveChatModal } from '../components/modals/LiveChatModal';
|
||||
import { FriendsManagerModal } from '../components/modals/FriendsManagerModal';
|
||||
import { AppDownloadBanner } from '../components/layout/AppDownloadBanner';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { processImageModeration } from '../hooks/useImageModeration';
|
||||
import { useTranslation } from '../hooks/useTranslation';
|
||||
import { compressImage } from '../utils/image';
|
||||
import { processAndResizeImage } from '../utils/imageProcessor';
|
||||
import { getDeviceLocation } from '../utils/geolocation';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
|
||||
|
||||
interface LandingPageProps {
|
||||
onContinue?: () => void;
|
||||
@@ -48,7 +52,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
const galleryInputRef = useRef<HTMLInputElement>(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 notify = useNotification();
|
||||
const { t } = useTranslation();
|
||||
@@ -186,56 +190,119 @@ export const LandingPage: React.FC<LandingPageProps> = ({
|
||||
return () => clearInterval(interval);
|
||||
}, [publicPhotos]);
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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' });
|
||||
|
||||
const processAndUploadFile = async (file: File) => {
|
||||
try {
|
||||
// Nén ảnh trước
|
||||
const compressedFile = await compressImage(file);
|
||||
// Process image: Reads EXIF data and forces 2K resizing on device memory
|
||||
const { file: processedFile, latitude: exifLat, longitude: exifLng } = await processAndResizeImage(file);
|
||||
// 0. Kiểm duyệt ảnh
|
||||
const moderationResult = await processImageModeration(compressedFile);
|
||||
const moderationResult = await processImageModeration(processedFile);
|
||||
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' });
|
||||
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)
|
||||
const location = await Promise.race([
|
||||
new Promise<GeolocationPosition | null>((resolve) => {
|
||||
if (!navigator.geolocation) {
|
||||
resolve(null);
|
||||
} else {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => resolve(pos),
|
||||
() => resolve(null),
|
||||
{ timeout: 4000, enableHighAccuracy: true }
|
||||
);
|
||||
// Determine image upload coordinates based on priority checklist:
|
||||
let finalLat: number | null = exifLat;
|
||||
let finalLng: number | null = exifLng;
|
||||
|
||||
if (finalLat !== null && finalLng !== null) {
|
||||
console.log('[Upload Location] Priority 1: EXIF data coordinates found:', finalLat, finalLng);
|
||||
}
|
||||
|
||||
// 2. Get current mobile/device GPS position of the user
|
||||
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);
|
||||
}
|
||||
}),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 4500))
|
||||
]);
|
||||
} catch (e) {
|
||||
console.error('[Upload Location] Error acquiring current GPS:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Lưu file và location vào state pending, hiển thị modal tags
|
||||
setPendingPhotoFile(processedFile);
|
||||
setPendingPhotoLocation(location);
|
||||
// 3. Get last viewed map viewport center coordinates
|
||||
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]);
|
||||
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
|
||||
const previewUrl = URL.createObjectURL(processedFile);
|
||||
const previewUrl = URL.createObjectURL(finalProcessedFile);
|
||||
setPhotoPreviewUrl(previewUrl);
|
||||
|
||||
setIsTagsModalOpen(true);
|
||||
} catch (error: any) {
|
||||
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[]) => {
|
||||
if (!pendingPhotoFile) return;
|
||||
|
||||
@@ -269,15 +336,15 @@ export const LandingPage: React.FC<LandingPageProps> = ({
|
||||
|
||||
// 3. Tải ảnh lên
|
||||
const formData = new FormData();
|
||||
formData.append('images', pendingPhotoFile);
|
||||
if (pendingPhotoLocation) {
|
||||
formData.append('latitude', pendingPhotoLocation.coords.latitude.toString());
|
||||
formData.append('longitude', pendingPhotoLocation.coords.longitude.toString());
|
||||
formData.append('latitude', pendingPhotoLocation.latitude.toString());
|
||||
formData.append('longitude', pendingPhotoLocation.longitude.toString());
|
||||
}
|
||||
// Thêm tags vào formData
|
||||
if (selectedTags.length > 0) {
|
||||
formData.append('tags', JSON.stringify(selectedTags));
|
||||
}
|
||||
formData.append('images', pendingPhotoFile);
|
||||
|
||||
let uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
|
||||
method: 'POST',
|
||||
@@ -310,8 +377,6 @@ export const LandingPage: React.FC<LandingPageProps> = ({
|
||||
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
|
||||
localStorage.removeItem('pendingInviteToken');
|
||||
const url = new URL(window.location.href);
|
||||
@@ -343,7 +408,10 @@ export const LandingPage: React.FC<LandingPageProps> = ({
|
||||
|
||||
|
||||
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 */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
{/* Active image for panning */}
|
||||
@@ -640,7 +708,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({
|
||||
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"
|
||||
>
|
||||
<Camera className="w-4.5 h-4.5" />
|
||||
<CameraIcon className="w-4.5 h-4.5" />
|
||||
<span>{t('shortCamera') || 'Chụp ảnh'}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -655,13 +723,19 @@ export const LandingPage: React.FC<LandingPageProps> = ({
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login Modal Component */}
|
||||
<LoginModal
|
||||
isOpen={isLoginModalOpen}
|
||||
onClose={() => setIsLoginModalOpen(false)}
|
||||
onSwitchToSignup={onGoToSignup}
|
||||
onLoginSuccess={onLoginSuccess}
|
||||
onLoginSuccess={(loggedInUser) => {
|
||||
setIsLoginModalOpen(false);
|
||||
if (onLoginSuccess) {
|
||||
onLoginSuccess(loggedInUser);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Report Business Modal */}
|
||||
@@ -707,12 +781,16 @@ export const LandingPage: React.FC<LandingPageProps> = ({
|
||||
<button
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
<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 className="flex-1 text-left">
|
||||
<div className="font-bold text-[var(--text-primary)]">Chụp ảnh bằng camera</div>
|
||||
@@ -724,7 +802,11 @@ export const LandingPage: React.FC<LandingPageProps> = ({
|
||||
<button
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -26,18 +26,19 @@ function rewriteUrls(obj: any, backendUrl: string): any {
|
||||
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()) {
|
||||
// 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
|
||||
try {
|
||||
|
||||
Generated
+1
@@ -208,6 +208,7 @@
|
||||
"@capacitor/local-notifications": "^8.2.0",
|
||||
"@codetrix-studio/capacitor-google-auth": "^3.4.0-rc.4",
|
||||
"date-fns": "^4.4.0",
|
||||
"exifr": "^7.1.3",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.8",
|
||||
"leaflet": "^1.9.4",
|
||||
|
||||
Reference in New Issue
Block a user