34 Commits

Author SHA1 Message Date
3dtours f91fc7c5ac feat: tính năng offline cho upload ảnh và tải bản đồ 2026-06-28 15:18:13 +07:00
3dtours 2f5ef424c8 fix: sửa UI photoview hiển thị ở public 2026-06-28 14:55:25 +07:00
3dtours 41c8e67229 fix: photo edit by admin 2026-06-28 11:00:28 +07:00
3dtours 9f60efeb6d fix: remove success notifications and parse map coordinates safely 2026-06-27 22:28:13 +07:00
3dtours 0152e7014d feat: use Capacitor Camera API to select/capture photos to preserve EXIF location on Android app 2026-06-27 22:21:28 +07:00
3dtours 964a72514f feat: use Capacitor Geolocation to prompt and get native location on Android 2026-06-27 22:15:18 +07:00
3dtours 7f426c8e46 feat: replace manual binary EXIF parser with robust exifr library on frontend 2026-06-27 22:04:21 +07:00
3dtours 414fac3e72 feat: add ACCESS_MEDIA_LOCATION permission to AndroidManifest.xml to enable EXIF GPS reading on Android 10+ 2026-06-27 21:59:08 +07:00
3dtours e5606e64e5 feat: enforce 4-level coordinates resolution priority for tour photo uploads in AddPhotoModal.tsx 2026-06-27 21:53:52 +07:00
3dtours 464a4019f1 chore: mount Android app-debug.apk directly as a docker volume in backend container 2026-06-27 21:45:57 +07:00
3dtours a1bf0d2c08 feat: implement imageProcessor.ts and fix fullscreen photo lightbox alignment 2026-06-27 21:42:27 +07:00
3dtours 277f647e40 fix: copy public folder to production docker image stage to serve APK downloads 2026-06-27 21:17:33 +07:00
3dtours f39750af72 feat: implement 4-level priority coordinates resolution on photo upload 2026-06-27 21:08:42 +07:00
3dtours ff4dc9bb48 fix: restrict AppDownloadBanner to mobile browsers only using Capacitor.isNativePlatform() 2026-06-27 20:54:47 +07:00
3dtours 175668da35 feat: restore and integrate AppDownloadBanner under Top-Bar 2026-06-27 20:50:00 +07:00
3dtours 7ab1342690 fix: close login modal automatically on successful login in LandingPage 2026-06-27 20:41:40 +07:00
3dtours 2f4b24c41c fix: add androidClientId matching serverClientId in capacitor.config.ts 2026-06-27 20:36:26 +07:00
3dtours 1417f40dde fix: restore GoogleAuth.initialize on native to prevent startup NullPointerException crash 2026-06-27 20:32:59 +07:00
3dtours eaf79eaf8f fix: call GoogleAuth.initialize only on web to resolve Native OAuth error 2026-06-27 20:29:01 +07:00
3dtours 3fa80b69bf fix: android dropdown menu button 2026-06-27 20:27:44 +07:00
admin a7e569d9b4 merge: gộp thành công phần code sửa trước từ stash 2026-06-27 19:42:45 +07:00
admin 3bc7c8a160 fix: menu on android 2026-06-27 19:38:05 +07:00
admin 9fae6afaac fix: phần code sửa sau trên máy B 2026-06-27 17:52:42 +07:00
admin 614054f35d fix: phần code build 2026-06-27 17:51:19 +07:00
admin 796487ef76 fix: phần code build android 2026-06-27 17:46:23 +07:00
3dtours 813b41933b fix: android file update 2026-06-27 17:05:27 +07:00
3dtours 59153cffc2 fix: android pre-update 2026-06-27 16:54:47 +07:00
3dtours 4ee46371fa fix: gộp các nút thành viên và bỏ MemberDashboard 2026-06-27 16:53:19 +07:00
3dtours 400a3d098d fix: gộp các nút của thành viên và guest vào một 2026-06-27 15:37:00 +07:00
3dtours 5f49070a98 merge: giải quyết xung đột env và dashboard giữa 2 máy 2026-06-27 12:09:01 +07:00
3dtours accda67b22 fix: cập nhật giao diện và các trang dashboard từ máy B 2026-06-27 11:44:28 +07:00
admin fee2aed2e6 fix: sửa lỗi notification và giao diện chat trên android 2026-06-27 11:33:07 +07:00
admin ee42bfcefa fix: build ứng dụng trên android và có thể đăng nhập bằng Google Oauth 2026-06-27 10:39:22 +07:00
3dtours b5b6082d26 fix: update env 2026-06-26 21:00:50 +07:00
151 changed files with 11170 additions and 1376 deletions
+14
View File
@@ -0,0 +1,14 @@
DATABASE_URL="postgresql://postgres:password@localhost:5432/travel_db?schema=public"
PORT=3001
REDIS_URL=redis://localhost:6379
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=yotripadmin@gmail.com
SMTP_PASS="ahhh suif kfcp waie"
GOOGLE_CLIENT_ID=639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com
FRONTEND_URL=https://yotrip.labz.io.vn
ADMIN_SECRET_KEY=Yotrip@Bitabub1
# Development: Backend URL for vite dev server proxy (only used with npm run dev)
# For production: Nginx handles proxying, this is not used
VITE_BACKEND_URL=http://localhost:3001
-1
View File
@@ -1,4 +1,3 @@
.env
node_modules
server/dist
dist
-126
View File
@@ -1,126 +0,0 @@
# To AI Agent: Implement Smart Date-Based Accordion Auto-Expansion and Smooth Viewport Auto-Scroll
## 1. Context & UI/UX Requirements
We are enhancing the routing UX between `MemberDashboard.tsx` and `ItineraryTimeline.tsx` based on `image_cc31a4.png`.
### Functional Specifications:
1. **Date-Matching Evaluation:** When a user clicks "Chi tiết hành trình" on a tour card, calculate if the current user system local date falls inside any Stage/Leg timeline block.
2. **Auto-Expansion State:** On timeline page load, the matched Leg accordion must be expanded by default (`expandedStageId === leg.id`).
3. **Smart Auto-Scroll Layout:** The viewport must smoothly auto-scroll so that the expanded Leg's title bar lands **exactly below the sticky navigation tab bar** ("Lộ trình", "Chi phí", etc.), making it fully visible at the top of the viewport without layout clipping.
---
## 2. Technical Architecture & Layout Constraints
- **The Sticky Header Challenge:** Since the top navigation tab bar uses sticky/fixed positioning, a naive `scrollIntoView({ block: 'start' })` will cause the Leg's title to crawl *underneath* the tabs, hiding it.
- **The Solution:** We will inject a dynamic CSS scroll-margin-top parameter (`scroll-mt-[70px]` or matching header height) on each Leg container, and trigger a minor delayed layout effect pool using a React `setTimeout` to wait for the DOM accordion expansion reflow before pulling the scroll trigger.
---
## 3. Code Refactoring Blueprint
### Step 1: Update Navigation Payload Handler in Dashboard Component
Ensure the `MemberDashboard.tsx` accurately packages the matched target identifier into the client route state bucket:
```typescript
import { useNavigate } from 'react-router-dom';
const navigate = useNavigate();
const handleNavigateToItinerary = (tour: any) => {
let targetLegId = null;
if (tour?.legs && tour.legs.length > 0) {
const today = new Date();
today.setHours(0, 0, 0, 0);
for (const leg of tour.legs) {
const rawStart = leg.startDate || leg.plannedStart || leg.date;
const rawEnd = leg.endDate || leg.plannedEnd || leg.date;
if (rawStart) {
const startBound = new Date(rawStart);
startBound.setHours(0, 0, 0, 0);
const endBound = rawEnd ? new Date(rawEnd) : new Date(rawStart);
endBound.setHours(23, 59, 59, 999);
if (today >= startBound && today <= endBound) {
targetLegId = leg.id;
break;
}
}
}
if (!targetLegId) {
targetLegId = tour.legs[0].id; // Fallback to Chặng 1 if out of tour bounds
}
}
navigate(`/tour/${tour.id}`, {
state: { defaultExpandedLegId: targetLegId, shouldScrollToTarget: true }
});
};
### Step 2: Inject Safe Identifiers and Scroll Margin inside ItineraryTimeline
Locate the top-level outer container div of each Leg item inside the timeline loop (currentTour.legs.map). Assign a unique id and a scroll margin class utility:
{currentTour.legs.map((leg: any, legIdx: number) => (
<div
key={leg.id}
id={`leg-anchor-node-${leg.id}`}
/* CRITICAL: scroll-mt-[70px] leaves a 70px buffer space at the top.
Adjust '70px' to match the exact height of your white Tour Navigation Tabs bar!
*/
className="scroll-mt-[70px] transition-all w-full mb-4"
>
{/* Accordion Header Title ("Chặng 2: Dạo chơi ở xứ hoa vàng...") */}
<div className="flex items-center justify-between ...">
...
</div>
</div>
))}
### Step 3: Implement Lifecycle Interaction Trigger Engine
Inside ItineraryTimeline.tsx, listen to the passed history route parameters. Set the state, then handle the async smooth scrolling action:
import { useLocation } from 'react-router-dom';
const location = useLocation();
const [expandedStageId, setExpandedStageId] = useState<string | null>(null);
useEffect(() => {
if (location.state?.defaultExpandedLegId) {
const targetId = location.state.defaultExpandedLegId;
// 1. Instantly trigger the accordion state layout expansion
setExpandedStageId(targetId);
// 2. Schedule a deferred macro-task callback to allow DOM re-renders to finish
if (location.state?.shouldScrollToTarget) {
const scrollTimer = setTimeout(() => {
const targetElement = document.getElementById(`leg-anchor-node-${targetId}`);
if (targetElement) {
targetElement.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
// Clear history router state token flags to prevent repeating scroll on subsequent reload shifts
window.history.replaceState({}, document.title);
}, 150); // 150ms ensures smooth accordion height deployment transition finishes
return () => clearTimeout(scrollTimer);
}
} else if (currentTour?.legs && currentTour.legs.length > 0 && !expandedStageId) {
setExpandedStageId(currentTour.legs[0].id);
}
}, [location.state, currentTour]);
## 4. Quality Control & Acceptance Verification
[ ] Date Matching Accuracy: Set today's date context matching a target Leg configuration profile. Tap the transition interface button from dashboard. The page must route directly and expand the target container block node.
[ ] Flush Viewport Ceiling Test: The animated target header segment node must slide upwards smoothly. It must lock positions cleanly right below the lowest baseline layer shadow boundary of the Tab Controller without passing behind it.
[ ] State Cleanliness Check: Refreshing or navigating back and forth within the active Timeline panel after the initial landing should not lock or force layout views to keep jumping scroll heights automatically.
+129
View File
@@ -0,0 +1,129 @@
# To AI Agent: Deprecate MemberDashboard Page and Re-architect All User Features into Dedicated Modals on LandingPage
## 1. Architectural Strategy & Goal
We are completely removing the standalone `MemberDashboard` route/page. When a user logs in successfully, they must **remain directly on the `LandingPage`** (or `ExplorerMap`), with their authentication state shifting cleanly to display the member avatar dropdown menu in the top-bar header.
Every core feature previously hosted on the dashboard page must now be converted into a high-performance, responsive **Modal Overlay Component**. Clicking an item in the avatar dropdown menu will toggle the visibility state of its respective modal over the current viewport, ensuring zero page-redirection disruption.
---
## 2. Route Deletion & Auth Redirection Clean-up
### 1. **Route Removal:** Open your router configuration (`App.tsx` or `routes.tsx`) and permanently delete the `<Route path="/dashboard" ... />` node.
### 2. **Auth Hook Modification:** Inside the login handler lifecycle (e.g., `LoginModal.tsx` or `AuthContext.tsx`), replace `Maps('/dashboard')` with a simple state closer that preserves the current page instance:
```typescript
// ❌ OLD: navigate('/dashboard');
// ✅ NEW: Keep user on context page, close login overlay, update profile states
setIsLoginModalOpen(false);
## 3. Technical Specifications for Each Target Modal
Implement the following modal architectures inside frontend/src/components/modals/:
### 3.1. Modal Chat Trực Tiếp (LiveChatModal.tsx)
Layout Architecture: A wide responsive viewport split into two main functional vertical columns (flex flex-col md:flex-row h-[80vh]).
Left Column (w-full md:w-80 border-r border-slate-800): Interactive scrollable contact strip displaying the User's Friend List with online status indicators and latest message snippets.
Right Column (flex-1 flex flex-col bg-slate-950): Active conversation window.
Bottom Input Tray Wrapper: A rich-text typing container bar fixed at the baseline containing:
Text input area field.
Attachment Trigger buttons: Icon for Image uploads (accept="image/*") and an Icon for transmitting spatial GPS Coordinates/Locations directly into the text stream.
### 3.2. Modal Hành Trình Của Tôi (MyToursModal.tsx)
Layout Architecture: A dynamic card explorer view featuring structural chronological timeline tabs at the top:
Tabs Grid: Đang thực hiện (Ongoing), Sắp khởi hành (Upcoming), and Đã hoàn thành (Past).
Core Content Panel: Clicking a tab renders a fluid inner grid loop of compact trip tiles. Each tile houses a banner photo, progress indicators, and quick action links to open the standalone TourNavigationPage or route maps directly.
### 3.3. Modal Thư Viện Ảnh (PhotoGalleryModal.tsx)
Layout Architecture: A dedicated media repository browser displaying all images uploaded by the member.
Filtering Header Matrix: Dual filter select boxes pinned at the top:
Filter 1: Filter by Itinerary/Trip (Theo hành trình).
Filter 2: Filter by Media Hashtags (Theo tags).
Core Workspace: A masonry-style gallery layout grid with hover micro-interactions enabling the user to view full resolution views, edit asset tagging descriptions, or delete pictures directly.
### 3.4. Modal Danh Sách Bạn Bè (FriendsManagerModal.tsx)
Layout Architecture: A centralized social dashboard layout built to handle user connections.
Functional Subsections:
Search engine bar to lookup new profiles via display name or telephone metrics.
Tab views separating Danh sách bạn bè (Active Friends) and Lời mời kết bạn (Pending Requests).
Row items equipped with direct context action triggers: Hủy kết bạn (Unfriend), Chấp nhận (Accept), or Nhắn tin (Quick Message - which bridges states to auto-toggle the Chat Modal).
## 4. Top-Bar Profile Dropdown Structure Integration
Refactor the items list container within MapProfileDropdown.tsx to match the localized modal toggles state logic:
TypeScript
import React, { useState } from 'react';
import { Compass, Map, Image, Settings, ShieldAlert, Users, LogOut } from 'lucide-react';
export const HeaderMemberDropdown = ({ openModal }) => {
return (
<div className="absolute right-0 top-14 w-64 bg-slate-900 border border-slate-800 rounded-xl p-1.5 shadow-2xl flex flex-col space-y-0.5 text-xs text-slate-200 z-[999999]">
{/* 1. Nút "Tạo tour" */}
<button onClick={() => openModal('create_tour')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
<Compass className="w-4 h-4 text-amber-400" />
<span className="font-medium">Tạo tour</span>
</button>
{/* 2. Nút "Hành trình của tôi" */}
<button onClick={() => openModal('my_tours')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
<Map className="w-4 h-4 text-blue-400" />
<span className="font-medium">Hành trình của tôi</span>
</button>
{/* 3. Nút "Thư viện ảnh" */}
<button onClick={() => openModal('gallery')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
<Image className="w-4 h-4 text-emerald-400" />
<span className="font-medium">Thư viện ảnh</span>
</button>
{/* 4. Nút "Cài đặt" */}
<button onClick={() => openModal('settings')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
<Settings className="w-4 h-4 text-slate-400" />
<span className="font-medium">Cài đặt</span>
</button>
{/* 5. Nút "Báo cáo vi phạm" */}
<button onClick={() => openModal('reports')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
<ShieldAlert className="w-4 h-4 text-rose-400" />
<span className="font-medium">Báo cáo vi phạm</span>
</button>
<div className="h-[1px] bg-slate-800/80 my-1 mx-2" />
{/* 6. Nút "Danh sách bạn bè" */}
<button onClick={() => openModal('friends')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
<Users className="w-4 h-4 text-indigo-400" />
<span className="font-medium">Danh sách bạn bè</span>
</button>
{/* 7. Nút "Đăng xuất" */}
<button onClick={() => openModal('logout')} className="flex items-center gap-3 w-full px-4 py-2.5 text-rose-400 hover:bg-slate-800 rounded-lg text-left font-bold transition-colors">
<LogOut className="w-4 h-4 text-rose-500" />
<span>Đăng xuất</span>
</button>
</div>
);
};
## 5. Automation Checklists for Quality Verification
[ ] Redirection Invalidation Check: Perform a successful user login sweep. Verify the active URL hash parameter remains exactly / or /map, completely dropping dashboard transitions.
[ ] Chat Modal Layout Sizing: Trigger the Live Chat button. Ensure the workspace scales to an explicit split-view pane framework (Left: Contacts / Right: Dialog Thread) with working attachment trays.
[ ] Gallery Filter Interception: Confirm that filtering options inside the Photo modal dynamically re-index asset cards based on itinerary tags or custom upload parameters.
[ ] Global Z-Index Verification: All new modals must enforce a strict z-[999999] utility layer rule to pop up cleanly above the underlying map viewport layer without clip cutting.
+181
View File
@@ -0,0 +1,181 @@
# To AI Agent: Implement Offline-First Architecture for Mobile App & Web Browsers (Offline Photo Upload & Offline Map Routing)
## 1. Context & Architectural Strategy
We are implementing an **Offline-First Capabilities Layer** for both browser environments (Chrome Android, iOS Safari) and mobile application wrappers. The application must remain functional when network connectivity is lost ($Network = 0$).
### Core Requirements:
1. **Offline Photo Upload Queue:** When a user uploads a photo without an active internet connection, the system must not throw a network exception. Instead, it must store the image file (Blob/ArrayBuffer) and its accompanying metadata (GPS, Timestamp) into a browser-native transactional database (**IndexedDB**).
2. **Background Sync Resume:** As soon as the device regains cellular/Wi-Fi data telemetry, a background synchronization worker must automatically trigger, reading the IndexedDB queue and completing the multi-part upload streams to the Debian server sequentially.
3. **Offline Map Navigation Routing:** Cache critical operational Map Layout Tiles and routing geometries via a persistent client-side caching framework (**Service Workers + Cache API**).
---
## 2. Technical Offline System Stack Overview
[Offline User Interaction]
├──➔ (Photo Upload) ──➔ Save Blob & EXIF Metadata ──➔ [ IndexedDB Storage ]
│ │ (Device re-connects)
│ ▼
│ [ Background Sync ] ──➔ POST to Server
└──➔ (Map Routing) ──➔ Request Map Assets ──➔ [ Service Worker Cache API ] ──➔ Render View
## 3. Code Refactoring Blueprint
### Step 1: Implement Offline Storage Queue Service (`offlineQueue.ts`)
Create a localized database management layer at `frontend/src/utils/offlineQueue.ts` leveraging IndexedDB to hold pending image uploads:
```typescript
import { openDB, IDBPDatabase } from 'idb';
const DB_NAME = 'yotrip-offline-db';
const STORE_NAME = 'pending-uploads';
interface OfflinePhoto {
id: string;
fileBlob: Blob;
fileName: string;
latitude: string | null;
longitude: string | null;
capturedAt: string | null;
timestamp: number;
}
// Initialize the local IndexedDB container safely
const getDB = (): Promise<IDBPDatabase> => {
return openDB(DB_NAME, 1, {
upgrade(db) {
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
},
});
};
// 1. STASH UPLOAD METADATA INTO OFFLINE STORAGE
export const queueOfflineUpload = async (photoData: Omit<OfflinePhoto, 'id' | 'timestamp'>) => {
const db = await getDB();
const id = crypto.randomUUID();
const item: OfflinePhoto = {
...photoData,
id,
timestamp: Date.now(),
};
await db.put(STORE_NAME, item);
console.log(`📦 Photo [${id}] stashed safely into local IndexedDB queue for offline background sync.`);
return id;
};
// 2. RETRIEVE ALL PENDING FILES ONCE ONLINE
export const getPendingUploads = async (): Promise<OfflinePhoto[]> => {
const db = await getDB();
return db.getAll(STORE_NAME);
};
// 3. REMOVE COMPLETED TRANSACTION FROM QUEUE
export const removePendingUpload = async (id: string) => {
const db = await getDB();
await db.delete(STORE_NAME, id);
};
### Step 2: Implement Background Synchronization Sync Engine (backgroundSync.ts)
Create an active network monitoring service that intercepts reconnection signals and synchronizes the local database records:
import { getPendingUploads, removePendingUpload } from './offlineQueue';
import { api } from '../services/api';
export const executeBackgroundSyncEngine = async (refreshMapPins: () => void) => {
const pendingItems = await getPendingUploads();
if (pendingItems.length === 0) return;
console.log(`🔄 Internet restored! Syncing [${pendingItems.length}] pending items to yotrip.labz.io.vn...`);
for (const item of pendingItems) {
const formData = new FormData();
formData.append('photo', item.fileBlob, item.fileName);
if (item.latitude && item.longitude) {
formData.append('latitude', item.latitude);
formData.append('longitude', item.longitude);
}
if (item.capturedAt) {
formData.append('capturedAt', item.capturedAt);
}
try {
// Dispatch payload to Debian production server endpoint
const response = await api.post('/photos/upload-with-meta', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (response.status === 200 || response.status === 201) {
// Remove item from local DB once server confirms safe receipt
await removePendingUpload(item.id);
console.log(`✅ Offline photo [${item.id}] synchronized successfully.`);
}
} catch (error) {
console.error(`✖ Failed to synchronize item [${item.id}]. Will retry on next connectivity cycle:`, error);
break; // Stop loop if server goes down mid-transit to protect execution queues
}
}
// Trigger reactive map layer update
refreshMapPins();
};
// Global Connectivity Listener initialization hook
export const initNetworkStatusListeners = (refreshMapPins: () => void) => {
window.addEventListener('online', () => executeBackgroundSyncEngine(refreshMapPins));
// Guard check on application startup in case network recovered while app was closed
if (navigator.onLine) {
executeBackgroundSyncEngine(refreshMapPins);
}
};
### Step 3: Configure Service Worker for Offline Map Tiles Cache (sw.js)
Configure your service worker script block (e.g., public/sw.js) to intercept and cache vector navigation maps or stylesheet assets statically:
const CACHE_NAME = 'yotrip-static-map-v1';
const MAP_TILE_PATTERN = /tiles\.maps\.lincoln|openstreetmap|mapbox/;
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll([
'/',
'/index.html',
'/src/main.tsx',
'/manifest.json',
'/assets/offline-map-placeholder.png' // Fallback image asset
]);
})
);
});
// NETWORK-FIRST FALLBACK TO CACHE STRATEGY FOR OFFLINE ROUTING MAPS
self.addEventListener('fetch', (event) => {
const requestUrl = event.request.url;
if (MAP_TILE_PATTERN.test(requestUrl)) {
event.respondWith(
fetch(event.request)
.then((response) => {
// Clone and update cache with newly acquired dynamic tile data layers
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return response;
})
.catch(() => {
// If offline, serve map tiles seamlessly straight from client-side Cache API
return caches.match(event.request);
})
);
}
});
## 4. Quality Verification & Acceptance Criteria for AI Agent[ ] Airplane Mode Upload Stability: Toggle Airplane mode ($Network = 0$) inside the Android Emulator/iOS Safari responsive inspector. Upload an image. The UI must transition seamlessly, displaying an "Ảnh đã được lưu tạm ngoại tuyến" notification banner without runtime exceptions.[ ] Automatic Queue Flushing Check: Disable Airplane mode. Verify through the browser network inspector tool that a series of asynchronous multi-part POST network requests fire automatically towards https://yotrip.labz.io.vn/api/ without requiring user interaction.[ ] Offline Map Cache Test: Clear your browser network connectivity states. Navigate around the map interface canvas. Previously inspected map sections and routing data blocks must remain fully rendered from the Service Worker cache layer.
+181
View File
@@ -0,0 +1,181 @@
# To AI Agent: Fix EXIF GPS Extraction, Implement Client-Side 2K Image Resizing, and Fix Android Fullscreen Lightbox Alignment
## 1. Context & Feature Objectives
We are addressing three crucial image-handling and layout bugs on the mobile/Android web wrapper:
1. **Fix (Missing EXIF Location):** When users upload photos, the system fails to extract the geographic coordinates (Latitude/Longitude) embedded within the image metadata. We need to parse EXIF data completely on the client side before submission.
2. **Feat (Native Save & 2K Downscale):** Whether the user shoots a new photo via the Camera or picks one from the Gallery, the original image must remain safely stored in the phone's native album (handled by native webview permissions). Before uploading the file to our Debian server, the frontend must dynamically resize/downscale the image to a maximum resolution of **2K (2048px on its longest edge)** to optimize network bandwidth and server storage.
3. **Bug (Fullscreen Viewer Displacement):** When clicking an image inside the gallery/photo manager view to preview it in fullscreen mode on an Android device, the image incorrectly aligns to the absolute bottom edge of the viewport instead of centering beautifully.
---
## 2. Technical Execution Strategy
### 2.1. Client-Side EXIF Processing & Metadata Preservation
Standard browser file inputs often strip EXIF headers during dynamic manipulation or fail to parse them natively. We will introduce `exif-js` or use a standard binary array buffer scanner to extract the `GPSLatitude` and `GPSLongitude` headers right before resizing occurs, attaching them to the final multipart upload payload.
### 2.2. Downscaling to 2K via HTML5 Canvas
To achieve hardware-accelerated image scaling on mobile devices without losing core image visibility, the source image will be rendered onto an offscreen `<canvas>` container configured to enforce a `max-dimension` of `2048px`, maintaining the original aspect ratio.
### 2.3. Flexbox/Absolute Centering Fix for Android Lightbox
The bottom-displacement bug is tied to incorrect layout bounds calculations on mobile screens when toolbars or navigation rows shift view heights. We will refactor the Lightbox container modal to use rigid viewport configurations (`fixed inset-0`) along with standard vertical centering mechanics.
---
## 3. Code Refactoring Blueprint
### Step 1: Implement Image Metadata Picker & 2K Resizer Logic (`imageProcessor.ts`)
Create a utility service at `frontend/src/utils/imageProcessor.ts` to handle metadata extraction and canvas downscaling sequentially:
```typescript
import EXIF from 'exif-js';
interface ProcessedImageResult {
file: Blob;
latitude: number | null;
longitude: number | null;
}
// Helper to convert EXIF rational coordinates to standard decimal degrees
const convertDMSToDD = (dms: number[], ref: string): number => {
if (!dms || dms.length < 3) return 0;
const degrees = dms[0] + dms[1] / 60 + dms[2] / 3600;
return ref === 'S' || ref === 'W' ? -degrees : degrees;
};
export const processAndResizeImage = (file: File): Promise<ProcessedImageResult> => {
return new Promise((resolve) => {
let latitude: number | null = null;
let longitude: number | null = null;
// 1. EXTRACT EXIF METADATA BEFORE CANVAS CLEARING
EXIF.getData(file as any, function (this: any) {
const allTags = EXIF.getAllTags(this);
if (allTags.GPSLatitude && allTags.GPSLatitudeRef) {
latitude = convertDMSToDD(allTags.GPSLatitude, allTags.GPSLatitudeRef);
}
if (allTags.GPSLongitude && allTags.GPSLongitudeRef) {
longitude = convertDMSToDD(allTags.GPSLongitude, allTags.GPSLongitudeRef);
}
console.log(`📸 Extracted EXIF Metadata - Lat: ${latitude}, Lng: ${longitude}`);
// Proceed directly to resizing stage
proceedToResize();
});
function proceedToResize() {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target?.result as string;
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
const MAX_SIZE = 2048; // Enforce rigid 2K maximum boundary limit
// Calculate ideal bounding proportions
if (width > height) {
if (width > MAX_SIZE) {
height = Math.round((height * MAX_SIZE) / width);
width = MAX_SIZE;
}
} else {
if (height > MAX_SIZE) {
width = Math.round((width * MAX_SIZE) / height);
height = MAX_SIZE;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return resolve({ file, latitude, longitude });
// Render image onto downscaled dimensions canvas bounding box
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
if (blob) {
resolve({
file: blob,
latitude,
longitude
});
} else {
resolve({ file, latitude, longitude });
}
}, 'image/jpeg', 0.88); // 88% quality compression sweet-spot
};
};
}
});
};
### Step 2: Update Image Upload Handler Layer
Integrate the processor wrapper inside your central upload function (e.g., ImageUploader.tsx or your form submission handler):
import { processAndResizeImage } from '../../utils/imageProcessor';
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const targetFile = event.target.files?.[0];
if (!targetFile) return;
try {
// 1. Process image: Reads EXIF data and forces 2K resizing on device memory
const { file, latitude, longitude } = await processAndResizeImage(targetFile);
const formData = new FormData();
formData.append('image', file, 'yotrip_upload.jpg');
// 2. Append coordinates safely to standard server fields
if (latitude !== null && longitude !== null) {
formData.append('latitude', latitude.toString());
formData.append('longitude', longitude.toString());
}
// 3. Post to API endpoint
const response = await api.post('/media/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
console.log("✅ Media successfully synchronized with server backend:", response.data);
} catch (error) {
console.error("Failed to safely prepare media stream:", error);
}
};
### Step 3: Fix Fullscreen Image Alignment Layout (ImageLightbox.tsx)
Locate your photo viewer overlay or modal drawer component. Overhaul the tailwind utilities to guarantee true vertical and horizontal centering layout balance on Android devices:
{/* ❌ BEFORE: Faulty container pinning images to device bottom edges */}
<div className="fixed inset-0 bg-black flex items-end justify-center">
{/* ✅ AFTER: True viewport overlay centering bounding context */}
<div className="fixed inset-0 bg-black/95 backdrop-blur-sm flex flex-col items-center justify-center z-[999999] overflow-hidden animate-fade-in">
{/* Close Button Top Tracker Bar Container */}
<div className="absolute top-4 right-4 z-50">
<button className="p-2.5 bg-slate-900/60 rounded-full text-white">✕</button>
</div>
{/* Image wrapper frame context forcing clean alignment metrics */}
<div className="w-full h-full flex items-center justify-center p-4">
<img
src={currentImageUrl}
alt="YoTrip Preview"
className="max-w-full max-h-full object-contain select-none pointer-events-auto"
style={{
/* Prevent Android webviews from accidental shifting behaviors */
transform: 'translate3d(0, 0, 0)'
}}
/>
</div>
</div>
## 4. Automated Verification Checklist for AI Agent
[ ] EXIF Validation Verification: Test uploading a photo embedded with active geolocation values. Inspect the API outgoing transmission payload in the network panel; latitude and longitude fields must contain accurate decimal metrics instead of blank string indicators.
[ ] Longest-Edge Constraint Check: Upload a ultra-high resolution image (e.g., 4000px wide). Verify that the processed file size shrinks significantly, and confirm through terminal logging that the generated canvas asset limits width/height strictly to 2048px.
[ ] Android Centering Success: Activate the image preview mode inside the Android Simulator. The image layout must align mathematically dead-center vertically, leaving symmetric padding bars on both the top header and bottom system navigation boundaries.
+151
View File
@@ -0,0 +1,151 @@
# To AI Agent: Restructure Mobile Photo Viewer Layout with Fixed Viewport, Overlay Metadata, and Scrollable Comments
## 1. Context & Architectural UI Refactor
We are redesigning the full-screen mobile photo inspector interface based on the visual layout annotated in `image.png`. The current structure is fragmented, causing layout instability when switching between images.
### Key Refactor Requirements:
1. **Fixed Image Viewport (Blue Zone):** Secure the photo viewport inside a strict, unyielding aspect-locked square container. Regardless of whether the active image is Landscape or Portrait, the container dimension must NOT snap, resize, or cause screen layout jumps.
2. **Geospatial Overlay (Red Arrow Destination):** Completely remove the static location metadata box from the bottom white panel. Relocate and render this location string as a clean, translucent text overlay pinned directly to the **bottom-left corner inside the image viewport**.
3. **Title Transformation (Green Arrow Destination):** Remove the text header segment "Lịch sử ảnh tại vị trí này (...)". In its place, dynamically render the active **Title of the Photo** (Tiêu đề của ảnh), serving as the primary text separator.
4. **Isolated Scrollable Comment Feed:** The entire lower comment zone must be configured to scroll dynamically. When users swipe up to read multiple comments, the layout thread must slide seamlessly underneath the fixed sticky image frame container (`z-20`).
---
## 2. Structural Layer Elevation (`z-index`) Matrix
┌────────────────────────────────────────────────────────┐
│ Tier 4: System Action Row & Closes (z-50) │ ➔ Native buttons (X, Close)
├────────────────────────────────────────────────────────┤
│ Tier 3: Fixed Photo Frame Viewport (z-20 / sticky) │ ➔ Aspect-Square Image Box
│ └─► Sub-Layer: Location & Time Overlay (z-30) │ ➔ Pinned Bottom-Left on Image
├────────────────────────────────────────────────────────┤
│ Tier 2: Scrollable Comments Panel (z-10) │ ➔ Slides BEHIND Tier 3 when swiped
└────────────────────────────────────────────────────────┘
---
## 3. Code Refactoring Blueprint
### Step 1: Overhaul Layout Architecture (`MobilePhotoViewerModal.tsx`)
Update or create the mobile component layout to enforce the absolute layer boundaries and fixed dimensions:
```typescript
import React, { useState } from 'react';
import { X, MapPin, Calendar, Heart, Send } from 'lucide-react';
interface PhotoDetail {
id: string;
url: string;
title: string;
description?: string;
latitude: number;
longitude: number;
locationName: string;
capturedAt: string;
likesCount: number;
}
export const MobilePhotoViewerModal: React.FC<{ photo: PhotoDetail; onClose: () => void }> = ({ photo, onClose }) => {
const [commentInput, setCommentInput] = useState('');
return (
<div className="fixed inset-0 bg-slate-950 z-[999999] flex flex-col overflow-hidden select-none text-white antialiased">
{/* TIER 4: FIXED SYSTEM ACTIONS CONTROL ROW (z-50) */}
<div className="absolute top-4 right-4 z-50">
<button
onClick={onClose}
className="p-2.5 bg-black/60 hover:bg-black/80 rounded-full border border-slate-800 backdrop-blur-md active:scale-95 transition-transform"
>
<X className="w-5 h-5 text-white" />
</button>
</div>
{/* TIER 3: FIXED BLUE ZONE - ASPECT LOCKED PHOTO FRAME (z-20 / sticky) */}
<div className="w-full aspect-square bg-slate-950 flex items-center justify-center sticky top-0 z-20 border-b border-slate-900/60 shadow-2xl shrink-0">
<img
src={photo.url}
alt={photo.title}
className="w-full h-full object-contain select-none pointer-events-none"
/>
{/* TIER 3.1: RELOCATED DYNAMIC LOCATION & TIMESTAMP OVERLAY (z-30) */}
<div className="absolute bottom-4 left-4 right-16 z-30 flex flex-col gap-1 p-2.5 bg-black/50 backdrop-blur-sm rounded-xl border border-white/10 text-[10px] text-slate-200 max-w-[75vw] pointer-events-none">
<div className="flex items-center gap-1.5 font-semibold text-white">
<MapPin className="w-3.5 h-3.5 text-blue-400 shrink-0" />
<span className="truncate">{photo.locationName || "Vị trí không xác định"}</span>
</div>
<div className="flex items-center gap-1.5 text-slate-300 pl-5">
<Calendar className="w-3 h-3 text-slate-400 shrink-0" />
<span>Ngày chụp: {new Date(photo.capturedAt).toLocaleDateString('vi-VN')}</span>
</div>
</div>
{/* Floating Like Badge Counter */}
<div className="absolute bottom-4 right-4 z-30 bg-black/50 backdrop-blur-sm border border-white/10 rounded-xl px-2.5 py-1.5 flex items-center gap-1 text-[11px] font-bold text-rose-500">
<Heart className="w-3.5 h-3.5 fill-rose-500" />
<span>{photo.likesCount}</span>
</div>
</div>
{/* TIER 2: SCROLLABLE CORE CONTENTS & COMMENTS PANEL (z-10) */}
{/* This entire workspace container slides underneath the photo layout on swipe-up */}
<div className="flex-1 overflow-y-auto bg-slate-900/30 relative z-10 flex flex-col min-h-0">
{/* REPLACED HEADER ZONE: Title replaces the old History text strip */}
<div className="px-5 py-4 border-b border-slate-900/80 bg-slate-900/90 backdrop-blur-md sticky top-0 z-10 space-y-1">
<h2 className="text-sm font-bold text-slate-100 tracking-wide">
{photo.title || "Chưa có tiêu đề"}
</h2>
{photo.description && (
<p className="text-[11px] text-slate-400 leading-relaxed font-medium">
{photo.description}
</p>
)}
</div>
{/* DYNAMIC COMMENTS FEED BLOCK */}
<div className="p-4 space-y-3.5 flex-1 overflow-y-visible">
{/* Mock iteration loop representing user chat bubbles */}
{[...Array(5)].map((_, index) => (
<div key={index} className="flex gap-3 items-start text-xs text-slate-300 animate-fade-in">
<div className="w-7 h-7 rounded-full bg-slate-800 font-bold text-[9px] flex items-center justify-center shrink-0 border border-slate-700">U</div>
<div className="flex-1 bg-slate-900/50 border border-slate-800/60 rounded-xl p-3 space-y-1 shadow-sm">
<div className="flex justify-between items-center text-[10px] font-semibold">
<span className="text-slate-200">Thành viên YoTrip</span>
<span className="text-slate-500 font-normal">Vừa xong</span>
</div>
<p className="text-slate-400 leading-relaxed text-[11px]">Góc chụp đẹp quá, bối cảnh nhìn rất thoáng và đầy đủ ánh sáng tự nhiên!</p>
</div>
</div>
))}
</div>
{/* STICKY BOTTOM INPUT SEND TRAY BAR */}
<div className="p-3 bg-slate-950 border-t border-slate-900 flex items-center gap-2 sticky bottom-0 z-20">
<input
type="text"
value={commentInput}
onChange={(e) => setCommentInput(e.target.value)}
placeholder="Viết bình luận công khai..."
className="flex-1 bg-slate-900 border border-slate-800 text-white rounded-xl p-3 text-xs outline-none focus:border-blue-500 transition-colors placeholder-slate-500"
/>
<button className="p-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl transition-colors active:scale-95 shadow-lg">
<Send className="w-4 h-4" />
</button>
</div>
</div>
</div>
);
};
## 4. Quality Verification & Acceptance Criteria for AI Agent
[ ] Dimension Stability Test: Switch back and forth between an ultra-wide panoramic photo and a 4:3 vertical shot. The parent blue container (aspect-square) must remain exactly static on the viewport layout, blocking size shifts.
[ ] Overlay Check Validation: Verify the location marker coordinates box is completely wiped from the lower white profile zone and draws successfully on top of the image canvas at the bottom-left edge.
[ ] Text Swap Alignment: Ensure the string text "Lịch sử ảnh tại vị trí này" is replaced completely by the active image title asset hook.
[ ] Scroll Pass Inspection: Swipe up to read the text inside the comments panel. Verify the message rows pass behind the bottom border line of the image canvas container (z-20), while the close button at the top remains fully accessible.
+1
View File
@@ -0,0 +1 @@
../.env
+1
View File
@@ -33,6 +33,7 @@ COPY package*.json ./
COPY --from=build /usr/src/app/node_modules ./node_modules
COPY --from=build /usr/src/app/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"]
+11
View File
@@ -154,6 +154,17 @@ async function bootstrap() {
app.useStaticAssets(UPLOAD_ROOT, {
prefix: '/uploads/',
});
const downloadsDir = path.join(process.cwd(), 'public/downloads');
if (!fs.existsSync(downloadsDir)) {
fs.mkdirSync(downloadsDir, { recursive: true });
}
app.useStaticAssets(downloadsDir, {
prefix: '/downloads/',
setHeaders: (res) => {
res.set('Content-Type', 'application/vnd.android.package-archive');
res.set('Content-Disposition', 'attachment; filename="yotrip-latest.apk"');
}
});
const prisma = app.get(prisma_service_1.PrismaService);
startAutoCleanup(prisma);
await app.listen(3001);
+1 -1
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
+41
View File
@@ -0,0 +1,41 @@
import exifr from 'exifr';
import fs from 'fs';
import path from 'path';
const searchDir = '.';
async function walk(dir) {
let files = [];
const list = fs.readdirSync(dir);
for (const file of list) {
if (file === 'node_modules' || file === '.git' || file === '.vscode') continue;
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
files = files.concat(await walk(fullPath));
} else {
if (['.jpg', '.jpeg', '.png'].includes(path.extname(file).toLowerCase())) {
files.push(fullPath);
}
}
}
return files;
}
async function run() {
const images = await walk(searchDir);
console.log(`Found ${images.length} images to scan...`);
for (const img of images) {
try {
const gps = await exifr.gps(img);
if (gps) {
console.log(`FOUND IMAGE WITH GPS: ${img}`, gps);
}
} catch (e) {
// ignore
}
}
console.log('Scan completed.');
}
run();
+39
View File
@@ -0,0 +1,39 @@
import EXIF from 'exif-js';
import exifr from 'exifr';
import fs from 'fs';
async function test() {
const files = [
'./node_modules/exif-js/example/dsc_09827.jpg',
'./node_modules/exif-js/example/DSCN0614_small.jpg',
'./node_modules/exif-js/example/Bloated-Hero.jpg',
'./node_modules/exif-js/example/Bush-dog.jpg'
];
for (const f of files) {
console.log(`--- Testing file: ${f} ---`);
try {
const gpsExifr = await exifr.gps(f);
console.log(' exifr.gps:', gpsExifr);
} catch (e) {
console.log(' exifr error:', e.message);
}
try {
const data = fs.readFileSync(f);
const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
const parsed = await exifr.parse(arrayBuffer);
console.log(' exifr.parse output:', parsed ? {
latitude: parsed.latitude,
longitude: parsed.longitude,
DateTimeOriginal: parsed.DateTimeOriginal,
CreateDate: parsed.CreateDate,
ModifyDate: parsed.ModifyDate
} : 'null');
} catch (e) {
console.log(' exifr.parse error:', e.message);
}
}
}
test();
+19 -8
View File
@@ -114,6 +114,21 @@ async function bootstrap() {
prefix: '/uploads/',
});
// Tự động tạo thư mục downloads nếu chưa tồn tại
const downloadsDir = path.join(process.cwd(), 'public/downloads');
if (!fs.existsSync(downloadsDir)) {
fs.mkdirSync(downloadsDir, { recursive: true });
}
// Khai báo thư mục lưu trữ APK tải về
app.useStaticAssets(downloadsDir, {
prefix: '/downloads/',
setHeaders: (res) => {
res.set('Content-Type', 'application/vnd.android.package-archive');
res.set('Content-Disposition', 'attachment; filename="yotrip-latest.apk"');
}
});
const prisma = app.get(PrismaService);
startAutoCleanup(prisma);
@@ -2169,12 +2184,7 @@ class PhotoController {
}
const uploaderId = req.user.id;
const isAnonymous = req.user.isAnonymous;
// Chỉ người dùng ẩn danh mới được dùng endpoint này
if (!isAnonymous) {
throw new ForbiddenException('Chỉ người dùng khách mới có thể sử dụng tính năng này.');
}
// Cả người dùng đã đăng ký và khách ẩn danh đều được dùng endpoint này để chia sẻ ảnh công khai lên bản đồ
const file = files[0];
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
@@ -2309,7 +2319,7 @@ class PhotoController {
@Patch(':id')
async updatePhoto(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: { title?: string; description?: string; latitude?: number; longitude?: number },
@Body() body: { title?: string; description?: string; latitude?: number; longitude?: number; tags?: string[] },
@Req() req: any
) {
const photo = await this.prisma.photo.findUnique({
@@ -2332,6 +2342,7 @@ class PhotoController {
lng: body.longitude !== undefined ? body.longitude : currentMetadata.lng,
title: body.title !== undefined ? body.title : currentMetadata.title,
description: body.description !== undefined ? body.description : currentMetadata.description,
tags: body.tags !== undefined ? body.tags : currentMetadata.tags,
};
return this.prisma.photo.update({
@@ -2463,7 +2474,7 @@ class UserController {
return this.prisma.user.update({
where: { id },
data,
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true }
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true, avatar: true, phone: true, address: true }
});
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 868 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 844 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 644 KiB

@@ -0,0 +1 @@
{"web":{"client_id":"639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com","project_id":"yotrip-web","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"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"}}
+1
View File
@@ -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"
+87
View File
@@ -0,0 +1,87 @@
# Kế hoạch triển khai: Cấu hình Docker để Chạy Server & Phát triển (Hot-reload)
Kế hoạch này phác thảo cách cấu hình Docker và Docker Compose cho dự án YoTrip. Cấu hình này sẽ đáp ứng đồng thời hai nhu cầu:
1. **Môi trường Phát triển (Development)**: Đồng bộ mã nguồn trực tiếp (bind mounts) từ máy local vào container, hỗ trợ hot-reload cho cả backend (NestJS watch) và frontend (Vite HMR).
2. **Môi trường Triển khai (Production)**: Đóng gói tối ưu thành các image độc lập, sử dụng Nginx để phục vụ frontend tĩnh và tối ưu hóa hiệu năng NestJS backend.
---
## User Review Required
> [!IMPORTANT]
> - **Biến môi trường trong Docker**: Khi chạy trong Docker Compose, địa chỉ kết nối cơ sở dữ liệu (`DATABASE_URL`) và Redis (`REDIS_URL`) phải trỏ đến tên các service của container (ví dụ: `postgres` thay vì `localhost`). Chúng tôi sẽ cấu hình Docker Compose ghi đè (override) các biến này một cách tự động để tránh làm hỏng cấu hình chạy trực tiếp bằng `npm run start:dev` trên máy local của bạn.
> - **Cổng mạng (Ports)**:
> - Backend: Cổng `3001` được mở ra ngoài.
> - Frontend: Cổng `5173` (cho Dev) và cổng `3002` (cho Production thông qua Nginx).
> - Database: Cổng `5432` (mở để truy cập quản trị nếu cần).
> - Redis: Cổng `6379`.
---
## Proposed Changes
### 1. Dockerfile cho Backend
#### [NEW] [Dockerfile](file:///home/locpham/travelplanning/backend/Dockerfile)
- Thiết lập môi trường chạy Node.js (phiên bản `20-alpine`).
- Cài đặt các gói phụ thuộc hệ thống cần thiết (như openssl cho Prisma).
- Cấu hình chạy chế độ phát triển (sử dụng volume mounts để hot-reload) và chế độ production (build code JS).
- Chạy Prisma client generation lúc build.
---
### 2. Dockerfile cho Frontend
#### [NEW] [Dockerfile](file:///home/locpham/travelplanning/frontend/Dockerfile)
- Sử dụng chiến lược Multi-stage build để tối ưu hóa dung lượng:
- **Stage 1 (Build)**: Cài đặt dependencies và build mã nguồn React/Vite thành thư mục tĩnh `dist`.
- **Stage 2 (Nginx)**: Copy thư mục `dist` vào container chạy Nginx để phục vụ các file tĩnh ở cổng `80` (phục vụ môi trường production).
- Hỗ trợ chạy Node.js trực tiếp cho môi trường phát triển để dùng Vite dev server và HMR.
---
### 3. Docker Compose cho Phát triển & Sửa Code (Hot-reload)
#### [NEW] [docker-compose.yml](file:///home/locpham/travelplanning/docker-compose.yml)
- Khởi tạo 4 services chính:
1. `postgres`: Cơ sở dữ liệu PostgreSQL 15, lưu trữ dữ liệu bền vững qua volume `pg_data`.
2. `redis`: Caching và WebSockets.
3. `backend`: Mount thư mục `backend/` vào container, chạy lệnh `npm run start:dev` để tự động reload khi sửa code trên máy host.
4. `frontend`: Mount thư mục `frontend/` vào container, chạy lệnh `npm run dev -- --host` để phục vụ Vite dev server hỗ trợ HMR (Hot Module Replacement).
- Đồng bộ hóa các volume ẩn `node_modules` để tránh xung đột hệ điều hành giữa máy host và container.
---
### 4. Docker Compose cho Triển khai lên Server (Production)
#### [NEW] [docker-compose.prod.yml](file:///home/locpham/travelplanning/docker-compose.prod.yml)
- Cấu hình tối ưu để triển khai lên server cloud:
- Builds production image cho `backend` và chạy trực tiếp file JS đã build (`dist/src/main.js`).
- Builds production image cho `frontend` sử dụng Nginx để phục vụ client, tối ưu hóa tốc độ tải trang và bảo mật.
- Tự động restart dịch vụ nếu gặp sự cố (`restart: always`).
---
## Verification Plan
### Automated Tests
- Kiểm tra tính hợp lệ của cấu hình docker-compose:
```bash
docker compose config
```
### Manual Verification
1. **Kiểm tra Môi trường Phát triển (Sửa code trực tiếp)**:
- Chạy lệnh khởi động môi trường dev:
```bash
docker compose up --build
```
- Truy cập giao diện tại `http://localhost:5173`.
- Sửa đổi một dòng văn bản trong frontend (ví dụ: nhãn nút ở `LandingPage.tsx`) hoặc backend và kiểm tra xem container có tự động tải lại (hot-reload) tức thì hay không.
2. **Kiểm tra Môi trường Production (Triển khai server)**:
- Chạy lệnh khởi động môi trường prod:
```bash
docker compose -f docker-compose.prod.yml up --build -d
```
- Xác nhận mọi service khởi chạy ngầm thành công.
- Truy cập ứng dụng qua cổng `80` (http://localhost) và xác nhận hoạt động bình thường.
+2
View File
@@ -0,0 +1,2 @@
VITE_BACKEND_URL=https://yotrip.labz.io.vn
VITE_GOOGLE_CLIENT_ID=639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com
+2
View File
@@ -0,0 +1,2 @@
VITE_BACKEND_URL=https://yotrip.labz.io.vn
VITE_GOOGLE_CLIENT_ID=639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com
+1 -1
View File
@@ -15,7 +15,7 @@ CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
FROM base AS build
ARG VITE_GOOGLE_CLIENT_ID
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
RUN npm install
RUN npm install --legacy-peer-deps
COPY . .
RUN npm run build
@@ -9,7 +9,10 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-camera')
implementation project(':capacitor-geolocation')
implementation project(':capacitor-local-notifications')
implementation project(':codetrix-studio-capacitor-google-auth')
}
@@ -1,14 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8" ?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
android:name=".MainActivity"
@@ -16,12 +15,10 @@
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
@@ -29,13 +26,26 @@
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-feature android:name="android.hardware.location.gps" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<queries>
<intent>
<action android:name="android.media.action.IMAGE_CAPTURE" />
</intent>
</queries>
</manifest>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 23 KiB

@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background>
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
</background>
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
</adaptive-icon>
@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background>
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
</background>
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 39 KiB

@@ -4,4 +4,5 @@
<string name="title_activity_main">YoTrip</string>
<string name="package_name">com.yotrip.app</string>
<string name="custom_url_scheme">com.yotrip.app</string>
<string name="server_client_id">639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com</string>
</resources>
+9
View File
@@ -24,6 +24,15 @@ allprojects {
}
}
subprojects {
configurations.all {
resolutionStrategy {
force "androidx.core:core-ktx:${rootProject.ext.androidxCoreVersion}"
force "androidx.core:core:${rootProject.ext.androidxCoreVersion}"
}
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
@@ -2,5 +2,14 @@
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../../node_modules/@capacitor/android/capacitor')
include ':capacitor-camera'
project(':capacitor-camera').projectDir = new File('../../node_modules/@capacitor/camera/android')
include ':capacitor-geolocation'
project(':capacitor-geolocation').projectDir = new File('../../node_modules/@capacitor/geolocation/android')
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../../node_modules/@capacitor/local-notifications/android')
include ':codetrix-studio-capacitor-google-auth'
project(':codetrix-studio-capacitor-google-auth').projectDir = new File('../../node_modules/@codetrix-studio/capacitor-google-auth/android')
+1 -1
View File
@@ -1,5 +1,5 @@
ext {
minSdkVersion = 23
minSdkVersion = 24
compileSdkVersion = 35
targetSdkVersion = 35
androidxActivityVersion = '1.9.2'
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

+12 -1
View File
@@ -3,7 +3,18 @@ import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.yotrip.app',
appName: 'YoTrip',
webDir: 'dist'
webDir: 'dist',
plugins: {
SplashScreen: {
launchShowDuration: 2000
},
GoogleAuth: {
scopes: ['profile', 'email'],
serverClientId: '639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com',
androidClientId: '639044397050-7hair384u6lle941h033trut1s3q47l4.apps.googleusercontent.com',
forceCodeForRefreshToken: true
}
}
};
export default config;
+16 -2
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#10b981" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<script src="https://accounts.google.com/gsi/client" async defer></script>
<title>Travel Planner</title>
@@ -21,8 +27,16 @@
<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-D0YTcNWm.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cnj-xh6m.css">
<script type="module" crossorigin src="/assets/index-1GoR2rZV.js"></script>
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CMxvf4Kt.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-others-CNhtyHGs.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-editor-BDwQQzB8.js">
<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-CLipHKhu.css">
</head>
<body>
<div id="root"></div>

Some files were not shown because too many files have changed in this diff Show More