Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f91fc7c5ac | |||
| 2f5ef424c8 | |||
| 41c8e67229 | |||
| 9f60efeb6d | |||
| 0152e7014d | |||
| 964a72514f | |||
| 7f426c8e46 | |||
| 414fac3e72 | |||
| e5606e64e5 | |||
| 464a4019f1 | |||
| a1bf0d2c08 | |||
| 277f647e40 | |||
| f39750af72 | |||
| ff4dc9bb48 | |||
| 175668da35 | |||
| 7ab1342690 | |||
| 2f4b24c41c | |||
| 1417f40dde | |||
| eaf79eaf8f | |||
| 3fa80b69bf | |||
| a7e569d9b4 | |||
| 3bc7c8a160 | |||
| 9fae6afaac | |||
| 614054f35d | |||
| 796487ef76 | |||
| 813b41933b | |||
| 59153cffc2 | |||
| 4ee46371fa | |||
| 400a3d098d | |||
| 5f49070a98 | |||
| accda67b22 | |||
| b5b6082d26 |
@@ -1,201 +0,0 @@
|
||||
# 🗺️ Hướng Dẫn Đóng Gói & Kiểm Thử Ứng Dụng Android (YoTrip)
|
||||
|
||||
Tài liệu này hướng dẫn chi tiết quy trình thiết lập môi trường máy tính Windows Local để biên dịch, kiểm thử dự án Frontend (React + Vite) qua Capacitor, kết nối tới hệ thống Docker Production (`https://yotrip.labz.io.vn`) và chuẩn bị phát hành lên Google Play Store.
|
||||
|
||||
---
|
||||
|
||||
## 📋 1. Điều Kiện Tiên Quyết (Môi Trường Windows)
|
||||
|
||||
Trước khi chạy lệnh, đảm bảo máy tính local đã cài đặt và cấu hình đầy đủ các công cụ sau:
|
||||
|
||||
* **Node.js:** Phiên bản v18 hoặc v20+.
|
||||
* **Java JDK:** Phiên bản 17 hoặc 21 (Temurin hoặc Microsoft OpenJDK).
|
||||
* Biến môi trường hệ thống: `JAVA_HOME` trỏ tới thư mục cài đặt JDK.
|
||||
* Biến `Path` hệ thống: Bổ sung `%JAVA_HOME%\bin`.
|
||||
* **Android Studio:** * Đã cài đặt **Android SDK**, **Android SDK Command-line Tools**.
|
||||
* Biến môi trường hệ thống: `ANDROID_HOME` trỏ tới `AppData\Local\Android\Sdk`.
|
||||
* Đã khởi tạo 1 thiết bị ảo (Android Simulator) qua *Virtual Device Manager*.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 2. Cấu Hình Mã Nguồn Frontend (Local)
|
||||
|
||||
### 2.1 Cấu hình file `.env.production`
|
||||
Tạo hoặc cập nhật file `.env.production` nằm tại thư mục gốc của `frontend/`:
|
||||
|
||||
```text
|
||||
VITE_BACKEND_URL=[https://yotrip.labz.io.vn](https://yotrip.labz.io.vn)
|
||||
|
||||
### 2.2 Cấu hình Axios / API Instance (src/api/axios.ts)
|
||||
Cập nhật logic baseURL để tự động phân tách môi trường chạy Web Dev (sử dụng Proxy của Vite) và môi trường chạy App Native (gọi trực tiếp URL tuyệt đối):
|
||||
|
||||
import axios from 'axios';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
const API = axios.create({
|
||||
baseURL: Capacitor.isNativePlatform()
|
||||
? import.meta.env.VITE_BACKEND_URL
|
||||
: '',
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
export default API;
|
||||
|
||||
## 3. Cấu Hình Nền Tảng Android Native
|
||||
|
||||
### 3.1 Thiết lập Biểu tượng Ứng dụng (App Icon)
|
||||
|
||||
Để sử dụng frontend/public/favicon.ico làm icon của app trên Android, chúng ta cần chuyển đổi nó sang định dạng .png độ phân giải cao và sử dụng công cụ của Capacitor để tự động tạo các kích thước cần thiết cho Android.
|
||||
|
||||
Chuẩn bị ảnh: Chuyển đổi file favicon.ico của bạn thành file .png (khuyên dùng độ phân giải ít nhất 1024x1024 pixel để có chất lượng tốt nhất trên các thiết bị đời mới) và lưu tên là icon-only.png.
|
||||
|
||||
Cài đặt công cụ: Chạy lệnh sau tại thư mục frontend/ để cài đặt công cụ quản lý tài nguyên của Capacitor:
|
||||
|
||||
Bash
|
||||
npm install @capacitor/assets --save-dev
|
||||
Khởi tạo thư mục: Tạo thư mục assets ở thư mục gốc của frontend/ (cùng cấp với src) và đặt file icon-only.png vào đó.
|
||||
|
||||
Bash
|
||||
mkdir assets
|
||||
# Sau đó di chuyển file icon-only.png của bạn vào thư mục assets/
|
||||
Tạo Icon: Chạy lệnh sau để tự động tạo và đặt các icon vào đúng vị trí trong dự án Android:
|
||||
|
||||
Bash
|
||||
npx capacitor-assets generate --android
|
||||
|
||||
### 3.2 File capacitor.config.json
|
||||
|
||||
Định danh chính xác gói ứng dụng (App ID) dùng để đăng ký trên Google Play Console:
|
||||
|
||||
{
|
||||
"appId": "com.yotrip.app",
|
||||
"appName": "YoTrip",
|
||||
"webDir": "dist",
|
||||
"plugins": {
|
||||
"SplashScreen": {
|
||||
"launchShowDuration": 2000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
### 3.3 File capacitor.config.json
|
||||
Định danh chính xác gói ứng dụng (App ID) dùng để đăng ký trên Google Play Console:
|
||||
|
||||
JSON
|
||||
{
|
||||
"appId": "com.yotrip.app",
|
||||
"appName": "YoTrip",
|
||||
"webDir": "dist",
|
||||
"plugins": {
|
||||
"SplashScreen": {
|
||||
"launchShowDuration": 2000
|
||||
}
|
||||
}
|
||||
}
|
||||
### 3.4 Cấu hình quyền trong AndroidManifest.xml
|
||||
Mở đường dẫn android/app/src/main/AndroidManifest.xml, thêm các quyền truy cập Internet, định vị GPS, và các quyền cần thiết cho tính năng chụp ảnh và lưu ảnh vào bộ nhớ máy:
|
||||
|
||||
XML
|
||||
<manifest xmlns:android="http://schemas.microsoft.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-feature android:name="android.hardware.location.gps" />
|
||||
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.media.action.IMAGE_CAPTURE" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
...
|
||||
android:usesCleartextTraffic="true"> ...
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
## 4. Tích Hợp Tính Năng Chụp Ảnh và Lưu Ảnh (React Code)
|
||||
Sử dụng Plugin của Capacitor để tích hợp trực tiếp vào code React của bạn.
|
||||
|
||||
Cài đặt Plugin Camera: Chạy lệnh sau tại thư mục frontend/:
|
||||
|
||||
Bash
|
||||
npm install @capacitor/camera
|
||||
npx cap update
|
||||
Ví dụ code: Dưới đây là cách implement tính năng chụp ảnh và tự động lưu ảnh gốc vào thư viện ảnh của điện thoại trong một React component (ví dụ src/components/PhotoTaker.tsx):
|
||||
|
||||
JavaScript
|
||||
import React, { useState } from 'react';
|
||||
import { IonButton, IonIcon, IonContent, IonPage } from '@ionic/react';
|
||||
import { camera } from 'ionicons/icons';
|
||||
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
|
||||
|
||||
const PhotoTaker: React.FC = () => {
|
||||
const [photoUri, setPhotoUri] = useState<string | undefined>();
|
||||
|
||||
const takeAndSavePhoto = async () => {
|
||||
try {
|
||||
const image = await Camera.getPhoto({
|
||||
quality: 90,
|
||||
allowEditing: false, // Giữ nguyên ảnh gốc, không qua chỉnh sửa
|
||||
resultType: CameraResultType.Uri,
|
||||
source: CameraSource.Camera, // Mở camera trực tiếp
|
||||
saveToGallery: true, // YÊU CẦU MỚI: Tự động lưu ảnh gốc vào thư viện điện thoại
|
||||
});
|
||||
|
||||
// Bạn có thể sử dụng image.webPath để hiển thị xem trước
|
||||
setPhotoUri(image.webPath);
|
||||
console.log('Ảnh đã được chụp và lưu tại:', image.path);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Lỗi khi chụp hoặc lưu ảnh:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonContent className="ion-padding">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<h1 className="text-xl font-bold">Tính năng Chụp ảnh</h1>
|
||||
|
||||
<IonButton onClick={takeAndSavePhoto} color="primary">
|
||||
<IonIcon slot="start" icon={camera}></IonIcon>
|
||||
Chụp và Lưu Ảnh Gốc
|
||||
</IonButton>
|
||||
|
||||
{photoUri && (
|
||||
<div className="mt-4 border p-2">
|
||||
<p>Xem trước ảnh vừa chụp:</p>
|
||||
<img src={photoUri} alt="Xem trước ảnh chụp" className="max-w-xs mt-2" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhotoTaker;
|
||||
|
||||
## 5. Quy Trình Biên Dịch & Kiểm Thử (Simulator)
|
||||
Mỗi lần cập nhật code giao diện ở máy local, chạy chuỗi lệnh sau tại Terminal của VS Code để đẩy app lên máy ảo:
|
||||
|
||||
# Bước 1: Cài đặt các thư viện phụ thuộc tại local
|
||||
npm install
|
||||
|
||||
# Bước 2: Build code React + Vite thành file tĩnh
|
||||
npm run build
|
||||
|
||||
# Bước 3: Đồng bộ mã nguồn tĩnh vào thư mục Android mã nguồn mở
|
||||
npx cap sync
|
||||
|
||||
# Bước 4: Khởi chạy máy ảo và nạp ứng dụng tự động
|
||||
npx cap run android
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
../.env
|
||||
@@ -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"]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -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 }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 868 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 645 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 994 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 844 KiB |
|
Before Width: | Height: | Size: 408 KiB |
|
Before Width: | Height: | Size: 422 KiB |
|
Before Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 662 KiB |
|
Before Width: | Height: | Size: 522 KiB |
|
Before Width: | Height: | Size: 644 KiB |
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,44 @@
|
||||
<!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-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-DQOuwrxr.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-pdf-IjT8x9OX.js">
|
||||
<!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" />
|
||||
<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>
|
||||
|
||||
<!-- 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-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-21UejyBs.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CLipHKhu.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -12,6 +12,15 @@ server {
|
||||
add_header Cache-Control "public, max-age=31536000";
|
||||
}
|
||||
|
||||
# Proxy downloaded APK files from backend
|
||||
location /downloads/ {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# JavaScript and CSS files - immutable caching
|
||||
location ~* \.(?:js|css)$ {
|
||||
root /usr/share/nginx/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",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "YoTrip - Khám phá chuyến đi",
|
||||
"short_name": "YoTrip",
|
||||
"description": "Chia sẻ ảnh du lịch và khám phá bản đồ cộng đồng",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#020617",
|
||||
"theme_color": "#10b981",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
],
|
||||
"categories": ["travel", "social", "maps"]
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* sw.js — YoTrip Service Worker
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* Strategy: Network-First with Cache-API fallback.
|
||||
*
|
||||
* Caches:
|
||||
* 1. App Shell (static assets): cached at install time
|
||||
* 2. OpenStreetMap tiles: cached dynamically on first fetch, served from
|
||||
* cache when offline — so previously visited map areas remain navigable
|
||||
*
|
||||
* Cache names are versioned so old caches get evicted on SW update.
|
||||
*/
|
||||
|
||||
const SHELL_CACHE = 'yotrip-shell-v1';
|
||||
const MAP_CACHE = 'yotrip-map-tiles-v1';
|
||||
|
||||
// App shell files to pre-cache at install
|
||||
const SHELL_URLS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/manifest.json',
|
||||
'/favicon.ico',
|
||||
];
|
||||
|
||||
// URL patterns for map tile providers
|
||||
const MAP_TILE_ORIGINS = [
|
||||
'tile.openstreetmap.org',
|
||||
'a.tile.openstreetmap.org',
|
||||
'b.tile.openstreetmap.org',
|
||||
'c.tile.openstreetmap.org',
|
||||
'tiles.stadiamaps.com',
|
||||
'server.arcgisonline.com',
|
||||
];
|
||||
|
||||
// ─── Install ──────────────────────────────────────────────────────────────
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[SW] Installing yotrip service worker…');
|
||||
event.waitUntil(
|
||||
caches.open(SHELL_CACHE).then((cache) => {
|
||||
return cache.addAll(SHELL_URLS).catch((err) => {
|
||||
// Non-fatal: some shell files may not exist in dev mode
|
||||
console.warn('[SW] Shell pre-cache partial failure (non-fatal):', err);
|
||||
});
|
||||
})
|
||||
);
|
||||
// Activate immediately without waiting for old tabs to close
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// ─── Activate ─────────────────────────────────────────────────────────────
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
const CURRENT_CACHES = [SHELL_CACHE, MAP_CACHE];
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) =>
|
||||
Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => !CURRENT_CACHES.includes(name))
|
||||
.map((name) => {
|
||||
console.log('[SW] Evicting stale cache:', name);
|
||||
return caches.delete(name);
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
// Take control of all open clients immediately
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// ─── Fetch ────────────────────────────────────────────────────────────────
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// 1. Map tile requests — Network-First, fall back to cache
|
||||
const isMapTile = MAP_TILE_ORIGINS.some((origin) => url.hostname.includes(origin));
|
||||
if (isMapTile) {
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
if (response && response.status === 200) {
|
||||
const clone = response.clone();
|
||||
caches.open(MAP_CACHE).then((cache) => {
|
||||
cache.put(event.request, clone);
|
||||
});
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(event.request))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. API calls — Network-Only (never cache API responses)
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
return; // Let the browser handle normally
|
||||
}
|
||||
|
||||
// 3. App shell navigation — Cache-First for HTML, then network
|
||||
if (event.request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
caches.match('/index.html').then((cached) => {
|
||||
return cached || fetch(event.request);
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Static assets (JS/CSS/images) — Cache-First
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
return cached || fetch(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { LandingPage } from './pages/LandingPage';
|
||||
import { ExploreMap } from './pages/ExploreMap';
|
||||
import { TourDetailPage } from './pages/TourDetailPage';
|
||||
@@ -6,12 +6,124 @@ import SignupPage from './pages/SignupPage';
|
||||
import { MyPhotosPage } from './pages/MyPhotosPage';
|
||||
import { MyNotePage } from './pages/MyNotePage';
|
||||
import { JoinTourPage } from './pages/JoinTourPage';
|
||||
import { MemberDashboard } from './pages/MemberDashboard';
|
||||
import { AdminDashboard } from './pages/AdminDashboard';
|
||||
import { ShareJourneyPage } from './pages/ShareJourneyPage';
|
||||
import { TourNavigationPage } from './pages/TourNavigationPage';
|
||||
import { ConfirmProvider } from './hooks/useConfirm';
|
||||
import { NotificationProvider } from './hooks/useNotification';
|
||||
import { NotificationProvider, useNotification } from './hooks/useNotification';
|
||||
import { io } from 'socket.io-client';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { initNetworkStatusListeners } from './utils/backgroundSync';
|
||||
|
||||
interface GlobalNotificationListenerProps {
|
||||
user: any;
|
||||
currentPage: string;
|
||||
currentTourId: string | null;
|
||||
}
|
||||
|
||||
const GlobalNotificationListener: React.FC<GlobalNotificationListenerProps> = ({ user, currentPage, currentTourId }) => {
|
||||
const notify = useNotification();
|
||||
|
||||
// Request browser Notification permission once on mount
|
||||
useEffect(() => {
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id) return;
|
||||
|
||||
const socketInstance = Capacitor.isNativePlatform()
|
||||
? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn')
|
||||
: io();
|
||||
|
||||
socketInstance.on('connect', () => {
|
||||
console.log('[WS] Global notification socket connected:', socketInstance.id);
|
||||
socketInstance.emit('joinUser', user.id);
|
||||
});
|
||||
|
||||
socketInstance.on('tourMessageNotification', (data: any) => {
|
||||
// Dispatch custom window event
|
||||
window.dispatchEvent(new CustomEvent('app:tourMessageNotification', { detail: data }));
|
||||
|
||||
// Check if user is actively viewing this specific tour chat
|
||||
const isViewingThisTourChat = currentPage === 'tourDetail' && currentTourId === data.tourId && (window as any).activeTourChatTab;
|
||||
|
||||
if (!isViewingThisTourChat) {
|
||||
notify({
|
||||
title: `Tin nhắn mới trong tour "${data.tourTitle}"`,
|
||||
message: `${data.senderName}: "${data.content.substring(0, 30)}${data.content.length > 30 ? '...' : ''}"`,
|
||||
type: 'success'
|
||||
});
|
||||
|
||||
// Show push notification on desktop browser
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
try {
|
||||
new Notification(`Tin nhắn mới trong tour "${data.tourTitle}"`, {
|
||||
body: `${data.senderName}: ${data.content}`,
|
||||
icon: '/favicon.ico'
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('System Notification error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socketInstance.on('messageReceived', (message: any) => {
|
||||
// Dispatch custom window event
|
||||
window.dispatchEvent(new CustomEvent('app:messageReceived', { detail: message }));
|
||||
|
||||
// Check if user is actively chatting with the sender
|
||||
const isChattingWithSender = currentPage === 'dashboard' && (window as any).activeChatUserId === message.senderId;
|
||||
|
||||
if (!isChattingWithSender) {
|
||||
notify({
|
||||
title: 'Tin nhắn mới',
|
||||
message: `${message.sender?.name || 'Ai đó'} gửi: "${message.content.substring(0, 30)}${message.content.length > 30 ? '...' : ''}"`,
|
||||
type: 'success'
|
||||
});
|
||||
|
||||
// Show push notification on desktop browser
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
try {
|
||||
new Notification(`Tin nhắn mới từ ${message.sender?.name || 'Thành viên'}`, {
|
||||
body: message.content,
|
||||
icon: '/favicon.ico'
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('System Notification error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socketInstance.on('connectionAccepted', (data: any) => {
|
||||
window.dispatchEvent(new CustomEvent('app:connectionAccepted', { detail: data }));
|
||||
notify({
|
||||
title: 'Kết nối mới',
|
||||
message: `${data.acceptedByName} đã chấp nhận yêu cầu kết nối của bạn.`,
|
||||
type: 'success'
|
||||
});
|
||||
});
|
||||
|
||||
socketInstance.on('joinRequestAccepted', (data: any) => {
|
||||
window.dispatchEvent(new CustomEvent('app:joinRequestAccepted', { detail: data }));
|
||||
notify({
|
||||
title: 'Yêu cầu tham gia được duyệt',
|
||||
message: `Yêu cầu tham gia hành trình "${data.tourTitle}" của bạn đã được chấp nhận!`,
|
||||
type: 'success'
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
socketInstance.disconnect();
|
||||
};
|
||||
}, [user?.id, currentPage, currentTourId]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
function App() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -27,9 +139,32 @@ function App() {
|
||||
);
|
||||
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
|
||||
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
|
||||
const [previousPage, setPreviousPage] = useState<'explore' | 'dashboard' | 'landing'>('explore');
|
||||
const [previousPage, setPreviousPage] = useState<'explore' | 'landing'>('explore');
|
||||
const [navigationPayload, setNavigationPayload] = useState<{ tourId: string; origin: { lat: number; lng: number }; destination: { lat: number; lng: number; name: string }; tourTitle: string } | null>(null);
|
||||
|
||||
// ── Service Worker registration ─────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js', { scope: '/' })
|
||||
.then((reg) => console.log('[SW] Registered, scope:', reg.scope))
|
||||
.catch((err) => console.warn('[SW] Registration failed:', err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Background sync network listeners ────────────────────────────────────
|
||||
// refreshLanding is a stable callback that fires fetchPublicPhotos inside LandingPage.
|
||||
// We use a window CustomEvent as a lightweight cross-component bus.
|
||||
const triggerMapRefresh = useCallback(() => {
|
||||
window.dispatchEvent(new CustomEvent('app:offlineSyncComplete'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = initNetworkStatusListeners(triggerMapRefresh);
|
||||
return cleanup;
|
||||
}, [triggerMapRefresh]);
|
||||
|
||||
// ── Auth + routing bootstrap ─────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const viewTourId = params.get('viewTour');
|
||||
@@ -49,7 +184,6 @@ function App() {
|
||||
const token = localStorage.getItem('token');
|
||||
const guestToken = localStorage.getItem('guest_token');
|
||||
const storedUser = localStorage.getItem('user');
|
||||
const storedGuestUser = localStorage.getItem('guest_user');
|
||||
|
||||
let loggedInUser = null;
|
||||
if (token && storedUser) {
|
||||
@@ -79,7 +213,7 @@ function App() {
|
||||
if (loggedInUser.isAdmin) {
|
||||
setCurrentPage('admin');
|
||||
} else {
|
||||
setCurrentPage('dashboard');
|
||||
setCurrentPage('landing');
|
||||
}
|
||||
} else {
|
||||
setCurrentPage('landing');
|
||||
@@ -98,14 +232,7 @@ function App() {
|
||||
// Nếu là admin, chuyển đến admin dashboard
|
||||
setCurrentPage('admin');
|
||||
} else {
|
||||
// Only set to dashboard if this is a real user (has token), not a guest
|
||||
const token = localStorage.getItem('token');
|
||||
const guestToken = localStorage.getItem('guest_token');
|
||||
if (token && !guestToken) {
|
||||
setCurrentPage('dashboard');
|
||||
} else {
|
||||
setCurrentPage('landing');
|
||||
}
|
||||
// Keep on current page after successful login, modal will close
|
||||
}
|
||||
};
|
||||
|
||||
@@ -116,10 +243,11 @@ function App() {
|
||||
setCurrentPage('landing');
|
||||
};
|
||||
|
||||
const handleViewTour = (tourId: string, fromPage?: 'explore' | 'dashboard') => {
|
||||
|
||||
const handleViewTour = (tourId: string, fromPage?: 'explore') => {
|
||||
setCurrentTourId(tourId);
|
||||
setIsPublicTourView(false); // Không phải chế độ công khai nếu đến từ bản đồ khám phá
|
||||
setPreviousPage(fromPage || (currentPage === 'dashboard' ? 'dashboard' : 'explore'));
|
||||
setPreviousPage(fromPage || 'explore');
|
||||
setCurrentPage('tourDetail');
|
||||
};
|
||||
|
||||
@@ -184,30 +312,11 @@ function App() {
|
||||
};
|
||||
|
||||
const handleBackFromExplore = () => {
|
||||
// Only allow real users (with token, not guest_token)
|
||||
const token = localStorage.getItem('token');
|
||||
const guestToken = localStorage.getItem('guest_token');
|
||||
const isRealUser = token && !guestToken;
|
||||
|
||||
if (user && isRealUser) {
|
||||
setCurrentPage('dashboard');
|
||||
} else {
|
||||
setCurrentPage('landing');
|
||||
}
|
||||
setCurrentPage('landing');
|
||||
};
|
||||
|
||||
const handleGoToDashboard = () => {
|
||||
// Only allow real users (with token, not guest_token)
|
||||
const token = localStorage.getItem('token');
|
||||
const guestToken = localStorage.getItem('guest_token');
|
||||
const isRealUser = token && !guestToken;
|
||||
|
||||
if (user && isRealUser) {
|
||||
setPreviousPage('explore');
|
||||
setCurrentPage('dashboard');
|
||||
} else {
|
||||
setCurrentPage('landing');
|
||||
}
|
||||
const handleGoToDashboard = (_tab?: 'tours' | 'connections' | 'photos' | 'chats') => {
|
||||
// Deprecated dashboard redirect: keeping empty function to satisfy interface prop requirements
|
||||
};
|
||||
|
||||
const handleGoToHome = () => {
|
||||
@@ -223,37 +332,19 @@ function App() {
|
||||
return (
|
||||
<ConfirmProvider>
|
||||
<NotificationProvider>
|
||||
{(() => {
|
||||
{user && (
|
||||
<GlobalNotificationListener
|
||||
user={user}
|
||||
currentPage={currentPage}
|
||||
currentTourId={currentTourId}
|
||||
/>
|
||||
)}
|
||||
{(() => {
|
||||
if (currentPage === 'admin') {
|
||||
return (
|
||||
<AdminDashboard
|
||||
user={user}
|
||||
onNavigate={setCurrentPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPage === 'dashboard') {
|
||||
// SECURITY: Prevent any guest from accessing dashboard
|
||||
const guestToken = localStorage.getItem('guest_token');
|
||||
if (guestToken) {
|
||||
console.warn('[App] Guest user attempted to access dashboard - forcing redirect to landing');
|
||||
setCurrentPage('landing');
|
||||
return (
|
||||
<LandingPage
|
||||
onLoginSuccess={handleLoginSuccess}
|
||||
onGoToSignup={() => setCurrentPage('signup')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MemberDashboard
|
||||
user={user}
|
||||
onLogout={handleLogout}
|
||||
onExploreTours={() => setCurrentPage('explore')}
|
||||
onViewTour={handleViewTour}
|
||||
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
|
||||
onNavigate={(page) => setCurrentPage(page as any)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -265,7 +356,10 @@ function App() {
|
||||
onBack={handleBackFromTourDetail}
|
||||
isPublicView={isPublicTourView}
|
||||
onOpenNotes={() => setCurrentPage('notes')}
|
||||
onOpenNavigationPage={handleOpenNavigationPage}
|
||||
onOpenNavigationPage={(routeData) => handleOpenNavigationPage({
|
||||
tourId: currentTourId!,
|
||||
...routeData
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -294,9 +388,9 @@ function App() {
|
||||
onLogout={handleLogout}
|
||||
user={user}
|
||||
onViewTour={handleViewTour}
|
||||
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
|
||||
onLoginSuccess={handleLoginSuccess}
|
||||
onGoToDashboard={handleGoToDashboard}
|
||||
onOpenNavigation={handleOpenNavigationPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -338,7 +432,18 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
return <LandingPage onContinue={() => setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />;
|
||||
return (
|
||||
<LandingPage
|
||||
onContinue={() => setCurrentPage('explore')}
|
||||
onGoToSignup={() => setCurrentPage('signup')}
|
||||
onGoToMap={() => setCurrentPage('explore')}
|
||||
onLoginSuccess={handleLoginSuccess}
|
||||
user={user}
|
||||
onLogout={handleLogout}
|
||||
onGoToDashboard={handleGoToDashboard}
|
||||
onOpenNavigation={handleOpenNavigationPage}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</NotificationProvider>
|
||||
</ConfirmProvider>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { X, Send, MessageSquare, User, Loader2, Trash2 } from 'lucide-react';
|
||||
import { io } from 'socket.io-client';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { BACKEND_URL } from '@/utils/backendEndpoint';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
|
||||
@@ -71,7 +72,7 @@ export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, loc
|
||||
|
||||
// Lắng nghe bình luận mới qua Proxy (không cần hardcode URL)
|
||||
const socket = Capacitor.isNativePlatform()
|
||||
? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn')
|
||||
? io(BACKEND_URL)
|
||||
: io();
|
||||
socket.emit('joinTour', 'global'); // Hoặc logic join cụ thể
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Compass, Map, Image as ImageIcon, Settings, ShieldAlert, Users, LogOut, LogIn, Globe, Sun, Moon } from 'lucide-react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
|
||||
interface MapProfileDropdownProps {
|
||||
user: any;
|
||||
onLogout?: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onOpenCreateTour: () => void;
|
||||
onOpenReport: () => void;
|
||||
onOpenLogin: () => void;
|
||||
onOpenMyPhotos: () => void;
|
||||
onOpenMyTours: () => void;
|
||||
onOpenFriends: () => void;
|
||||
onOpenAdmin?: () => void;
|
||||
}
|
||||
|
||||
export const MapProfileDropdown: React.FC<MapProfileDropdownProps> = ({
|
||||
user,
|
||||
onLogout,
|
||||
onOpenSettings,
|
||||
onOpenCreateTour,
|
||||
onOpenReport,
|
||||
onOpenLogin,
|
||||
onOpenMyPhotos,
|
||||
onOpenMyTours,
|
||||
onOpenFriends,
|
||||
onOpenAdmin,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { lang, changeLanguage } = useTranslation();
|
||||
const { theme, changeTheme } = useTheme();
|
||||
|
||||
const guestToken = localStorage.getItem('guest_token');
|
||||
const isAuthenticated = !!user && !guestToken;
|
||||
|
||||
const renderInitialsAvatar = (name: string) => {
|
||||
const initials = name
|
||||
? name.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase()
|
||||
: 'U';
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center bg-indigo-600 text-white font-bold text-sm">
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleItemClick = (callback: () => void) => {
|
||||
setIsOpen(false);
|
||||
callback();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative inline-block text-left select-none pointer-events-auto">
|
||||
{/* TRIGGER BUTTON */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-11 h-11 rounded-full border-2 border-white dark:border-slate-800 bg-slate-800 flex items-center justify-center overflow-hidden shadow-xl active:scale-95 transition-all duration-150 cursor-pointer"
|
||||
title="Menu cá nhân"
|
||||
>
|
||||
{isAuthenticated ? (
|
||||
user?.avatar ? (
|
||||
<img src={user.avatar} alt="Avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
renderInitialsAvatar(user?.name || 'User')
|
||||
)
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-slate-700 text-slate-300 font-bold text-sm">
|
||||
G
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* DROPDOWN MENU / MOBILE BOTTOM SHEET LAYER */}
|
||||
{isOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[999998] bg-black/50 sm:bg-transparent"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute right-0 top-14 z-[999999] w-64 bg-slate-900 border border-slate-800 rounded-2xl p-2 shadow-2xl flex flex-col space-y-0.5 text-xs text-slate-200"
|
||||
style={{
|
||||
maxHeight: '75vh',
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.6)'
|
||||
}}
|
||||
>
|
||||
|
||||
{isAuthenticated ? (
|
||||
<div className="flex flex-col space-y-1">
|
||||
{/* User info header */}
|
||||
<div className="px-4 py-2 border-b border-slate-800/60 mb-1 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full overflow-hidden shrink-0 border border-slate-700">
|
||||
{user?.avatar ? (
|
||||
<img src={user.avatar} alt="Avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
renderInitialsAvatar(user?.name || 'User')
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-bold text-white truncate text-sm">{user?.name}</span>
|
||||
<span className="text-[10px] text-slate-400 truncate">{user?.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extra System Admin option */}
|
||||
{user?.isAdmin && (
|
||||
<button
|
||||
onClick={() => handleItemClick(onOpenAdmin || (() => {}))}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 bg-blue-950/20 hover:bg-slate-800 rounded-lg text-left transition-colors font-bold text-blue-400 cursor-pointer"
|
||||
>
|
||||
<Settings className="w-4 h-4 shrink-0 text-blue-400" /> Quản trị hệ thống
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 1. Tạo tour */}
|
||||
<button
|
||||
onClick={() => handleItemClick(onOpenCreateTour)}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors font-semibold cursor-pointer"
|
||||
>
|
||||
<Compass className="w-4 h-4 text-amber-400 shrink-0" /> Tạo tour
|
||||
</button>
|
||||
|
||||
{/* 2. Hành trình của tôi */}
|
||||
<button
|
||||
onClick={() => handleItemClick(onOpenMyTours)}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors font-semibold cursor-pointer"
|
||||
>
|
||||
<Map className="w-4 h-4 text-blue-400 shrink-0" /> Hành trình của tôi
|
||||
</button>
|
||||
|
||||
{/* 3. Thư viện ảnh */}
|
||||
<button
|
||||
onClick={() => handleItemClick(onOpenMyPhotos)}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors font-semibold cursor-pointer"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4 text-emerald-400 shrink-0" /> Thư viện ảnh
|
||||
</button>
|
||||
|
||||
{/* 4. Cài đặt */}
|
||||
<button
|
||||
onClick={() => handleItemClick(onOpenSettings)}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors font-semibold cursor-pointer"
|
||||
>
|
||||
<Settings className="w-4 h-4 text-slate-400 shrink-0" /> Cài đặt
|
||||
</button>
|
||||
|
||||
{/* 5. Báo cáo vi phạm */}
|
||||
<button
|
||||
onClick={() => handleItemClick(onOpenReport)}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors font-semibold cursor-pointer"
|
||||
>
|
||||
<ShieldAlert className="w-4 h-4 text-rose-400 shrink-0" /> Báo cáo vi phạm
|
||||
</button>
|
||||
|
||||
{/* STRICT VISUAL DIVIDER LINE */}
|
||||
<div className="border-b border-slate-800 my-1 mx-2" />
|
||||
|
||||
{/* 6. Danh sách bạn bè */}
|
||||
<button
|
||||
onClick={() => handleItemClick(onOpenFriends)}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors font-semibold cursor-pointer"
|
||||
>
|
||||
<Users className="w-4 h-4 text-indigo-400 shrink-0" /> Danh sách bạn bè
|
||||
</button>
|
||||
|
||||
{/* 7. Đăng xuất */}
|
||||
<button
|
||||
onClick={() => handleItemClick(onLogout || (() => {}))}
|
||||
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 cursor-pointer"
|
||||
>
|
||||
<LogOut className="w-4 h-4 shrink-0 text-rose-500" /> Đăng xuất
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div className="px-4 py-1.5 text-[10px] font-black uppercase tracking-wider text-slate-500">Tùy chỉnh nhanh</div>
|
||||
|
||||
<div className="px-3 py-2 flex flex-col gap-3 bg-slate-950/40 rounded-xl m-1 border border-slate-850">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-semibold flex items-center gap-1.5 text-slate-400">
|
||||
<Globe className="w-3.5 h-3.5 text-indigo-400" /> Ngôn ngữ:
|
||||
</span>
|
||||
<select
|
||||
value={lang}
|
||||
onChange={(e) => changeLanguage(e.target.value as any)}
|
||||
className="bg-slate-800 border border-slate-700 rounded-lg px-2 py-1 text-white text-[11px] font-bold focus:outline-none cursor-pointer"
|
||||
>
|
||||
<option value="vi">Tiếng Việt</option>
|
||||
<option value="en">English</option>
|
||||
<option value="zh">中文</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-semibold flex items-center gap-1.5 text-slate-400">
|
||||
{theme === 'light' ? (
|
||||
<Sun className="w-3.5 h-3.5 text-amber-500" />
|
||||
) : (
|
||||
<Moon className="w-3.5 h-3.5 text-indigo-400" />
|
||||
)}
|
||||
Giao diện:
|
||||
</span>
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => changeTheme(e.target.value as any)}
|
||||
className="bg-slate-800 border border-slate-700 rounded-lg px-2 py-1 text-white text-[11px] font-bold focus:outline-none cursor-pointer"
|
||||
>
|
||||
<option value="dark">Tối</option>
|
||||
<option value="light">Sáng</option>
|
||||
<option value="system">Hệ thống</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[1px] bg-slate-850 my-1 mx-2" />
|
||||
|
||||
<button
|
||||
onClick={() => handleItemClick(onOpenLogin)}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 text-blue-400 hover:bg-slate-800 rounded-xl text-left font-black transition-colors cursor-pointer"
|
||||
>
|
||||
<LogIn className="w-4 h-4 shrink-0" /> Đăng ký / Đăng nhập
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { X, Image as ImageIcon, Loader2, Calendar, Download, Eye, MapPin } from 'lucide-react';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
|
||||
interface MyPhotosModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: any;
|
||||
}
|
||||
|
||||
export const MyPhotosModal: React.FC<MyPhotosModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
user,
|
||||
}) => {
|
||||
const notify = useNotification();
|
||||
|
||||
const [photos, setPhotos] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && user) {
|
||||
const loadPhotos = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch('/api/v1/users/me/photos', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPhotos(data);
|
||||
} else {
|
||||
throw new Error('Không thể tải thư viện ảnh.');
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
message: e.message || 'Không thể tải ảnh.',
|
||||
type: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadPhotos();
|
||||
}
|
||||
}, [isOpen, user]);
|
||||
|
||||
if (!isOpen || !user) return null;
|
||||
|
||||
const handleDownload = async (url: string, filename: string) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = blobUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
notify({
|
||||
title: 'Lỗi tải về',
|
||||
message: 'Không thể tải trực tiếp ảnh xuống thiết bị.',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1000000] bg-black/70 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-4 pointer-events-auto">
|
||||
<div className="bg-slate-900 w-full sm:max-w-3xl h-[92vh] sm:h-[80vh] rounded-t-3xl sm:rounded-2xl flex flex-col overflow-hidden text-slate-200 text-xs border border-slate-800/80 shadow-2xl animate-in slide-in-from-bottom sm:zoom-in-95 duration-300">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-slate-800/60 flex justify-between items-center shrink-0">
|
||||
<span className="font-bold text-sm text-white flex items-center gap-2">
|
||||
<ImageIcon className="w-5 h-5 text-emerald-400" /> Thư viện ảnh
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content body */}
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{isLoading ? (
|
||||
<div className="h-full flex items-center justify-center flex-col gap-2 py-20">
|
||||
<Loader2 className="w-8 h-8 text-emerald-400 animate-spin" />
|
||||
<span className="text-slate-400 font-medium">Đang tải thư viện ảnh của bạn...</span>
|
||||
</div>
|
||||
) : photos.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center py-20 px-6 gap-3">
|
||||
<div className="w-16 h-16 bg-slate-850 rounded-full flex items-center justify-center border border-slate-800">
|
||||
<ImageIcon className="w-8 h-8 text-slate-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-white text-sm">Thư viện ảnh trống</div>
|
||||
<p className="text-slate-500 mt-1 max-w-xs mx-auto">Bạn chưa đăng tải bức ảnh nào trong các chuyến đi của mình.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{photos.map((photo) => {
|
||||
const dateStr = photo.capturedAt ? new Date(photo.capturedAt).toLocaleDateString('vi-VN') : '';
|
||||
return (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="group relative aspect-square rounded-2xl overflow-hidden bg-slate-950 border border-slate-850 hover:border-slate-700 shadow-lg transition-all flex flex-col"
|
||||
>
|
||||
<img
|
||||
src={photo.imageUrl}
|
||||
alt="Gallery Asset"
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
|
||||
{/* Hover controls overlay */}
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 flex flex-col justify-between p-3.5 transition-opacity duration-250 z-10">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<button
|
||||
onClick={() => setSelectedPhoto(photo)}
|
||||
className="p-2 bg-slate-900/85 hover:bg-indigo-650 text-white rounded-xl transition-colors cursor-pointer"
|
||||
title="Xem phóng to"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownload(photo.originalUrl || photo.imageUrl, `yotrip-photo-${photo.id}.jpg`)}
|
||||
className="p-2 bg-slate-900/85 hover:bg-emerald-650 text-white rounded-xl transition-colors cursor-pointer"
|
||||
title="Tải về tệp gốc"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
{photo.tour?.title && (
|
||||
<div className="text-[10px] font-bold text-indigo-300 truncate flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3 shrink-0" />
|
||||
{photo.tour.title}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[9px] text-slate-400 mt-0.5 flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3 shrink-0" />
|
||||
{dateStr}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Preview overlay */}
|
||||
{selectedPhoto && (
|
||||
<div className="fixed inset-0 z-[2000000] bg-black/95 flex flex-col justify-between p-4 pointer-events-auto">
|
||||
{/* Close trigger top bar */}
|
||||
<div className="flex justify-between items-center w-full pb-3 border-b border-slate-900">
|
||||
<div className="text-white font-bold text-xs truncate">
|
||||
{selectedPhoto.tour?.title || 'Xem ảnh'}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedPhoto(null)}
|
||||
className="p-2 bg-slate-900 hover:bg-slate-800 text-slate-400 hover:text-white rounded-xl cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Photo view */}
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<img
|
||||
src={selectedPhoto.originalUrl || selectedPhoto.imageUrl}
|
||||
alt="Fullscreen Preview"
|
||||
className="max-w-full max-h-[75vh] object-contain rounded-xl shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action bottom bar */}
|
||||
<div className="flex justify-center items-center py-4 border-t border-slate-900">
|
||||
<button
|
||||
onClick={() => handleDownload(selectedPhoto.originalUrl || selectedPhoto.imageUrl, `yotrip-photo-${selectedPhoto.id}.jpg`)}
|
||||
className="flex items-center gap-2 px-5 py-3 bg-emerald-650 hover:bg-emerald-600 font-bold text-white rounded-xl shadow-lg transition-all active:scale-95 cursor-pointer"
|
||||
>
|
||||
<Download className="w-4 h-4" /> Tải về tệp gốc (.jpg)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { X, Calendar, Users, Navigation, Trash2, Loader2, Compass } from 'lucide-react';
|
||||
import { useTourStore } from '../store/useTourStore';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
|
||||
interface MyToursModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: any;
|
||||
onViewTour: (tourId: string) => void;
|
||||
}
|
||||
|
||||
export const MyToursModal: React.FC<MyToursModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
user,
|
||||
onViewTour,
|
||||
}) => {
|
||||
const notify = useNotification();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const publicTours = useTourStore(state => state.publicTours);
|
||||
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
|
||||
const deleteTour = useTourStore(state => state.deleteTour);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isDeletingId, setIsDeletingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const loadTours = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await fetchPublicTours();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadTours();
|
||||
}
|
||||
}, [isOpen, fetchPublicTours]);
|
||||
|
||||
if (!isOpen || !user) return null;
|
||||
|
||||
// Filter tours where user is participant
|
||||
const myTours = publicTours.filter(tour =>
|
||||
tour.participants?.some((p: any) => p.userId === user?.id)
|
||||
);
|
||||
|
||||
const handleDelete = async (tourId: string, tourTitle: string) => {
|
||||
const ok = await confirm({
|
||||
title: 'Xóa chuyến đi?',
|
||||
message: `Bạn có chắc chắn muốn xóa chuyến đi "${tourTitle}" không? Hành động này không thể hoàn tác.`,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
setIsDeletingId(tourId);
|
||||
try {
|
||||
await deleteTour(tourId);
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: 'Đã xóa chuyến đi thành công.',
|
||||
type: 'success',
|
||||
});
|
||||
} catch (err: any) {
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
message: err.message || 'Xóa chuyến đi thất bại.',
|
||||
type: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1000000] bg-black/70 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-4 pointer-events-auto">
|
||||
<div className="bg-slate-900 w-full sm:max-w-xl h-[92vh] sm:h-[80vh] rounded-t-3xl sm:rounded-2xl flex flex-col overflow-hidden text-slate-200 text-xs border border-slate-800/80 shadow-2xl animate-in slide-in-from-bottom sm:zoom-in-95 duration-300">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-slate-800/60 flex justify-between items-center shrink-0">
|
||||
<span className="font-bold text-sm text-white flex items-center gap-2">
|
||||
<Compass className="w-5 h-5 text-indigo-400" /> Hành trình của tôi
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content body */}
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{isLoading ? (
|
||||
<div className="h-full flex items-center justify-center flex-col gap-2 py-20">
|
||||
<Loader2 className="w-8 h-8 text-indigo-400 animate-spin" />
|
||||
<span className="text-slate-400 font-medium">Đang tải danh sách chuyến đi...</span>
|
||||
</div>
|
||||
) : myTours.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center py-20 px-6 gap-3">
|
||||
<div className="w-16 h-16 bg-slate-850 rounded-full flex items-center justify-center border border-slate-800">
|
||||
<Compass className="w-8 h-8 text-slate-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-white text-sm">Chưa có hành trình nào</div>
|
||||
<p className="text-slate-500 mt-1 max-w-xs mx-auto">Bạn chưa tham gia chuyến đi nào. Hãy nhấp Tạo Tour mới để bắt đầu hành trình của riêng mình!</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3.5">
|
||||
{myTours.map((tour) => {
|
||||
const isOwner = tour.participants?.some(
|
||||
(p: any) => p.userId === user?.id && p.role === 'OWNER'
|
||||
);
|
||||
const startDateStr = tour.startDate ? new Date(tour.startDate).toLocaleDateString('vi-VN') : '';
|
||||
const endDateStr = tour.endDate ? new Date(tour.endDate).toLocaleDateString('vi-VN') : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tour.id}
|
||||
className="p-4 bg-slate-950/40 border border-slate-850 hover:border-slate-750 rounded-2xl flex flex-col gap-3 transition-all"
|
||||
>
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-bold text-white text-sm truncate">{tour.title}</h4>
|
||||
<div className="flex items-center gap-4 text-[10px] text-slate-400 mt-1 font-semibold">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||
{startDateStr} - {endDateStr}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||
{tour.participants?.length || 0} thành viên
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end items-center gap-2 border-t border-slate-850 pt-3">
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={() => handleDelete(tour.id, tour.title)}
|
||||
disabled={isDeletingId === tour.id}
|
||||
className="p-2 text-rose-400 hover:bg-rose-500/10 rounded-xl transition-all cursor-pointer shrink-0 disabled:opacity-50"
|
||||
title="Xóa chuyến đi"
|
||||
>
|
||||
{isDeletingId === tour.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onViewTour(tour.id);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-indigo-650 hover:bg-indigo-600 font-bold text-white rounded-xl transition-all active:scale-95 cursor-pointer shadow-md shadow-indigo-900/10"
|
||||
>
|
||||
<Navigation className="w-3.5 h-3.5" /> Xem hành trình
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,350 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Camera, Loader2, Check } from 'lucide-react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
|
||||
interface ProfileSettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: any;
|
||||
onSaveSuccess?: (updatedUser: any) => void;
|
||||
}
|
||||
|
||||
export const ProfileSettingsModal: React.FC<ProfileSettingsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
user,
|
||||
onSaveSuccess,
|
||||
}) => {
|
||||
const { lang, changeLanguage } = useTranslation();
|
||||
const { theme, changeTheme } = useTheme();
|
||||
const notify = useNotification();
|
||||
|
||||
// Form States
|
||||
const [name, setName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [address, setAddress] = useState('');
|
||||
const [avatar, setAvatar] = useState('');
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
|
||||
// Status States
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setName(user.name || '');
|
||||
setPhone(user.phone || '');
|
||||
setAddress(user.address || '');
|
||||
setAvatar(user.avatar || '');
|
||||
}
|
||||
}, [user, isOpen]);
|
||||
|
||||
if (!isOpen || !user) return null;
|
||||
|
||||
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch('/api/v1/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Không thể tải ảnh lên.');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setAvatar(data.url);
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: 'Đã cập nhật ảnh đại diện xem trước.',
|
||||
type: 'success',
|
||||
});
|
||||
} catch (err: any) {
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
message: err.message || 'Tải ảnh thất bại.',
|
||||
type: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
message: 'Tên hiển thị không được để trống.',
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
const updateData: any = {
|
||||
name,
|
||||
phone,
|
||||
address,
|
||||
avatar,
|
||||
};
|
||||
|
||||
if (currentPassword && newPassword) {
|
||||
// Typically the backend might check the old password, let's pass it
|
||||
updateData.password = newPassword;
|
||||
// We pass both or verify password on backend
|
||||
} else if (newPassword && !currentPassword) {
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
message: 'Vui lòng cung cấp mật khẩu hiện tại để đổi mật khẩu.',
|
||||
type: 'error',
|
||||
});
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch(`/api/v1/users/${user.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(updateData),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json();
|
||||
throw new Error(errData.message || 'Không thể lưu thay đổi.');
|
||||
}
|
||||
|
||||
const updatedUser = await res.json();
|
||||
|
||||
// Update local storage user object
|
||||
const storedUser = localStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
const userObj = JSON.parse(storedUser);
|
||||
const mergedUser = { ...userObj, ...updatedUser };
|
||||
localStorage.setItem('user', JSON.stringify(mergedUser));
|
||||
if (onSaveSuccess) {
|
||||
onSaveSuccess(mergedUser);
|
||||
}
|
||||
}
|
||||
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: 'Thông tin hồ sơ cá nhân đã được lưu thành công.',
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
// Clear password inputs
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
message: err.message || 'Lưu thay đổi thất bại.',
|
||||
type: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to render initials fallback
|
||||
const renderInitialsAvatar = (name: string) => {
|
||||
const initials = name
|
||||
? name.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase()
|
||||
: 'U';
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center bg-indigo-600 text-white font-bold text-2xl">
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1000000] bg-black/70 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-4 pointer-events-auto">
|
||||
<div className="bg-slate-900 w-full sm:max-w-xl h-[92vh] sm:h-auto max-h-[92vh] sm:max-h-[85vh] rounded-t-3xl sm:rounded-2xl flex flex-col overflow-hidden text-slate-200 text-xs border border-slate-800/80 shadow-2xl animate-in slide-in-from-bottom sm:zoom-in-95 duration-300">
|
||||
|
||||
{/* Header bar */}
|
||||
<div className="px-5 py-4 border-b border-slate-800/60 flex justify-between items-center shrink-0">
|
||||
<span className="font-bold text-sm text-white">Chỉnh sửa hồ sơ cá nhân</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Form Workspace Canvas */}
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-5">
|
||||
|
||||
{/* Avatar Upload Container Component */}
|
||||
<div className="flex flex-col items-center justify-center space-y-2">
|
||||
<div className="relative w-20 h-20 rounded-full border-2 border-slate-700 overflow-hidden bg-slate-850 group shadow-lg">
|
||||
{isUploading ? (
|
||||
<div className="w-full h-full flex items-center justify-center bg-slate-800/85">
|
||||
<Loader2 className="w-6 h-6 text-indigo-400 animate-spin" />
|
||||
</div>
|
||||
) : avatar ? (
|
||||
<img src={avatar} alt="Profile Avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
renderInitialsAvatar(name || 'User')
|
||||
)}
|
||||
|
||||
<label className="absolute inset-0 bg-black/60 flex flex-col items-center justify-center opacity-0 group-hover:opacity-100 cursor-pointer transition-opacity duration-200">
|
||||
<Camera className="w-5 h-5 text-white" />
|
||||
<span className="text-[8px] text-white/80 mt-1">Thay ảnh</span>
|
||||
<input type="file" accept="image/*" className="hidden" onChange={handleAvatarChange} disabled={isUploading} />
|
||||
</label>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-semibold">Di chuột qua ảnh và nhấp để tải hình đại diện mới</span>
|
||||
</div>
|
||||
|
||||
{/* Text Input Row Fields */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="font-bold text-slate-400">Tên hiển thị:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none transition-all font-semibold"
|
||||
placeholder="Nhập tên hiển thị"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="font-bold text-slate-400">Số điện thoại:</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none transition-all font-semibold"
|
||||
placeholder="Nhập số điện thoại"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="font-bold text-slate-400">Địa chỉ liên hệ:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
className="bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none transition-all font-semibold"
|
||||
placeholder="Nhập địa chỉ của bạn"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* EMAIL COMPONENT BOUNDARY - CRITICAL REQUIREMENT: DISABLED CHANGING */}
|
||||
<div className="flex flex-col gap-1.5 bg-slate-950/30 p-3.5 rounded-xl border border-slate-850">
|
||||
<label className="font-bold text-slate-500">Địa chỉ Email đăng nhập (Không thể chỉnh sửa):</label>
|
||||
<input
|
||||
type="email"
|
||||
disabled
|
||||
className="bg-slate-950/50 border border-slate-850 text-slate-500 rounded-xl p-3 cursor-not-allowed select-none font-semibold"
|
||||
value={user.email || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Security Password Mutation Stack */}
|
||||
<div className="border-t border-slate-800/60 pt-4 flex flex-col gap-3">
|
||||
<span className="font-bold text-slate-300 text-sm">Thay đổi mật khẩu đăng nhập</span>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Mật khẩu hiện tại"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:border-blue-500 outline-none transition-all font-semibold"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Mật khẩu mới"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:border-blue-500 outline-none transition-all font-semibold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Core Configuration Toggles (Moved from Top-Bar into profile settings context) */}
|
||||
<div className="border-t border-slate-800/60 pt-4 grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="font-bold text-slate-400">Lựa chọn ngôn ngữ:</label>
|
||||
<select
|
||||
value={lang}
|
||||
onChange={(e) => changeLanguage(e.target.value as any)}
|
||||
className="bg-slate-955 border border-slate-800 rounded-xl p-3 text-white focus:outline-none cursor-pointer font-bold"
|
||||
>
|
||||
<option value="vi">Tiếng Việt (ICT)</option>
|
||||
<option value="en">English (US)</option>
|
||||
<option value="zh">中文 (ZH)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="font-bold text-slate-400">Lựa chọn giao diện:</label>
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => changeTheme(e.target.value as any)}
|
||||
className="bg-slate-955 border border-slate-800 rounded-xl p-3 text-white focus:outline-none cursor-pointer font-bold"
|
||||
>
|
||||
<option value="dark">Chế độ tối (Dark Mode)</option>
|
||||
<option value="light">Chế độ sáng (Light Mode)</option>
|
||||
<option value="system">Chế độ hệ thống (System)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Action Bottom Save Trigger */}
|
||||
<div className="p-4 bg-slate-950 border-t border-slate-800/60 flex justify-end gap-3 shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2.5 bg-slate-800 hover:bg-slate-700 disabled:opacity-50 text-slate-300 font-bold rounded-xl transition-all active:scale-95 cursor-pointer"
|
||||
>
|
||||
Hủy
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="px-5 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 font-bold text-white rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
Đang lưu...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
Lưu cấu hình
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit, Heart, Trash2 } from 'lucide-react';
|
||||
import { io } from 'socket.io-client';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { BACKEND_URL } from '@/utils/backendEndpoint';
|
||||
import { CoordinateSelectModal } from './CoordinateSelectModal';
|
||||
import { useTranslation } from '../hooks/useTranslation';
|
||||
import { useConfirm } from '../hooks/useConfirm';
|
||||
@@ -248,7 +249,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
fetchComments();
|
||||
|
||||
const socket = Capacitor.isNativePlatform()
|
||||
? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn')
|
||||
? io(BACKEND_URL)
|
||||
: io();
|
||||
socket.emit('joinPhoto', photo.id);
|
||||
|
||||
@@ -426,358 +427,308 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Left Side: Photo Detail */}
|
||||
<div className="relative w-full md:w-3/5 md:h-full bg-slate-950 flex flex-col overflow-hidden group shrink-0">
|
||||
|
||||
{/* Photo wrapper for mobile view (handles top overlay name and bottom-right like) */}
|
||||
<div className="relative w-full flex items-center justify-center md:absolute md:inset-0 md:flex md:items-center md:justify-center bg-slate-950">
|
||||
{/* Mobile Only: Uploader details overlay */}
|
||||
<div className="absolute top-[calc(0.75rem+env(safe-area-inset-top,0px))] left-4 z-40 md:hidden flex items-center gap-2 bg-slate-950/70 backdrop-blur-md px-2.5 py-1.5 rounded-full border border-slate-700/50">
|
||||
<div className="w-5 h-5 rounded-full bg-emerald-500/20 flex items-center justify-center">
|
||||
<User className="w-3 h-3 text-emerald-400" />
|
||||
</div>
|
||||
<span className="text-xs text-slate-200 max-w-[120px] truncate">
|
||||
{photo.uploader?.name || 'Ẩn danh'}
|
||||
</span>
|
||||
{/* ============================================================
|
||||
TIER 3: FIXED PHOTO FRAME VIEWPORT (z-20 / sticky on mobile)
|
||||
Aspect-square container. Does NOT resize on image swap.
|
||||
Desktop: left 3/5 column, absolute-fill with overlay metadata.
|
||||
============================================================ */}
|
||||
<div className="w-full aspect-square bg-slate-950 flex items-center justify-center sticky top-0 z-20 shrink-0 shadow-2xl border-b border-slate-900/60 md:relative md:aspect-auto md:w-3/5 md:h-full md:overflow-hidden md:border-b-0 group">
|
||||
|
||||
{/* Image fill */}
|
||||
<a
|
||||
href={`${window.location.origin}/api/v1/public-photos/${photo.id}/share`}
|
||||
className="absolute inset-0 flex items-center justify-center cursor-zoom-in"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsFullscreen(true);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={photo.imageUrl}
|
||||
alt="Public Map Upload"
|
||||
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||
className="w-full h-full object-contain select-none pointer-events-none"
|
||||
draggable={false}
|
||||
/>
|
||||
{(!isAuthorized || !isLoggedIn) && (
|
||||
<div className="absolute inset-0 bg-transparent select-none z-10" />
|
||||
)}
|
||||
</a>
|
||||
|
||||
{/* TIER 3.1: LOCATION & TIMESTAMP OVERLAY — bottom-left inside image (z-30) */}
|
||||
<div className="absolute bottom-3 left-3 z-30 flex flex-col gap-0.5 max-w-[70vw] md:max-w-[45%] pointer-events-none drop-shadow-[0_1px_3px_rgba(0,0,0,0.9)]">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-blue-300">
|
||||
<MapPin className="w-3.5 h-3.5 text-red-500 shrink-0" />
|
||||
<span className="truncate leading-tight">{resolvedAddress}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[9px] text-blue-200/80 pl-5">
|
||||
<Calendar className="w-2.5 h-2.5 text-blue-300/70 shrink-0" />
|
||||
<span>{new Date(photo.capturedAt).toLocaleDateString('vi-VN', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
})}</span>
|
||||
</div>
|
||||
|
||||
{/* Like Button Overlay */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleLike();
|
||||
}}
|
||||
className="absolute bottom-4 right-4 z-40 flex items-center gap-1.5 bg-slate-950/70 hover:bg-slate-900/80 border border-slate-700/50 text-slate-200 hover:text-white font-bold py-1.5 px-3 rounded-full transition-all active:scale-95 text-[11px] backdrop-blur-md md:absolute md:top-[calc(1rem+env(safe-area-inset-top,0px))] md:left-4 md:bottom-auto md:right-auto"
|
||||
title={isLiked ? "Bỏ thích" : "Thích"}
|
||||
>
|
||||
<Heart className={`w-4 h-4 transition-colors ${
|
||||
isLiked ? 'text-rose-500 fill-rose-500 animate-in zoom-in-75' : 'text-slate-350 hover:text-rose-450'
|
||||
}`} />
|
||||
<span>{likeCount}</span>
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={`${window.location.origin}/api/v1/public-photos/${photo.id}/share`}
|
||||
className="w-full h-auto max-h-[85vh] object-contain cursor-zoom-in md:h-full md:max-h-none flex items-center justify-center relative"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsFullscreen(true);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={photo.imageUrl}
|
||||
alt="Public Map Upload"
|
||||
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||
className={`w-full h-auto max-h-[85vh] object-contain md:h-full md:max-h-none select-none ${
|
||||
!isLoggedIn ? 'pointer-events-none' : ''
|
||||
}`}
|
||||
draggable={false}
|
||||
/>
|
||||
{/* Shield overlay to prevent Save Image As on right-click / long press for non-owners */}
|
||||
{(!isAuthorized || !isLoggedIn) && (
|
||||
<div className="absolute inset-0 bg-transparent select-none z-10" />
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Info & Timeline overlay inside photo panel */}
|
||||
<div className="relative z-20 p-6 bg-slate-900 border-b border-slate-800/60 md:absolute md:bottom-0 md:left-0 md:right-0 md:bg-gradient-to-t md:from-black/60 md:via-black/20 md:to-transparent md:border-b-0 flex flex-col gap-4">
|
||||
|
||||
{/* Timeline scroll */}
|
||||
{/* Like button — bottom-right on mobile, top-left on desktop */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleLike();
|
||||
}}
|
||||
className="absolute bottom-3 right-3 z-30 flex items-center gap-1 bg-black/55 backdrop-blur-sm border border-white/10 rounded-xl px-2.5 py-1.5 text-[11px] font-bold transition-all active:scale-95 md:top-[calc(1rem+env(safe-area-inset-top,0px))] md:left-4 md:bottom-auto md:right-auto"
|
||||
title={isLiked ? "Bỏ thích" : "Thích"}
|
||||
>
|
||||
<Heart className={`w-3.5 h-3.5 transition-colors ${
|
||||
isLiked ? 'text-rose-500 fill-rose-500' : 'text-white'
|
||||
}`} />
|
||||
<span className="text-white">{likeCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Uploader pill — top-left on mobile */}
|
||||
<div className="absolute top-[calc(0.75rem+env(safe-area-inset-top,0px))] left-3 z-30 md:hidden flex items-center gap-1.5 bg-black/55 backdrop-blur-sm px-2.5 py-1 rounded-full border border-white/10">
|
||||
<div className="w-4 h-4 rounded-full bg-emerald-500/30 flex items-center justify-center">
|
||||
<User className="w-2.5 h-2.5 text-emerald-300" />
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-200 max-w-[110px] truncate font-semibold">
|
||||
{photo.uploader?.name || 'Ẩn danh'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Desktop: overlay metadata gradient at bottom of photo column */}
|
||||
<div className="hidden md:flex absolute bottom-0 left-0 right-0 z-20 px-6 pb-5 pt-12 bg-gradient-to-t from-black/70 via-black/20 to-transparent flex-col gap-1 pointer-events-none">
|
||||
<h4 className="text-sm font-black text-white tracking-tight leading-snug drop-shadow">
|
||||
{photo.metadata?.title || ''}
|
||||
</h4>
|
||||
{photo.metadata?.description && (
|
||||
<p className="text-xs text-slate-300 leading-snug line-clamp-2 drop-shadow">{photo.metadata.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-slate-300 mt-1">
|
||||
<MapPin className="w-3 h-3 text-rose-400 shrink-0" />
|
||||
<span className="truncate">{resolvedAddress}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-slate-400">
|
||||
<Calendar className="w-2.5 h-2.5 shrink-0" />
|
||||
<span>{new Date(photo.capturedAt).toLocaleDateString('vi-VN', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
})}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop thumbnail timeline carousel at the bottom */}
|
||||
{photoGroup && photoGroup.length > 1 && (
|
||||
<div className="hidden md:flex absolute bottom-0 left-0 right-0 z-20 px-4 pb-3 gap-2 overflow-x-auto no-scrollbar justify-end items-end pointer-events-auto">
|
||||
{photoGroup.map((p) => {
|
||||
const isActive = p.id === photo.id;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={(e) => { e.stopPropagation(); onSelectPhoto?.(p); }}
|
||||
className={`relative w-10 h-10 rounded-xl overflow-hidden transition-all active:scale-95 shrink-0 ${
|
||||
isActive ? 'border-2 border-emerald-400 scale-105 shadow-lg' : 'border border-slate-600/50 opacity-70 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<img src={p.imageUrl} alt="thumb" className="w-full h-full object-cover pointer-events-none" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ============================================================
|
||||
TIER 2: SCROLLABLE COMMENTS PANEL (z-10)
|
||||
Slides BEHIND the sticky image frame when swiping up on mobile.
|
||||
Desktop: right 2/5 column with its own overflow-y-auto.
|
||||
============================================================ */}
|
||||
<div className="flex-1 overflow-y-auto relative z-10 flex flex-col bg-slate-900 border-t md:border-t-0 md:border-l border-slate-800 md:w-2/5 md:flex-1 md:min-h-0">
|
||||
|
||||
{/* ---- TITLE HEADER: replaces old "Lịch sử ảnh tại vị trí này" strip ---- */}
|
||||
<div className="px-5 pt-4 pb-0 bg-slate-900/95 backdrop-blur-md border-b border-slate-800/60 sticky top-0 z-10 flex flex-col gap-2">
|
||||
|
||||
{/* Row 1: Title + Edit button */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-sm font-black text-slate-100 tracking-wide leading-snug">
|
||||
{photo.metadata?.title || 'Chưa có tiêu đề'}
|
||||
</h2>
|
||||
{photo.metadata?.description && (
|
||||
<p className="text-[11px] text-slate-400 leading-relaxed mt-0.5 line-clamp-2">
|
||||
{photo.metadata.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isAuthorized && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setIsEditing(true); }}
|
||||
className="p-1.5 bg-slate-800 hover:bg-slate-700 border border-slate-700/50 rounded-xl text-slate-300 hover:text-white transition-all shrink-0"
|
||||
title="Chỉnh sửa thông tin"
|
||||
>
|
||||
<Edit className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: Thumbnail timeline strip — below title, mobile only */}
|
||||
{photoGroup && photoGroup.length > 1 && (
|
||||
<div className="flex flex-col gap-2 border-b border-slate-800/80 pb-3 relative z-20">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-400 flex items-center gap-1.5">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500 animate-ping"></span>
|
||||
Lịch sử ảnh tại vị trí này ({photoGroup.length})
|
||||
</span>
|
||||
<div className="flex gap-3 overflow-x-auto no-scrollbar py-1 relative z-20">
|
||||
{photoGroup.map((p) => {
|
||||
const isActive = p.id === photo.id;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectPhoto?.(p);
|
||||
}}
|
||||
className={`relative w-12 h-12 rounded-xl overflow-hidden border-2 transition-all active:scale-95 shrink-0 ${
|
||||
isActive ? 'border-emerald-500 scale-110 shadow-lg' : 'border-slate-700 hover:border-slate-500'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={p.imageUrl}
|
||||
alt="Timeline thumbnail"
|
||||
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||
className={`w-full h-full object-cover ${
|
||||
!isLoggedIn ? 'pointer-events-none' : ''
|
||||
}`}
|
||||
/>
|
||||
<div className="absolute bottom-0 inset-x-0 bg-slate-950/70 text-[8px] text-center font-bold text-slate-300 py-0.5">
|
||||
{new Date(p.capturedAt).toLocaleDateString('vi-VN', { month: '2-digit', day: '2-digit' })}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto no-scrollbar pb-3 md:hidden">
|
||||
{photoGroup.map((p) => {
|
||||
const isActive = p.id === photo.id;
|
||||
const dateObj = new Date(p.capturedAt);
|
||||
const day = String(dateObj.getDate()).padStart(2, '0');
|
||||
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={(e) => { e.stopPropagation(); onSelectPhoto?.(p); }}
|
||||
className={`relative w-12 h-12 rounded-2xl overflow-hidden transition-all active:scale-95 shrink-0 ${
|
||||
isActive
|
||||
? 'border-[3px] border-emerald-500 shadow-lg shadow-emerald-500/25'
|
||||
: 'border-2 border-slate-600 hover:border-slate-400'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={p.imageUrl}
|
||||
alt="thumb"
|
||||
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||
className="w-full h-full object-cover pointer-events-none"
|
||||
/>
|
||||
<div className="absolute bottom-0 inset-x-0 bg-slate-950/70 text-[7px] text-center font-black text-slate-200 py-px tracking-wide">
|
||||
{day}-{month}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
{/* Edit form (when editing) */}
|
||||
{isEditing && (
|
||||
<div className="px-5 py-4 border-b border-slate-800/60 bg-slate-900/95">
|
||||
<div className="flex flex-col gap-3 bg-slate-900/95 border border-slate-800 p-4 rounded-2xl animate-in slide-in-from-bottom-2">
|
||||
<h4 className="text-xs font-black uppercase tracking-wider text-emerald-400">Chỉnh sửa thông tin ảnh</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Tiêu đề</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
<input type="text" value={editTitle} onChange={(e) => setEditTitle(e.target.value)}
|
||||
placeholder="Nhập tiêu đề cho ảnh..."
|
||||
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Mô tả</label>
|
||||
<textarea
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
placeholder="Mô tả bức ảnh này..."
|
||||
rows={2}
|
||||
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500 resize-none"
|
||||
/>
|
||||
<textarea value={editDescription} onChange={(e) => setEditDescription(e.target.value)}
|
||||
placeholder="Mô tả bức ảnh này..." rows={2}
|
||||
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500 resize-none" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Vĩ độ</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={editLat}
|
||||
<input type="number" step="any" value={editLat}
|
||||
onChange={(e) => setEditLat(e.target.value === '' ? '' : parseFloat(e.target.value))}
|
||||
placeholder="Vĩ độ..."
|
||||
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Kinh độ</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={editLng}
|
||||
<input type="number" step="any" value={editLng}
|
||||
onChange={(e) => setEditLng(e.target.value === '' ? '' : parseFloat(e.target.value))}
|
||||
placeholder="Kinh độ..."
|
||||
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsMapOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-750 border border-slate-700/50 text-slate-300 hover:text-white rounded-xl text-[10px] font-bold transition-all"
|
||||
>
|
||||
<MapPin className="w-3.5 h-3.5 text-rose-500" />
|
||||
Chọn trên bản đồ
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={(e) => { e.stopPropagation(); setIsMapOpen(true); }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-750 border border-slate-700/50 text-slate-300 hover:text-white rounded-xl text-[10px] font-bold transition-all">
|
||||
<MapPin className="w-3.5 h-3.5 text-rose-500" />
|
||||
Chọn trên bản đồ
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(false);
|
||||
}}
|
||||
disabled={isSavingEdit}
|
||||
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-bold transition-all"
|
||||
>
|
||||
<div className="flex justify-end gap-2 mt-1">
|
||||
<button onClick={(e) => { e.stopPropagation(); setIsEditing(false); }} disabled={isSavingEdit}
|
||||
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-bold transition-all">
|
||||
Hủy
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSaveEdit();
|
||||
}}
|
||||
disabled={isSavingEdit}
|
||||
className="flex items-center gap-1 px-4 py-1.5 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition-all"
|
||||
>
|
||||
{isSavingEdit ? (
|
||||
<>
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Đang lưu...
|
||||
</>
|
||||
) : (
|
||||
'Lưu lại'
|
||||
)}
|
||||
<button onClick={(e) => { e.stopPropagation(); handleSaveEdit(); }} disabled={isSavingEdit}
|
||||
className="flex items-center gap-1 px-4 py-1.5 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition-all">
|
||||
{isSavingEdit ? (<><Loader2 className="w-3 h-3 animate-spin" />Đang lưu...</>) : 'Lưu lại'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Title & Description display */}
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
{photo.metadata?.title ? (
|
||||
<h4 className="text-sm font-black text-white tracking-tight leading-snug break-words">
|
||||
{photo.metadata.title}
|
||||
</h4>
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-500 italic block mb-1">Chưa có tiêu đề</span>
|
||||
)}
|
||||
{photo.metadata?.description ? (
|
||||
<p className="text-xs text-slate-300 leading-relaxed mt-1 max-h-20 overflow-y-auto no-scrollbar break-words">
|
||||
{photo.metadata.description}
|
||||
</p>
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-500 italic block mt-1">Chưa có mô tả</span>
|
||||
)}
|
||||
</div>
|
||||
{isAuthorized && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className="p-1.5 bg-slate-800 hover:bg-slate-700 border border-slate-700/50 rounded-xl text-slate-300 hover:text-white transition-all shrink-0"
|
||||
title="Chỉnh sửa thông tin"
|
||||
>
|
||||
<Edit className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Photo Metadata */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-slate-800/80 pt-3 text-xs text-slate-350">
|
||||
<div className="space-y-1 text-left">
|
||||
<div className="flex items-center gap-1.5 text-slate-400">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
Ngày chụp: {new Date(photo.capturedAt).toLocaleDateString('vi-VN', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-slate-400" title={photo.metadata?.lat && photo.metadata?.lng ? `${photo.metadata.lat.toFixed(6)}, ${photo.metadata.lng.toFixed(6)}` : ''}>
|
||||
<MapPin className="w-3.5 h-3.5 text-rose-500" />
|
||||
Địa điểm: {resolvedAddress}
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-1.5 text-xs text-emerald-400">
|
||||
<User className="w-3.5 h-3.5" />
|
||||
Người đăng: {photo.uploader?.name || 'Ẩn danh'}
|
||||
</div>
|
||||
</div>
|
||||
{isAuthorized && photo.originalUrl && (
|
||||
<a
|
||||
href={photo.originalUrl}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 bg-slate-800/80 hover:bg-slate-700/80 border border-slate-700/50 text-slate-200 hover:text-white font-bold py-1 px-3 rounded-full transition-all active:scale-95 text-[10px]"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 text-emerald-500" />
|
||||
Tải ảnh gốc
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Comments */}
|
||||
<div className="w-full md:w-2/5 md:flex-1 md:min-h-0 flex flex-col bg-slate-900 border-t md:border-t-0 md:border-l border-slate-800">
|
||||
|
||||
{/* Comments Header */}
|
||||
<div className="hidden md:block p-6 border-b border-slate-800">
|
||||
<div>
|
||||
<h3 className="text-lg font-black tracking-tight text-white flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5 text-emerald-500" />
|
||||
{t('commentSectionTitle')}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 mt-1">Ảnh chia sẻ công khai trên bản đồ</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download original (authorized) */}
|
||||
{isAuthorized && photo.originalUrl && (
|
||||
<div className="px-5 py-2 border-b border-slate-800/40 flex justify-end">
|
||||
<a href={photo.originalUrl} download target="_blank" rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 bg-slate-800/80 hover:bg-slate-700/80 border border-slate-700/50 text-slate-200 hover:text-white font-bold py-1 px-3 rounded-full transition-all active:scale-95 text-[10px]">
|
||||
<Download className="w-3.5 h-3.5 text-emerald-500" />
|
||||
Tải ảnh gốc
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- COMMENTS HEADER (desktop) ---- */}
|
||||
<div className="hidden md:block px-6 py-4 border-b border-slate-800">
|
||||
<h3 className="text-base font-black tracking-tight text-white flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4 text-emerald-500" />
|
||||
{t('commentSectionTitle')}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Ảnh chia sẻ công khai trên bản đồ</p>
|
||||
</div>
|
||||
|
||||
{/* Comments list scroll area */}
|
||||
<div className="md:flex-1 md:overflow-y-auto p-6 space-y-4 bg-slate-900/50">
|
||||
{/* ---- COMMENT FEED ---- */}
|
||||
<div className="p-4 space-y-4 flex-1">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-slate-500 gap-2">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-emerald-500" />
|
||||
<span className="text-xs font-semibold">{t('loading')}</span>
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500 gap-3">
|
||||
<div className="flex flex-col items-center justify-center py-12 text-slate-500 gap-3">
|
||||
<div className="p-4 bg-slate-800/40 rounded-full text-slate-600">
|
||||
<MessageSquare className="w-8 h-8" />
|
||||
<MessageSquare className="w-7 h-7" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold italic">Chưa có bình luận nào. Hãy bắt đầu cuộc trò chuyện!</span>
|
||||
</div>
|
||||
) : (
|
||||
comments.map((c) => {
|
||||
return (
|
||||
<div key={c.id} className="flex gap-3 items-start animate-in fade-in slide-in-from-bottom-2 duration-200">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center flex-shrink-0 border border-slate-700/60">
|
||||
<User className="w-4.5 h-4.5 text-slate-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="bg-slate-800/55 p-3.5 rounded-2xl rounded-tl-none border border-slate-800 shadow-lg">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-xs font-black text-slate-200 truncate">{c.userName}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[9px] font-medium text-slate-500">
|
||||
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
{(currentUser?.isAdmin ||
|
||||
currentUser?.id === c.userId ||
|
||||
currentUser?.id === photo.uploaderId ||
|
||||
currentUser?.id === photo.uploader?.id) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteComment(c.id);
|
||||
}}
|
||||
className="text-slate-500 hover:text-rose-500 transition-colors p-0.5"
|
||||
title={t('delete') || "Xóa"}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
comments.map((c) => (
|
||||
<div key={c.id} className="flex gap-3 items-start animate-in fade-in slide-in-from-bottom-2 duration-200">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center flex-shrink-0 border border-slate-700/60">
|
||||
<User className="w-4 h-4 text-slate-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="bg-slate-800/55 p-3.5 rounded-2xl rounded-tl-none border border-slate-800 shadow-lg">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-xs font-black text-slate-200 truncate">{c.userName}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[9px] font-medium text-slate-500">
|
||||
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
{(currentUser?.isAdmin || currentUser?.id === c.userId ||
|
||||
currentUser?.id === photo.uploaderId || currentUser?.id === photo.uploader?.id) && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteComment(c.id); }}
|
||||
className="text-slate-500 hover:text-rose-500 transition-colors p-0.5"
|
||||
title={t('delete') || "Xóa"}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 leading-relaxed break-words">{c.content}</p>
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 leading-relaxed break-words">{c.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<div ref={commentsEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Comment Input Area */}
|
||||
<div className="sticky bottom-0 md:static p-4 bg-slate-950 md:bg-slate-950/40 border-t border-slate-800/80 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] md:pb-4 z-40">
|
||||
{/* ---- STICKY COMMENT INPUT TRAY BAR (z-20 stays above comments) ---- */}
|
||||
<div className="sticky bottom-0 z-20 p-4 bg-slate-950 border-t border-slate-800/80 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] md:pb-4">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
@@ -789,22 +740,19 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
disabled={isSending}
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSend();
|
||||
}}
|
||||
onClick={(e) => { e.stopPropagation(); handleSend(); }}
|
||||
disabled={!newComment.trim() || isSending}
|
||||
className="p-3 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-40 text-white rounded-2xl transition-all active:scale-95 shadow-lg shadow-emerald-950/30 flex items-center justify-center"
|
||||
className="p-3 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-40 text-white rounded-2xl transition-all active:scale-95 shadow-lg flex items-center justify-center"
|
||||
>
|
||||
{isSending ? (
|
||||
<Loader2 className="w-4.5 h-4.5 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-4.5 h-4.5" />
|
||||
)}
|
||||
{isSending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
`}</style>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -822,24 +770,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>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
|
||||
import { BACKEND_URL } from '@/utils/backendEndpoint';
|
||||
import { Send, MessageSquare, Loader2, Image as ImageIcon, MapPin, Download, X } from 'lucide-react';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
|
||||
@@ -182,7 +183,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId, embedded = false })
|
||||
// Connect to socket and listen for tour messages
|
||||
useEffect(() => {
|
||||
const socket = Capacitor.isNativePlatform()
|
||||
? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn')
|
||||
? io(BACKEND_URL)
|
||||
: io();
|
||||
socketRef.current = socket;
|
||||
|
||||
@@ -601,7 +602,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId, embedded = false })
|
||||
)}
|
||||
|
||||
{/* Locked Chat Input Footer */}
|
||||
<div className="locked-chat-input-footer !flex-shrink-0 !w-full !border-t border-gray-200 bg-white !z-40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 12px)' }}>
|
||||
<div className="locked-chat-input-footer relative !flex-shrink-0 !w-full !border-t border-gray-200 bg-white !z-40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 12px)' }}>
|
||||
{/* Mention list dropdown */}
|
||||
{showMentionList && filteredParticipants.length > 0 && (
|
||||
<div
|
||||
@@ -612,7 +613,14 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId, embedded = false })
|
||||
<button
|
||||
key={member.id}
|
||||
type="button"
|
||||
onClick={() => insertMention(member)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
insertMention(member);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
insertMention(member);
|
||||
}}
|
||||
className={`px-3 py-2 text-left text-xs font-semibold flex items-center gap-2 transition-colors ${
|
||||
index === mentionIndex
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,452 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { X, Search, Users, UserPlus, UserCheck, MessageSquare, Trash2, Check, UserX, Loader2 } from 'lucide-react';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
|
||||
interface FriendsManagerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: any;
|
||||
onOpenChatWithUser?: (userId: string) => void;
|
||||
}
|
||||
|
||||
export const FriendsManagerModal: React.FC<FriendsManagerModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
user,
|
||||
onOpenChatWithUser,
|
||||
}) => {
|
||||
const notify = useNotification();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'friends' | 'pending' | 'search'>('friends');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
// Connection Lists
|
||||
const [connections, setConnections] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const getHeaders = () => ({
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
});
|
||||
|
||||
// Fetch direct connections list
|
||||
const fetchConnections = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/connections', { headers: getHeaders() });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && user) {
|
||||
fetchConnections();
|
||||
}
|
||||
}, [isOpen, user]);
|
||||
|
||||
// Handle connection search lookup
|
||||
const handleSearchUsers = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/users?q=${encodeURIComponent(searchQuery)}`, { headers: getHeaders() });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Filter out current user from search results
|
||||
setSearchResults((data || []).filter((u: any) => u.id !== user?.id));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[FriendsManagerModal] Search error:', e);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Accept/Reject friend request
|
||||
const handleUpdateStatus = async (connId: string, status: 'ACCEPTED' | 'REJECTED') => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/connections/${connId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (res.ok) {
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: status === 'ACCEPTED' ? 'Đã chấp nhận kết nối.' : 'Đã từ chối kết nối.',
|
||||
type: 'success',
|
||||
});
|
||||
fetchConnections();
|
||||
} else {
|
||||
throw new Error('Cập nhật kết nối thất bại.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
message: err.message,
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Disconnect / Unfriend / Cancel request
|
||||
const handleDisconnect = async (connId: string, name: string) => {
|
||||
const ok = await confirm({
|
||||
title: 'Hủy kết nối?',
|
||||
message: `Bạn có chắc chắn muốn hủy kết nối với ${name} không?`,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/connections/${connId}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders(),
|
||||
});
|
||||
if (res.ok) {
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: 'Hủy kết nối thành công.',
|
||||
type: 'success',
|
||||
});
|
||||
fetchConnections();
|
||||
} else {
|
||||
throw new Error('Lỗi hủy kết nối.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
message: err.message,
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Send request connection
|
||||
const handleSendRequest = async (receiverId: string) => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/connections', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
body: JSON.stringify({ receiverId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message || 'Gửi lời mời thất bại.');
|
||||
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: 'Đã gửi lời mời kết nối thành công.',
|
||||
type: 'success',
|
||||
});
|
||||
fetchConnections();
|
||||
// Reset search lists to update button states
|
||||
setSearchResults(prev => prev.map(u => u.id === receiverId ? { ...u, pendingSent: true } : u));
|
||||
} catch (err: any) {
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
message: err.message,
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen || !user) return null;
|
||||
|
||||
// Filter lists
|
||||
const activeFriends = Array.isArray(connections)
|
||||
? connections.filter((c: any) => c && c.status === 'ACCEPTED')
|
||||
: [];
|
||||
|
||||
// Received pending requests
|
||||
const pendingRequests = Array.isArray(connections)
|
||||
? connections.filter((c: any) => c && c.status === 'PENDING' && c.targetUser?.id === user?.id)
|
||||
: [];
|
||||
|
||||
const getStatusText = (targetUserId: string) => {
|
||||
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') {
|
||||
return existing.requester?.id === user?.id ? 'SENT' : 'RECEIVED';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1000000] bg-black/70 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-4 pointer-events-auto">
|
||||
<div className="bg-slate-900 w-full sm:max-w-xl h-[92vh] sm:h-[80vh] rounded-t-3xl sm:rounded-2xl flex flex-col overflow-hidden text-slate-200 text-xs border border-slate-800/80 shadow-2xl animate-in slide-in-from-bottom sm:zoom-in-95 duration-300">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-slate-800/60 flex justify-between items-center shrink-0">
|
||||
<span className="font-bold text-sm text-white flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-indigo-400" /> Danh sách bạn bè
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Selection Header */}
|
||||
<div className="bg-slate-950/40 p-2.5 border-b border-slate-850 flex gap-2 shrink-0">
|
||||
{[
|
||||
{ id: 'friends', label: `Bạn bè (${activeFriends.length})` },
|
||||
{ id: 'pending', label: `Lời mời (${pendingRequests.length})` },
|
||||
{ id: 'search', label: 'Tìm bạn mới' }
|
||||
].map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex-1 py-2 rounded-xl text-center font-bold transition-all cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-indigo-650 text-white shadow-md'
|
||||
: 'bg-slate-900/40 hover:bg-slate-850 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content body */}
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{isLoading && activeTab !== 'search' ? (
|
||||
<div className="h-full flex items-center justify-center flex-col gap-2 py-20">
|
||||
<Loader2 className="w-8 h-8 text-indigo-400 animate-spin" />
|
||||
<span className="text-slate-400 font-medium">Đang tải dữ liệu...</span>
|
||||
</div>
|
||||
) : activeTab === 'friends' ? (
|
||||
activeFriends.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center py-20 px-6 gap-3">
|
||||
<div className="w-16 h-16 bg-slate-850 rounded-full flex items-center justify-center border border-slate-800">
|
||||
<Users className="w-8 h-8 text-slate-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-white text-sm">Chưa có bạn bè kết nối</div>
|
||||
<p className="text-slate-500 mt-1 max-w-xs mx-auto">Chọn mục "Tìm bạn mới" để tìm kiếm và gửi lời mời kết bạn.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{activeFriends.map((conn) => {
|
||||
const friend = conn.targetUser?.id === user?.id ? conn.requester : conn.targetUser;
|
||||
return (
|
||||
<div
|
||||
key={conn.id}
|
||||
className="p-3 bg-slate-950/40 border border-slate-850 rounded-xl flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-10 h-10 rounded-full bg-slate-850 overflow-hidden flex items-center justify-center font-bold border border-slate-800 shrink-0">
|
||||
{friend.avatar ? (
|
||||
<img src={friend.avatar} alt="Avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span>{friend.name.charAt(0).toUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h5 className="font-bold text-white truncate text-xs">{friend.name}</h5>
|
||||
<p className="text-[10px] text-slate-400 truncate mt-0.5">{friend.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onOpenChatWithUser) {
|
||||
onClose();
|
||||
onOpenChatWithUser(friend.id);
|
||||
}
|
||||
}}
|
||||
className="p-2 bg-indigo-650/15 hover:bg-indigo-650 text-indigo-400 hover:text-white rounded-xl transition-all cursor-pointer"
|
||||
title="Nhắn tin nhanh"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDisconnect(conn.id, friend.name)}
|
||||
className="p-2 bg-rose-650/15 hover:bg-rose-650 text-rose-400 hover:text-white rounded-xl transition-all cursor-pointer"
|
||||
title="Hủy kết bạn"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
) : activeTab === 'pending' ? (
|
||||
pendingRequests.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center py-20 px-6 gap-3">
|
||||
<div className="w-16 h-16 bg-slate-850 rounded-full flex items-center justify-center border border-slate-800">
|
||||
<UserPlus className="w-8 h-8 text-slate-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-white text-sm">Không có lời mời kết bạn</div>
|
||||
<p className="text-slate-500 mt-1 max-w-xs mx-auto">Bạn không có lời mời kết bạn nào đang chờ duyệt.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{pendingRequests.map((conn) => {
|
||||
const requester = conn.requester;
|
||||
return (
|
||||
<div
|
||||
key={conn.id}
|
||||
className="p-3 bg-slate-950/40 border border-slate-850 rounded-xl flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-10 h-10 rounded-full bg-slate-850 overflow-hidden flex items-center justify-center font-bold border border-slate-800 shrink-0">
|
||||
{requester.avatar ? (
|
||||
<img src={requester.avatar} alt="Avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span>{requester.name.charAt(0).toUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h5 className="font-bold text-white truncate text-xs">{requester.name}</h5>
|
||||
<p className="text-[10px] text-slate-400 truncate mt-0.5">{requester.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(conn.id, 'ACCEPTED')}
|
||||
className="px-3.5 py-1.5 bg-emerald-650 hover:bg-emerald-600 font-bold text-white rounded-xl text-[10px] transition-all flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" /> Chấp nhận
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDisconnect(conn.id, requester.name)}
|
||||
className="p-2 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-xl transition-all cursor-pointer"
|
||||
title="Từ chối lời mời"
|
||||
>
|
||||
<UserX className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
// Search Tab panel
|
||||
<div className="space-y-4">
|
||||
<form onSubmit={handleSearchUsers} className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tìm theo Tên hoặc Số điện thoại..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-2.5 pl-9 pr-4 text-white focus:outline-none focus:border-indigo-650"
|
||||
/>
|
||||
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3.5" />
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSearching || !searchQuery.trim()}
|
||||
className="px-4 py-2.5 bg-indigo-650 hover:bg-indigo-600 disabled:opacity-50 text-white font-bold rounded-xl transition-all flex items-center gap-1 cursor-pointer shrink-0"
|
||||
>
|
||||
{isSearching ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : 'Tìm kiếm'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
{isSearching ? (
|
||||
<div className="flex justify-center items-center py-10">
|
||||
<Loader2 className="w-6 h-6 text-slate-500 animate-spin" />
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
searchQuery.trim() && (
|
||||
<div className="text-center text-slate-500 py-10 italic">
|
||||
Không tìm thấy kết quả phù hợp.
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
searchResults.map((u) => {
|
||||
const status = getStatusText(u.id);
|
||||
return (
|
||||
<div
|
||||
key={u.id}
|
||||
className="p-3 bg-slate-950/40 border border-slate-850 rounded-xl flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-10 h-10 rounded-full bg-slate-850 overflow-hidden flex items-center justify-center font-bold border border-slate-800 shrink-0">
|
||||
{u.avatar ? (
|
||||
<img src={u.avatar} alt="Avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span>{u.name.charAt(0).toUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h5 className="font-bold text-white truncate text-xs">{u.name}</h5>
|
||||
<p className="text-[10px] text-slate-400 truncate mt-0.5">{u.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
{status === 'FRIEND' ? (
|
||||
<span className="text-[10px] text-indigo-400 font-bold flex items-center gap-1">
|
||||
<UserCheck className="w-3.5 h-3.5" /> Đã kết nối
|
||||
</span>
|
||||
) : status === 'SENT' || u.pendingSent ? (
|
||||
<span className="text-[10px] text-slate-500 italic">
|
||||
Đã gửi lời mời
|
||||
</span>
|
||||
) : status === 'RECEIVED' ? (
|
||||
<span className="text-[10px] text-amber-400 font-semibold">
|
||||
Chờ bạn duyệt
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleSendRequest(u.id)}
|
||||
className="px-3.5 py-1.5 bg-slate-800 hover:bg-slate-700 font-bold text-slate-200 rounded-xl text-[10px] transition-all flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" /> Kết nối
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,522 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { X, Image as ImageIcon, Send, MapPin, Loader2, Search, Smile, Users } from 'lucide-react';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
|
||||
interface LiveChatModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: any;
|
||||
defaultChatUserId?: string | null;
|
||||
}
|
||||
|
||||
export const LiveChatModal: React.FC<LiveChatModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
user,
|
||||
defaultChatUserId,
|
||||
}) => {
|
||||
const notify = useNotification();
|
||||
|
||||
const [connections, setConnections] = useState<any[]>([]);
|
||||
const [activeChatUser, setActiveChatUser] = useState<any | null>(null);
|
||||
const [chatMessages, setChatMessages] = useState<any[]>([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isLoadingContacts, setIsLoadingContacts] = useState(false);
|
||||
const [isLoadingMessages, setIsLoadingMessages] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
|
||||
// Attachments
|
||||
const [attachedImage, setAttachedImage] = useState<File | null>(null);
|
||||
const [attachedImageUrl, setAttachedImageUrl] = useState<string | null>(null);
|
||||
const [attachedLocation, setAttachedLocation] = useState<{ latitude: number; longitude: number } | null>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getHeaders = () => ({
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
});
|
||||
|
||||
// Fetch connections
|
||||
const fetchConnections = async () => {
|
||||
setIsLoadingContacts(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/connections', { headers: getHeaders() });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Accepted connections only
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch conversation messages
|
||||
const fetchMessages = async (targetUserId: string) => {
|
||||
setIsLoadingMessages(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/messages/${targetUserId}`, { headers: getHeaders() });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setChatMessages(data || []);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[LiveChatModal] Error fetching messages:', e);
|
||||
} finally {
|
||||
setIsLoadingMessages(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load contacts when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && user) {
|
||||
fetchConnections();
|
||||
}
|
||||
}, [isOpen, user]);
|
||||
|
||||
// Set active chat user dynamically if default is provided
|
||||
useEffect(() => {
|
||||
if (isOpen && defaultChatUserId && connections.length > 0) {
|
||||
const conn = connections.find(
|
||||
(c: any) => c.targetUser?.id === defaultChatUserId || c.requester?.id === defaultChatUserId
|
||||
);
|
||||
if (conn) {
|
||||
const target = conn.targetUser?.id === user?.id ? conn.requester : conn.targetUser;
|
||||
setActiveChatUser(target);
|
||||
}
|
||||
}
|
||||
}, [isOpen, defaultChatUserId, connections]);
|
||||
|
||||
// Load chat messages when activeChatUser shifts
|
||||
useEffect(() => {
|
||||
if (activeChatUser) {
|
||||
fetchMessages(activeChatUser.id);
|
||||
(window as any).activeChatUserId = activeChatUser.id;
|
||||
}
|
||||
return () => {
|
||||
(window as any).activeChatUserId = undefined;
|
||||
};
|
||||
}, [activeChatUser]);
|
||||
|
||||
// Handle incoming live messages from custom window event dispatched by App.tsx socket
|
||||
useEffect(() => {
|
||||
const handleMessageReceived = (e: Event) => {
|
||||
const msg = (e as CustomEvent).detail;
|
||||
if (activeChatUser && (msg.senderId === activeChatUser.id || msg.receiverId === activeChatUser.id)) {
|
||||
setChatMessages((prev) => [...prev, msg]);
|
||||
} else {
|
||||
// Reload contacts to update latest message snippets/badge alerts
|
||||
fetchConnections();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('app:messageReceived', handleMessageReceived);
|
||||
return () => {
|
||||
window.removeEventListener('app:messageReceived', handleMessageReceived);
|
||||
};
|
||||
}, [activeChatUser]);
|
||||
|
||||
// Scroll to chat baseline
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [chatMessages]);
|
||||
|
||||
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setAttachedImage(file);
|
||||
setAttachedImageUrl(URL.createObjectURL(file));
|
||||
notify({
|
||||
title: 'Đã đính kèm ảnh',
|
||||
message: `${file.name} đã được chọn để gửi.`,
|
||||
type: 'info',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAttachGps = () => {
|
||||
if (!navigator.geolocation) {
|
||||
notify({
|
||||
title: 'Không được hỗ trợ',
|
||||
message: 'Trình duyệt của bạn không hỗ trợ định vị.',
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setAttachedLocation({
|
||||
latitude: pos.coords.latitude,
|
||||
longitude: pos.coords.longitude,
|
||||
});
|
||||
notify({
|
||||
title: 'Đã đính kèm định vị',
|
||||
message: `Vị trí (${pos.coords.latitude.toFixed(4)}, ${pos.coords.longitude.toFixed(4)}) đã được ghi nhận.`,
|
||||
type: 'success',
|
||||
});
|
||||
},
|
||||
() => {
|
||||
notify({
|
||||
title: 'Lỗi định vị',
|
||||
message: 'Không thể lấy vị trí hiện tại. Hãy kiểm tra quyền GPS.',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleSendMessage = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
if (!newMessage.trim() && !attachedImage && !attachedLocation) return;
|
||||
if (!activeChatUser) return;
|
||||
|
||||
setIsSending(true);
|
||||
try {
|
||||
let uploadedUrl: string | null = null;
|
||||
if (attachedImage) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', attachedImage);
|
||||
const uploadRes = await fetch('/api/v1/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
|
||||
body: formData,
|
||||
});
|
||||
if (uploadRes.ok) {
|
||||
const uploadData = await uploadRes.json();
|
||||
uploadedUrl = uploadData.url;
|
||||
} else {
|
||||
throw new Error('Upload ảnh thất bại.');
|
||||
}
|
||||
}
|
||||
|
||||
const bodyData = {
|
||||
receiverId: activeChatUser.id,
|
||||
content: newMessage,
|
||||
mediaUrl: uploadedUrl,
|
||||
latitude: attachedLocation?.latitude || null,
|
||||
longitude: attachedLocation?.longitude || null,
|
||||
};
|
||||
|
||||
const sendRes = await fetch('/api/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
body: JSON.stringify(bodyData),
|
||||
});
|
||||
|
||||
if (sendRes.ok) {
|
||||
const sentMsg = await sendRes.json();
|
||||
setChatMessages((prev) => [...prev, sentMsg]);
|
||||
setNewMessage('');
|
||||
setAttachedImage(null);
|
||||
setAttachedImageUrl(null);
|
||||
setAttachedLocation(null);
|
||||
} else {
|
||||
throw new Error('Gửi tin nhắn thất bại.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
message: err.message || 'Không thể gửi tin nhắn.',
|
||||
type: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen || !user) return null;
|
||||
|
||||
// Filter connections by search query
|
||||
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">
|
||||
<div className="bg-slate-900 w-full sm:max-w-4xl h-[92vh] sm:h-[80vh] rounded-t-3xl sm:rounded-2xl flex flex-col overflow-hidden text-slate-200 text-xs border border-slate-800/80 shadow-2xl animate-in slide-in-from-bottom sm:zoom-in-95 duration-300">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-slate-800/60 flex justify-between items-center shrink-0">
|
||||
<span className="font-bold text-sm text-white flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-indigo-400" /> Trò chuyện trực tiếp
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Workspace panel */}
|
||||
<div className="flex-1 flex flex-col md:flex-row min-h-0">
|
||||
|
||||
{/* Left Column - Contacts Sidebar */}
|
||||
<div className="w-full md:w-80 border-r border-slate-800/80 flex flex-col min-h-0 bg-slate-950/20 shrink-0">
|
||||
<div className="p-3 border-b border-slate-800/50">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tìm kiếm bạn bè..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-slate-950/80 border border-slate-850 rounded-xl py-2 pl-9 pr-4 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-650"
|
||||
/>
|
||||
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-2.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1.5">
|
||||
{isLoadingContacts ? (
|
||||
<div className="flex justify-center items-center py-10">
|
||||
<Loader2 className="w-6 h-6 text-slate-500 animate-spin" />
|
||||
</div>
|
||||
) : filteredConnections.length === 0 ? (
|
||||
<div className="text-center text-slate-500 py-10 italic">
|
||||
Không tìm thấy bạn bè nào.
|
||||
</div>
|
||||
) : (
|
||||
filteredConnections.map((conn: any) => {
|
||||
const friend = conn.targetUser?.id === user?.id ? conn.requester : conn.targetUser;
|
||||
const isActive = activeChatUser?.id === friend.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={conn.id}
|
||||
onClick={() => setActiveChatUser(friend)}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-xl transition-all text-left cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-indigo-650 text-white font-bold'
|
||||
: 'bg-slate-950/20 hover:bg-slate-850 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
<div className="w-10 h-10 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center overflow-hidden font-bold">
|
||||
{friend.avatar ? (
|
||||
<img src={friend.avatar} alt="Avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span>{friend.name.charAt(0).toUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-green-500 rounded-full border-2 border-slate-900" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs truncate font-bold">{friend.name}</div>
|
||||
<div className={`text-[10px] truncate mt-0.5 ${isActive ? 'text-slate-200' : 'text-slate-500'}`}>
|
||||
{friend.email}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Active Chat View */}
|
||||
<div className="flex-1 flex flex-col min-w-0 bg-slate-950/40">
|
||||
{activeChatUser ? (
|
||||
<>
|
||||
{/* Active contact bar */}
|
||||
<div className="px-4 py-3 bg-slate-900/60 border-b border-slate-800/60 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-800 overflow-hidden flex items-center justify-center font-bold">
|
||||
{activeChatUser.avatar ? (
|
||||
<img src={activeChatUser.avatar} alt="Avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span>{activeChatUser.name.charAt(0).toUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-bold text-white text-xs">{activeChatUser.name}</div>
|
||||
<div className="text-[10px] text-green-400 flex items-center gap-1 font-semibold">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block animate-pulse" /> Đang hoạt động
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages stream */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3.5">
|
||||
{isLoadingMessages ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 text-indigo-400 animate-spin" />
|
||||
</div>
|
||||
) : chatMessages.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center text-slate-500 gap-1.5">
|
||||
<Smile className="w-8 h-8 text-slate-600" />
|
||||
<span>Hãy gửi tin nhắn để bắt đầu cuộc trò chuyện.</span>
|
||||
</div>
|
||||
) : (
|
||||
chatMessages.map((msg: any) => {
|
||||
const isMe = msg.senderId === user.id;
|
||||
const dateStr = msg.createdAt ? new Date(msg.createdAt).toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' }) : '';
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${isMe ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div className={`max-w-[75%] flex flex-col gap-1`}>
|
||||
<div
|
||||
className={`p-3 rounded-2xl break-words text-xs shadow-md ${
|
||||
isMe
|
||||
? 'bg-indigo-650 text-white rounded-tr-none'
|
||||
: 'bg-slate-800/90 text-slate-100 rounded-tl-none'
|
||||
}`}
|
||||
>
|
||||
{/* Attached Image */}
|
||||
{msg.mediaUrl && (
|
||||
<div className="mb-2 rounded-xl overflow-hidden max-w-xs border border-black/10">
|
||||
<img
|
||||
src={msg.mediaUrl}
|
||||
alt="Chat attachment"
|
||||
className="max-h-40 w-full object-cover cursor-pointer"
|
||||
onClick={() => window.open(msg.mediaUrl, '_blank')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attached Location */}
|
||||
{msg.latitude && msg.longitude && (
|
||||
<a
|
||||
href={`https://maps.google.com/?q=${msg.latitude},${msg.longitude}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mb-2 flex items-center gap-2 p-2 bg-black/20 hover:bg-black/30 rounded-xl text-[10px] text-blue-300 font-bold border border-blue-500/20"
|
||||
>
|
||||
<MapPin className="w-4 h-4 text-rose-500 shrink-0" />
|
||||
<span>Vị trí: {msg.latitude.toFixed(5)}, {msg.longitude.toFixed(5)}</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{msg.content && <p className="leading-relaxed whitespace-pre-wrap">{msg.content}</p>}
|
||||
</div>
|
||||
<span className={`text-[9px] text-slate-500 ${isMe ? 'text-right' : 'text-left'}`}>
|
||||
{dateStr}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Bottom Input Area */}
|
||||
<form onSubmit={handleSendMessage} className="p-3 bg-slate-900 border-t border-slate-800/80 flex flex-col gap-2 shrink-0">
|
||||
{/* Attachment Previews */}
|
||||
{(attachedImageUrl || attachedLocation) && (
|
||||
<div className="flex flex-wrap gap-2 p-2 bg-slate-950/60 rounded-xl border border-slate-850">
|
||||
{attachedImageUrl && (
|
||||
<div className="relative w-14 h-14 rounded-lg overflow-hidden border border-slate-750">
|
||||
<img src={attachedImageUrl} alt="Preview" className="w-full h-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAttachedImage(null); setAttachedImageUrl(null); }}
|
||||
className="absolute top-0.5 right-0.5 p-0.5 bg-black/70 hover:bg-black text-white rounded-full"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachedLocation && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-1 bg-rose-950/30 text-rose-300 border border-rose-800/30 rounded-xl text-[10px] font-bold">
|
||||
<MapPin className="w-3.5 h-3.5 text-rose-500 shrink-0" />
|
||||
<span>Vị trí GPS</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAttachedLocation(null)}
|
||||
className="p-0.5 bg-rose-900/50 hover:bg-rose-900 text-white rounded-full ml-1"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="p-2.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-xl transition-all cursor-pointer shrink-0"
|
||||
title="Đính kèm ảnh"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
accept="image/*"
|
||||
onChange={handleImageSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAttachGps}
|
||||
className="p-2.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-xl transition-all cursor-pointer shrink-0"
|
||||
title="Gửi vị trí định vị"
|
||||
>
|
||||
<MapPin className="w-4 h-4 text-rose-500" />
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nhập tin nhắn..."
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
className="flex-1 bg-slate-950 border border-slate-800 rounded-xl py-2.5 px-4 text-white focus:outline-none focus:border-indigo-650"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSending || (!newMessage.trim() && !attachedImage && !attachedLocation)}
|
||||
className="p-2.5 bg-indigo-650 hover:bg-indigo-600 disabled:opacity-50 text-white rounded-xl transition-all cursor-pointer shrink-0"
|
||||
>
|
||||
{isSending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center p-8 gap-3">
|
||||
<div className="w-16 h-16 bg-slate-900 border border-slate-800 rounded-full flex items-center justify-center">
|
||||
<Smile className="w-8 h-8 text-slate-600 animate-bounce" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-white text-sm">Chưa chọn bạn hội thoại</div>
|
||||
<p className="text-slate-500 mt-1 max-w-xs mx-auto">Chọn một người bạn ở danh sách bên trái để bắt đầu cuộc trò chuyện riêng tư của bạn.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,308 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { X, Calendar, Users, Navigation, Trash2, Loader2, Compass, Play } from 'lucide-react';
|
||||
import { useTourStore } from '../../store/useTourStore';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
|
||||
interface MyToursModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: any;
|
||||
onViewTour: (tourId: string) => void;
|
||||
onOpenNavigation?: (payload: any) => void;
|
||||
}
|
||||
|
||||
export const MyToursModal: React.FC<MyToursModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
user,
|
||||
onViewTour,
|
||||
onOpenNavigation,
|
||||
}) => {
|
||||
const notify = useNotification();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const publicTours = useTourStore(state => state.publicTours);
|
||||
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
|
||||
const deleteTour = useTourStore(state => state.deleteTour);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isDeletingId, setIsDeletingId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'ongoing' | 'upcoming' | 'past'>('ongoing');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && user) {
|
||||
const loadTours = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await fetchPublicTours();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadTours();
|
||||
}
|
||||
}, [isOpen, fetchPublicTours, user]);
|
||||
|
||||
if (!isOpen || !user) return null;
|
||||
|
||||
// Filter tours where user is participant
|
||||
const myTours = publicTours.filter(tour =>
|
||||
tour.participants?.some((p: any) => p.userId === user?.id)
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Classify tours
|
||||
const ongoingTours = myTours.filter(tour => {
|
||||
if (!tour.startDate || !tour.endDate) return false;
|
||||
const start = new Date(tour.startDate);
|
||||
const end = new Date(tour.endDate);
|
||||
return start <= now && end >= now;
|
||||
});
|
||||
|
||||
const upcomingTours = myTours.filter(tour => {
|
||||
if (!tour.startDate) return true;
|
||||
const start = new Date(tour.startDate);
|
||||
return start > now;
|
||||
});
|
||||
|
||||
const pastTours = myTours.filter(tour => {
|
||||
if (!tour.endDate) return false;
|
||||
const end = new Date(tour.endDate);
|
||||
return end < now;
|
||||
});
|
||||
|
||||
const getActiveList = () => {
|
||||
switch (activeTab) {
|
||||
case 'ongoing': return ongoingTours;
|
||||
case 'upcoming': return upcomingTours;
|
||||
case 'past': return pastTours;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (tourId: string, tourTitle: string) => {
|
||||
const ok = await confirm({
|
||||
title: 'Xóa chuyến đi?',
|
||||
message: `Bạn có chắc chắn muốn xóa chuyến đi "${tourTitle}" không? Hành động này không thể hoàn tác.`,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
setIsDeletingId(tourId);
|
||||
try {
|
||||
await deleteTour(tourId);
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: 'Đã xóa chuyến đi thành công.',
|
||||
type: 'success',
|
||||
});
|
||||
} catch (err: any) {
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
message: err.message || 'Xóa chuyến đi thất bại.',
|
||||
type: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartNavigation = (tour: any) => {
|
||||
if (!onOpenNavigation) {
|
||||
notify({
|
||||
title: 'Lưu ý',
|
||||
message: 'Tính năng dẫn đường chỉ khả dụng trong chế độ bản đồ.',
|
||||
type: 'info',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const firstLeg = tour.legs?.[0];
|
||||
const origin = firstLeg?.locations?.[0] || { lat: 10.7769, lng: 106.7009 };
|
||||
const destination = firstLeg?.locations?.[firstLeg?.locations?.length - 1] || { lat: 10.8231, lng: 106.6297, name: 'Điểm kết thúc' };
|
||||
|
||||
onOpenNavigation({
|
||||
tourId: tour.id,
|
||||
origin: { lat: Number(origin.latitude || origin.lat), lng: Number(origin.longitude || origin.lng) },
|
||||
destination: {
|
||||
lat: Number(destination.latitude || destination.lat),
|
||||
lng: Number(destination.longitude || destination.lng),
|
||||
name: destination.name || 'Điểm kết thúc'
|
||||
},
|
||||
tourTitle: tour.title,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Calculate ongoing timeline progress percentage
|
||||
const getProgressPercent = (tour: any) => {
|
||||
if (!tour.startDate || !tour.endDate) return 0;
|
||||
const start = new Date(tour.startDate).getTime();
|
||||
const end = new Date(tour.endDate).getTime();
|
||||
const current = now.getTime();
|
||||
if (current >= end) return 100;
|
||||
if (current <= start) return 0;
|
||||
return Math.round(((current - start) / (end - start)) * 100);
|
||||
};
|
||||
|
||||
const currentList = getActiveList();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1000000] bg-black/70 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-4 pointer-events-auto">
|
||||
<div className="bg-slate-900 w-full sm:max-w-2xl h-[92vh] sm:h-[80vh] rounded-t-3xl sm:rounded-2xl flex flex-col overflow-hidden text-slate-200 text-xs border border-slate-800/80 shadow-2xl animate-in slide-in-from-bottom sm:zoom-in-95 duration-300">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-slate-800/60 flex justify-between items-center shrink-0">
|
||||
<span className="font-bold text-sm text-white flex items-center gap-2">
|
||||
<Compass className="w-5 h-5 text-indigo-400" /> Hành trình của tôi
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Selection Header */}
|
||||
<div className="bg-slate-950/40 p-2.5 border-b border-slate-850 flex gap-2 shrink-0">
|
||||
{[
|
||||
{ id: 'ongoing', label: 'Đang thực hiện' },
|
||||
{ id: 'upcoming', label: 'Sắp khởi hành' },
|
||||
{ id: 'past', label: 'Đã hoàn thành' }
|
||||
].map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex-1 py-2 rounded-xl text-center font-bold transition-all cursor-pointer ${isActive
|
||||
? 'bg-indigo-650 text-white shadow-md'
|
||||
: 'bg-slate-900/40 hover:bg-slate-850 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content body */}
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{isLoading ? (
|
||||
<div className="h-full flex items-center justify-center flex-col gap-2 py-20">
|
||||
<Loader2 className="w-8 h-8 text-indigo-400 animate-spin" />
|
||||
<span className="text-slate-400 font-medium">Đang tải danh sách chuyến đi...</span>
|
||||
</div>
|
||||
) : currentList.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center py-20 px-6 gap-3">
|
||||
<div className="w-16 h-16 bg-slate-850 rounded-full flex items-center justify-center border border-slate-800">
|
||||
<Compass className="w-8 h-8 text-slate-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-white text-sm">Chưa có hành trình nào</div>
|
||||
<p className="text-slate-500 mt-1 max-w-xs mx-auto">Bạn không có chuyến đi nào trong mục này.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{currentList.map((tour) => {
|
||||
const isOwner = tour.participants?.some(
|
||||
(p: any) => p.userId === user?.id && p.role === 'OWNER'
|
||||
);
|
||||
const startDateStr = tour.startDate ? new Date(tour.startDate).toLocaleDateString('vi-VN') : '';
|
||||
const endDateStr = tour.endDate ? new Date(tour.endDate).toLocaleDateString('vi-VN') : '';
|
||||
const progress = getProgressPercent(tour);
|
||||
|
||||
// Fallback tour banner photo
|
||||
const tourBanner = tour.coverImage || 'https://images.unsplash.com/photo-1488646953014-85cb44e25828?w=500';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tour.id}
|
||||
className="bg-slate-950/40 border border-slate-850 hover:border-slate-750 rounded-2xl overflow-hidden flex flex-col justify-between transition-all"
|
||||
>
|
||||
{/* Banner cover */}
|
||||
<div className="relative h-28 w-full overflow-hidden">
|
||||
<img src={tourBanner} alt="Cover Banner" className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 to-transparent opacity-90" />
|
||||
|
||||
<div className="absolute bottom-3 left-3 right-3 min-w-0">
|
||||
<h4 className="font-bold text-white text-xs truncate drop-shadow-md">{tour.title}</h4>
|
||||
<p className="text-[10px] text-slate-300 font-medium mt-0.5">{startDateStr} - {endDateStr}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress details */}
|
||||
<div className="p-3.5 space-y-3 flex-1 flex flex-col justify-between">
|
||||
{activeTab === 'ongoing' && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center text-[10px] text-slate-400 font-bold">
|
||||
<span>Tiến độ hành trình</span>
|
||||
<span className="text-indigo-400">{progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-800 h-1.5 rounded-full overflow-hidden">
|
||||
<div className="bg-indigo-500 h-full rounded-full transition-all" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-[10px] text-slate-400 font-semibold">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Users className="w-3.5 h-3.5 text-slate-500" />
|
||||
{tour.participants?.length || 0} thành viên
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Navigation className="w-3.5 h-3.5 text-slate-500" />
|
||||
{tour.legs?.length || 0} chặng đi
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end items-center gap-2 border-t border-slate-850/60 pt-3 mt-1">
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={() => handleDelete(tour.id, tour.title)}
|
||||
disabled={isDeletingId === tour.id}
|
||||
className="p-2 text-rose-400 hover:bg-rose-500/10 rounded-xl transition-all cursor-pointer disabled:opacity-50"
|
||||
title="Xóa chuyến đi"
|
||||
>
|
||||
{isDeletingId === tour.id ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleStartNavigation(tour)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 font-bold text-indigo-300 rounded-xl transition-all active:scale-95 cursor-pointer"
|
||||
title="Bắt đầu dẫn đường"
|
||||
>
|
||||
<Play className="w-3 h-3 text-indigo-400 fill-indigo-400" /> Dẫn đường
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onViewTour(tour.id);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 bg-indigo-650 hover:bg-indigo-600 font-bold text-white rounded-xl transition-all active:scale-95 cursor-pointer shadow-md shadow-indigo-900/10"
|
||||
>
|
||||
Hành trình Tour
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,496 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { X, Image as ImageIcon, Loader2, Download, Eye, MapPin, Tag, Trash2, Edit2, Check } from 'lucide-react';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
|
||||
interface PhotoGalleryModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: any;
|
||||
}
|
||||
|
||||
const PHOTO_TAGS = [
|
||||
{ value: 'phong-canh', label: '🏞️ Phong cảnh' },
|
||||
{ value: 'con-nguoi', label: '👥 Con người' },
|
||||
{ value: 'doi-thuong', label: '🎒 Đời thường' },
|
||||
{ value: 'bien', label: '🌊 Biển' },
|
||||
{ value: 'nui', label: '⛰️ Núi' },
|
||||
{ value: 'do-thi', label: '🏙️ Đô thị' },
|
||||
{ value: 'thuc-an', label: '🍜 Thức ăn' },
|
||||
{ value: 'cho', label: '🛍️ Chợ' },
|
||||
{ value: 'hien-dai', label: '🏗️ Hiện đại' },
|
||||
{ value: 'dong-vat', label: '🦁 Động vật' },
|
||||
{ value: 'thu-cung', label: '🐕 Thú cưng' }
|
||||
];
|
||||
|
||||
export const PhotoGalleryModal: React.FC<PhotoGalleryModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
user,
|
||||
}) => {
|
||||
const notify = useNotification();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const [photos, setPhotos] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Filtering states
|
||||
const [filterTourId, setFilterTourId] = useState<string>('all');
|
||||
const [filterTag, setFilterTag] = useState<string>('all');
|
||||
|
||||
// Preview / Editor States
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null);
|
||||
const [editingPhotoId, setEditingPhotoId] = useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
const [editDescription, setEditDescription] = useState('');
|
||||
const [editTags, setEditTags] = useState<string[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeletingId, setIsDeletingId] = useState<string | null>(null);
|
||||
|
||||
const getHeaders = () => ({
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
});
|
||||
|
||||
const loadPhotos = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/users/me/photos', { headers: getHeaders() });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPhotos(data || []);
|
||||
} else {
|
||||
throw new Error('Không thể tải thư viện ảnh.');
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
message: e.message || 'Không thể tải ảnh.',
|
||||
type: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && user) {
|
||||
loadPhotos();
|
||||
}
|
||||
}, [isOpen, user]);
|
||||
|
||||
if (!isOpen || !user) return null;
|
||||
|
||||
// Extract unique tours from photos list for dropdown filtering
|
||||
const uniqueToursMap = new Map();
|
||||
photos.forEach(p => {
|
||||
if (p.tour?.id && p.tour?.title) {
|
||||
uniqueToursMap.set(p.tour.id, p.tour.title);
|
||||
}
|
||||
});
|
||||
const uniqueTours = Array.from(uniqueToursMap.entries()).map(([id, title]) => ({ id, title }));
|
||||
|
||||
// Handle Photo Deletion
|
||||
const handleDelete = async (photoId: string) => {
|
||||
const ok = await confirm({
|
||||
title: 'Xóa ảnh?',
|
||||
message: 'Bạn có chắc chắn muốn xóa bức ảnh này không? Ảnh sẽ được chuyển vào thùng rác.',
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
setIsDeletingId(photoId);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/photos/${photoId}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders(),
|
||||
});
|
||||
if (res.ok) {
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: 'Đã xóa ảnh thành công.',
|
||||
type: 'success',
|
||||
});
|
||||
setPhotos(prev => prev.filter(p => p.id !== photoId));
|
||||
if (selectedPhoto?.id === photoId) setSelectedPhoto(null);
|
||||
} else {
|
||||
throw new Error('Xóa ảnh thất bại.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
message: err.message || 'Không thể xóa ảnh.',
|
||||
type: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Start Editing Tag details
|
||||
const startEdit = (photo: any) => {
|
||||
setEditingPhotoId(photo.id);
|
||||
const meta = photo.metadata || {};
|
||||
setEditTitle(meta.title || '');
|
||||
setEditDescription(meta.description || '');
|
||||
setEditTags(meta.tags || []);
|
||||
};
|
||||
|
||||
const handleTagToggle = (tagValue: string) => {
|
||||
setEditTags(prev =>
|
||||
prev.includes(tagValue)
|
||||
? prev.filter(t => t !== tagValue)
|
||||
: [...prev, tagValue]
|
||||
);
|
||||
};
|
||||
|
||||
// Save Tagging Details
|
||||
const saveEdit = async (photoId: string) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/photos/${photoId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: editTitle,
|
||||
description: editDescription,
|
||||
tags: editTags,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: 'Cập nhật thông tin ảnh thành công.',
|
||||
type: 'success',
|
||||
});
|
||||
// Reload photos list to synchronize
|
||||
await loadPhotos();
|
||||
setEditingPhotoId(null);
|
||||
} else {
|
||||
throw new Error('Lỗi cập nhật ảnh.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
message: err.message || 'Cập nhật thất bại.',
|
||||
type: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (url: string, filename: string) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = blobUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
notify({
|
||||
title: 'Lỗi tải về',
|
||||
message: 'Không thể tải trực tiếp ảnh xuống thiết bị.',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Filter photos
|
||||
const filteredPhotos = photos.filter((photo) => {
|
||||
const matchTour = filterTourId === 'all' || photo.tour?.id === filterTourId;
|
||||
const matchTag = filterTag === 'all' || photo.metadata?.tags?.includes(filterTag);
|
||||
return matchTour && matchTag;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1000000] bg-black/70 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-4 pointer-events-auto">
|
||||
<div className="bg-slate-900 w-full sm:max-w-4xl h-[92vh] sm:h-[80vh] rounded-t-3xl sm:rounded-2xl flex flex-col overflow-hidden text-slate-200 text-xs border border-slate-800/80 shadow-2xl animate-in slide-in-from-bottom sm:zoom-in-95 duration-300">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-slate-800/60 flex justify-between items-center shrink-0">
|
||||
<span className="font-bold text-sm text-white flex items-center gap-2">
|
||||
<ImageIcon className="w-5 h-5 text-emerald-400" /> Thư viện ảnh
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dual Filter Header Select Pinned Matrix */}
|
||||
<div className="bg-slate-950/40 p-3.5 border-b border-slate-850 flex flex-col sm:flex-row gap-3.5 shrink-0">
|
||||
<div className="flex-1 flex flex-col gap-1.5">
|
||||
<label className="font-bold text-slate-400 text-[10px] uppercase tracking-wider">Theo hành trình:</label>
|
||||
<select
|
||||
value={filterTourId}
|
||||
onChange={(e) => setFilterTourId(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white font-bold cursor-pointer focus:outline-none focus:border-indigo-650"
|
||||
>
|
||||
<option value="all">Tất cả hành trình</option>
|
||||
{uniqueTours.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col gap-1.5">
|
||||
<label className="font-bold text-slate-400 text-[10px] uppercase tracking-wider">Theo thẻ phân loại:</label>
|
||||
<select
|
||||
value={filterTag}
|
||||
onChange={(e) => setFilterTag(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white font-bold cursor-pointer focus:outline-none focus:border-indigo-650"
|
||||
>
|
||||
<option value="all">Tất cả thẻ tags</option>
|
||||
{PHOTO_TAGS.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content body grid workspace */}
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{isLoading ? (
|
||||
<div className="h-full flex items-center justify-center flex-col gap-2 py-20">
|
||||
<Loader2 className="w-8 h-8 text-emerald-400 animate-spin" />
|
||||
<span className="text-slate-400 font-medium">Đang tải thư viện ảnh...</span>
|
||||
</div>
|
||||
) : filteredPhotos.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center py-20 px-6 gap-3">
|
||||
<div className="w-16 h-16 bg-slate-850 rounded-full flex items-center justify-center border border-slate-800">
|
||||
<ImageIcon className="w-8 h-8 text-slate-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-white text-sm">Không tìm thấy bức ảnh nào</div>
|
||||
<p className="text-slate-500 mt-1 max-w-xs mx-auto">Không có ảnh nào khớp với bộ lọc hiện tại.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{filteredPhotos.map((photo) => {
|
||||
const photoTagsList = photo.metadata?.tags || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="group relative aspect-square rounded-2xl overflow-hidden bg-slate-950 border border-slate-850 hover:border-slate-700 shadow-lg transition-all flex flex-col"
|
||||
>
|
||||
<img
|
||||
src={photo.imageUrl}
|
||||
alt="Gallery Item"
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
|
||||
{/* Standard tags badge dot count */}
|
||||
{photoTagsList.length > 0 && (
|
||||
<div className="absolute top-2.5 left-2.5 bg-black/60 backdrop-blur-md text-white font-bold text-[9px] px-2 py-0.5 rounded-full flex items-center gap-1 border border-white/10 z-10">
|
||||
<Tag className="w-2.5 h-2.5 text-emerald-400 shrink-0" />
|
||||
<span>{photoTagsList.length} tags</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover controls overlay */}
|
||||
<div className="absolute inset-0 bg-black/70 opacity-0 group-hover:opacity-100 flex flex-col justify-between p-3.5 transition-opacity duration-250 z-10">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<button
|
||||
onClick={() => startEdit(photo)}
|
||||
className="p-2 bg-slate-900/85 hover:bg-amber-650 text-white rounded-xl transition-colors cursor-pointer"
|
||||
title="Sửa thông tin"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(photo.id)}
|
||||
disabled={isDeletingId === photo.id}
|
||||
className="p-2 bg-slate-900/85 hover:bg-rose-650 text-white rounded-xl transition-colors cursor-pointer disabled:opacity-50"
|
||||
title="Xóa ảnh"
|
||||
>
|
||||
{isDeletingId === photo.id ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedPhoto(photo)}
|
||||
className="p-2 bg-slate-900/85 hover:bg-indigo-650 text-white rounded-xl transition-colors cursor-pointer"
|
||||
title="Xem chi tiết"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-bold text-white truncate">
|
||||
{photo.metadata?.title || 'Chưa đặt tiêu đề'}
|
||||
</div>
|
||||
{photo.tour?.title && (
|
||||
<div className="text-[9px] font-bold text-indigo-300 truncate flex items-center gap-1 mt-0.5">
|
||||
<MapPin className="w-2.5 h-2.5 shrink-0" />
|
||||
{photo.tour.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Editor & Tag modification Dialog */}
|
||||
{editingPhotoId && (
|
||||
<div className="fixed inset-0 z-[1000001] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 pointer-events-auto">
|
||||
<div className="bg-slate-900 w-full max-w-md rounded-2xl overflow-hidden border border-slate-800 p-5 space-y-4 shadow-2xl">
|
||||
<div className="flex justify-between items-center pb-2 border-b border-slate-800">
|
||||
<span className="font-bold text-sm text-white">Chỉnh sửa thông tin ảnh</span>
|
||||
<button
|
||||
onClick={() => setEditingPhotoId(null)}
|
||||
className="p-1 hover:bg-slate-800 text-slate-400 hover:text-white rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-xs">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="font-bold text-slate-400">Tiêu đề ảnh:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
placeholder="Ví dụ: Hoàng hôn biển Ba Động..."
|
||||
className="bg-slate-950 border border-slate-805 rounded-xl p-2.5 text-white focus:outline-none focus:border-indigo-650"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="font-bold text-slate-400">Mô tả ảnh:</label>
|
||||
<textarea
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
placeholder="Ghi lại kỷ niệm..."
|
||||
rows={2}
|
||||
className="bg-slate-950 border border-slate-805 rounded-xl p-2.5 text-white resize-none focus:outline-none focus:border-indigo-650"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-bold text-slate-400 block mb-1">Gắn thẻ phân loại (Hashtags):</label>
|
||||
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto p-1.5 bg-slate-950/60 border border-slate-850 rounded-xl">
|
||||
{PHOTO_TAGS.map((tag) => {
|
||||
const isSelected = editTags.includes(tag.value);
|
||||
return (
|
||||
<button
|
||||
key={tag.value}
|
||||
type="button"
|
||||
onClick={() => handleTagToggle(tag.value)}
|
||||
className={`px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all cursor-pointer flex items-center gap-1 ${
|
||||
isSelected
|
||||
? 'bg-emerald-950/40 text-emerald-400 border-emerald-500/50'
|
||||
: 'bg-slate-900 border-slate-800 text-slate-400 hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
{isSelected && <Check className="w-3 h-3 text-emerald-400 shrink-0" />}
|
||||
{tag.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-slate-850 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setEditingPhotoId(null)}
|
||||
className="px-4 py-2 bg-slate-850 hover:bg-slate-800 font-bold text-slate-300 rounded-xl"
|
||||
>
|
||||
Hủy
|
||||
</button>
|
||||
<button
|
||||
onClick={() => saveEdit(editingPhotoId)}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 bg-indigo-650 hover:bg-indigo-600 font-bold text-white rounded-xl flex items-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
{isSaving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
Lưu lại
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fullscreen Preview overlay */}
|
||||
{selectedPhoto && (
|
||||
<div className="fixed inset-0 z-[2000000] bg-black/95 flex flex-col justify-between p-4 pointer-events-auto">
|
||||
{/* Close trigger top bar */}
|
||||
<div className="flex justify-between items-center w-full pb-3 border-b border-slate-900">
|
||||
<div className="text-white font-bold text-xs truncate">
|
||||
{selectedPhoto.metadata?.title || 'Xem ảnh'}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedPhoto(null)}
|
||||
className="p-2 bg-slate-900 hover:bg-slate-800 text-slate-400 hover:text-white rounded-xl cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Photo view */}
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="max-w-2xl w-full flex flex-col gap-3">
|
||||
<img
|
||||
src={selectedPhoto.originalUrl || selectedPhoto.imageUrl}
|
||||
alt="Fullscreen Preview"
|
||||
className="max-w-full max-h-[60vh] object-contain rounded-xl shadow-2xl mx-auto"
|
||||
/>
|
||||
<div className="bg-slate-900/60 border border-slate-800 p-4 rounded-2xl space-y-1.5">
|
||||
{selectedPhoto.metadata?.title && (
|
||||
<h4 className="font-bold text-white text-xs">{selectedPhoto.metadata.title}</h4>
|
||||
)}
|
||||
{selectedPhoto.metadata?.description && (
|
||||
<p className="text-slate-400 text-[10px]">{selectedPhoto.metadata.description}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{selectedPhoto.metadata?.tags?.map((t: string) => (
|
||||
<span
|
||||
key={t}
|
||||
className="px-2 py-0.5 bg-slate-950/80 border border-slate-800 text-slate-400 text-[9px] font-bold rounded-full"
|
||||
>
|
||||
#{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action bottom bar */}
|
||||
<div className="flex justify-center items-center py-4 border-t border-slate-900 gap-3">
|
||||
<button
|
||||
onClick={() => handleDownload(selectedPhoto.originalUrl || selectedPhoto.imageUrl, `yotrip-photo-${selectedPhoto.id}.jpg`)}
|
||||
className="flex items-center gap-2 px-5 py-3 bg-emerald-650 hover:bg-emerald-600 font-bold text-white rounded-xl shadow-lg transition-all active:scale-95 cursor-pointer"
|
||||
>
|
||||
<Download className="w-4 h-4" /> Tải về tệp gốc (.jpg)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +1,26 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, useMap, useMapEvents, Tooltip } from 'react-leaflet';
|
||||
import { MapContainer, TileLayer, Marker, useMapEvents, Tooltip } from 'react-leaflet';
|
||||
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Share2, Filter, MapPin, Loader2, UserPlus, Clock, ChevronLeft, Lock, Globe, Sun, Moon, Laptop, Users, ShieldAlert, Star } from 'lucide-react';
|
||||
import { X, Navigation, Image as ImageIcon, Share2, Filter, MapPin, Loader2, UserPlus, Clock, ChevronLeft, Users, ShieldAlert, Star } from 'lucide-react';
|
||||
import { UserManagementModal } from '@/components/UserManagementModal';
|
||||
import { ReportBusinessModal } from '../components/ReportBusinessModal';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import { CreateTourModal } from '../components/CreateTourModal';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { PublicPhotoModal } from '../components/PublicPhotoModal';
|
||||
import { MapProfileDropdown } from '@/components/MapProfileDropdown';
|
||||
import { ProfileSettingsModal } from '@/components/ProfileSettingsModal';
|
||||
import { LoginModal } from '@/components/LoginModal';
|
||||
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({
|
||||
@@ -67,11 +74,9 @@ function MapTracker() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos, onLoginSuccess, onGoToDashboard }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void, onOpenMyPhotos: () => void, onLoginSuccess?: (user: any) => void, onGoToDashboard?: () => void }) => {
|
||||
// Check if user is logged in (real user) or is a guest
|
||||
export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onLoginSuccess, onGoToDashboard, onOpenNavigation }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void, onLoginSuccess?: (user: any) => void, onGoToDashboard?: (tab?: 'tours' | 'connections' | 'photos' | 'chats') => void, onOpenNavigation?: (payload: any) => void }) => {
|
||||
const guestToken = localStorage.getItem('guest_token');
|
||||
const isLoggedInOrGuest = user || guestToken;
|
||||
|
||||
const isAuthenticated = !!user && !guestToken;
|
||||
// Tối ưu hóa việc lấy dữ liệu từ store bằng selectors để tránh re-render thừa
|
||||
const publicTours = useTourStore(state => state.publicTours);
|
||||
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
|
||||
@@ -80,36 +85,8 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
|
||||
const notify = useNotification();
|
||||
const confirm = useConfirm();
|
||||
const { t, lang, changeLanguage } = useTranslation();
|
||||
const { theme, changeTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handlePromoteAdmin = async (secretKey: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/promote-admin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({ secretKey })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (response.ok && data.success) {
|
||||
const updatedUser = { ...user, isAdmin: true };
|
||||
localStorage.setItem('user', JSON.stringify(updatedUser));
|
||||
if (onLoginSuccess) {
|
||||
onLoginSuccess(updatedUser);
|
||||
}
|
||||
notify({ title: t('success'), message: 'Đã kích hoạt quyền quản trị thành công!', type: 'success' });
|
||||
setIsAdminModalOpen(true);
|
||||
} else {
|
||||
notify({ title: t('error'), message: data.message || t('invalidSecretKey'), type: 'error' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
notify({ title: t('error'), message: 'Lỗi mạng khi kích hoạt Admin.', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
// Refs for mobile long-press detection
|
||||
const touchTimerRef = React.useRef<any>(null);
|
||||
@@ -218,9 +195,9 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
|
||||
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] = [];
|
||||
@@ -252,12 +229,18 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
};
|
||||
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isProfileSettingsOpen, setIsProfileSettingsOpen] = useState(false);
|
||||
const [isMyToursOpen, setIsMyToursOpen] = useState(false);
|
||||
const [isMyPhotosOpen, setIsMyPhotosOpen] = useState(false);
|
||||
const [isChatOpen, setIsChatOpen] = useState(false);
|
||||
const [isFriendsOpen, setIsFriendsOpen] = useState(false);
|
||||
const [chatTargetUserId, setChatTargetUserId] = useState<string | null>(null);
|
||||
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
||||
const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
|
||||
const [isLeaderboardOpen, setIsLeaderboardOpen] = useState(false);
|
||||
const [blacklist, setBlacklist] = useState<any[]>([]);
|
||||
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
||||
const [isBlacklistOpen, setIsBlacklistOpen] = useState(false);
|
||||
const storeMapCenter = useTourStore(state => state.mapCenter);
|
||||
|
||||
// Recommendations and GPS States
|
||||
const [recommendedLocations, setRecommendedLocations] = useState<any[]>([]);
|
||||
@@ -438,6 +421,12 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
if (user || localStorage.getItem('token')) {
|
||||
fetchPublicTours();
|
||||
}
|
||||
|
||||
const targetTourId = localStorage.getItem('viewTourOnLand');
|
||||
if (targetTourId) {
|
||||
localStorage.removeItem('viewTourOnLand');
|
||||
onViewTour(targetTourId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Chỉ lấy vị trí GPS ban đầu để hiển thị marker, KHÔNG tự động nhảy bản đồ đến vị trí đó
|
||||
@@ -640,13 +629,13 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
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;
|
||||
});
|
||||
@@ -666,8 +655,11 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
}, [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">
|
||||
@@ -799,124 +791,35 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nhóm bên phải: Các thao tác người dùng */}
|
||||
<div className="flex items-center gap-2 pointer-events-auto">
|
||||
{/* Nút định vị người dùng */}
|
||||
<button
|
||||
onClick={requestGpsPosition}
|
||||
className="w-11 h-11 bg-[var(--surface)] rounded-full shadow-xl hover:bg-blue-100 text-blue-600 transition-all flex items-center justify-center border border-[var(--border)] shrink-0"
|
||||
title="Vị trí của tôi"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Nút Ảnh của tôi */}
|
||||
{isLoggedInOrGuest && (
|
||||
{/* Nhóm bên phải: Menu cá nhân hợp nhất */}
|
||||
<div className="flex items-center gap-3 pointer-events-auto">
|
||||
{isAuthenticated && (
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log("Đang mở Ảnh của tôi...");
|
||||
onOpenMyPhotos();
|
||||
setChatTargetUserId(null);
|
||||
setIsChatOpen(true);
|
||||
}}
|
||||
className="w-11 h-11 md:w-auto bg-[var(--surface)] p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-100 text-blue-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold border border-blue-100 shrink-0"
|
||||
title="Ảnh của tôi"
|
||||
className="w-11 h-11 bg-slate-900 border border-slate-800 rounded-full flex items-center justify-center shadow-xl hover:bg-slate-805 text-slate-300 hover:text-white transition-all active:scale-95 cursor-pointer shrink-0 relative group"
|
||||
title="Trò chuyện trực tiếp"
|
||||
>
|
||||
<ImageIcon className="w-5 h-5" />
|
||||
<span className="hidden md:inline text-sm">Ảnh của tôi</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Nút tạo Tour mới */}
|
||||
{user && !localStorage.getItem('guest_token') && (
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="w-11 h-11 md:w-auto bg-green-600 p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0"
|
||||
title="Tạo Tour mới"
|
||||
>
|
||||
<Navigation className="w-5 h-5" />
|
||||
<span className="hidden md:inline text-sm">Tạo Tour</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Nút Báo cáo sai phạm */}
|
||||
<button
|
||||
onClick={() => setIsReportModalOpen(true)}
|
||||
className="w-11 h-11 md:w-auto bg-red-600 p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-red-700 text-white transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0 cursor-pointer"
|
||||
title={t('reportBusinessBtn') || 'Báo cáo sai phạm'}
|
||||
>
|
||||
<ShieldAlert className="w-5 h-5" />
|
||||
<span className="hidden md:inline text-sm">{t('reportBusinessBtn') || 'Báo cáo'}</span>
|
||||
</button>
|
||||
|
||||
{/* Lựa chọn Ngôn ngữ */}
|
||||
<div className="relative group shrink-0">
|
||||
<button className="w-11 h-11 bg-[var(--surface)] dark:bg-slate-800 p-0 rounded-2xl shadow-xl hover:bg-[var(--background)] dark:hover:bg-slate-700 text-[var(--text-secondary)] dark:text-slate-200 transition-all flex items-center justify-center border border-[var(--border)] dark:border-slate-700">
|
||||
<Globe className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="absolute right-0 top-12 mt-1 hidden group-hover:block bg-[var(--surface)] dark:bg-slate-800 border border-[var(--border)] dark:border-slate-700 rounded-2xl shadow-2xl p-2 z-[9999] w-32 animate-in fade-in duration-200">
|
||||
<button onClick={() => changeLanguage('vi')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'vi' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>Tiếng Việt</button>
|
||||
<button onClick={() => changeLanguage('en')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'en' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>English</button>
|
||||
<button onClick={() => changeLanguage('zh')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'zh' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>中文</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lựa chọn Giao diện */}
|
||||
<div className="relative group shrink-0">
|
||||
<button className="w-11 h-11 bg-[var(--surface)] dark:bg-slate-800 p-0 rounded-2xl shadow-xl hover:bg-[var(--background)] dark:hover:bg-slate-700 text-[var(--text-secondary)] dark:text-slate-200 transition-all flex items-center justify-center border border-[var(--border)] dark:border-slate-700">
|
||||
{theme === 'light' && <Sun className="w-5 h-5 text-amber-500" />}
|
||||
{theme === 'dark' && <Moon className="w-5 h-5 text-indigo-400" />}
|
||||
{theme === 'system' && <Laptop className="w-5 h-5 animate-pulse" />}
|
||||
</button>
|
||||
<div className="absolute right-0 top-12 mt-1 hidden group-hover:block bg-[var(--surface)] dark:bg-slate-800 border border-[var(--border)] dark:border-slate-700 rounded-2xl shadow-2xl p-2 z-[9999] w-32 animate-in fade-in duration-200">
|
||||
<button onClick={() => changeTheme('light')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'light' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>
|
||||
<Sun className="w-3.5 h-3.5 text-amber-500" /> {t('themeLight')}
|
||||
</button>
|
||||
<button onClick={() => changeTheme('dark')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'dark' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>
|
||||
<Moon className="w-3.5 h-3.5 text-indigo-400" /> {t('themeDark')}
|
||||
</button>
|
||||
<button onClick={() => changeTheme('system')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'system' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>
|
||||
<Laptop className="w-3.5 h-3.5" /> {t('themeSystem')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nút quản lý người dùng cho Admin */}
|
||||
{user?.isAdmin && (
|
||||
<button
|
||||
onClick={() => setIsAdminModalOpen(true)}
|
||||
className="w-11 h-11 md:w-auto p-0 md:px-4 md:py-3 rounded-2xl shadow-xl transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0 bg-blue-600 hover:bg-blue-700 text-white"
|
||||
title={t('systemBtn')}
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
<span className="hidden md:inline text-sm">{t('systemBtn')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Nút Bảng điều khiển của tôi - chỉ hiển thị cho người dùng đã đăng nhập (không phải khách) */}
|
||||
{user && !localStorage.getItem('guest_token') && onGoToDashboard && (
|
||||
<button
|
||||
onClick={onGoToDashboard}
|
||||
className="w-11 h-11 md:w-auto p-0 md:px-4 md:py-3 rounded-2xl shadow-xl transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0 bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
title="Bảng điều khiển của tôi"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
<span className="hidden md:inline text-sm">Bảng điều khiển của tôi</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Nút đăng xuất */}
|
||||
{onLogout && (
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="w-11 h-11 md:w-auto bg-[var(--surface)] p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold text-[var(--text-secondary)] border border-[var(--border)] shrink-0"
|
||||
title="Đăng xuất"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span className="hidden md:inline text-sm">Rời đi</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 group-hover:scale-105 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-blue-500 rounded-full" />
|
||||
</button>
|
||||
)}
|
||||
<MapProfileDropdown
|
||||
user={user}
|
||||
onLogout={onLogout}
|
||||
onOpenSettings={() => setIsProfileSettingsOpen(true)}
|
||||
onOpenCreateTour={() => setIsCreateModalOpen(true)}
|
||||
onOpenReport={() => setIsReportModalOpen(true)}
|
||||
onOpenLogin={() => setIsLoginModalOpen(true)}
|
||||
onOpenMyPhotos={() => setIsMyPhotosOpen(true)}
|
||||
onOpenMyTours={() => setIsMyToursOpen(true)}
|
||||
onOpenFriends={() => setIsFriendsOpen(true)}
|
||||
onOpenAdmin={() => setIsAdminModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1066,9 +969,9 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
|
||||
{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
|
||||
@@ -1226,6 +1129,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Admin Modal */}
|
||||
<UserManagementModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} />
|
||||
@@ -1679,6 +1583,73 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
initialLatitude={mapCenter ? mapCenter[0] : undefined}
|
||||
initialLongitude={mapCenter ? mapCenter[1] : undefined}
|
||||
/>
|
||||
|
||||
{/* Profile Settings Modal */}
|
||||
<ProfileSettingsModal
|
||||
isOpen={isProfileSettingsOpen}
|
||||
onClose={() => setIsProfileSettingsOpen(false)}
|
||||
user={user}
|
||||
onSaveSuccess={onLoginSuccess}
|
||||
/>
|
||||
|
||||
{/* Login Modal for Guest Auth promotion */}
|
||||
<LoginModal
|
||||
isOpen={isLoginModalOpen}
|
||||
onClose={() => setIsLoginModalOpen(false)}
|
||||
onLoginSuccess={(loggedInUser) => {
|
||||
setIsLoginModalOpen(false);
|
||||
if (onLoginSuccess) {
|
||||
onLoginSuccess(loggedInUser);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* My Tours Modal */}
|
||||
<MyToursModal
|
||||
isOpen={isMyToursOpen}
|
||||
onClose={() => setIsMyToursOpen(false)}
|
||||
user={user}
|
||||
onViewTour={onViewTour}
|
||||
onOpenNavigation={onOpenNavigation}
|
||||
/>
|
||||
|
||||
{/* My Photos Modal */}
|
||||
<MyPhotosModal
|
||||
isOpen={isMyPhotosOpen}
|
||||
onClose={() => setIsMyPhotosOpen(false)}
|
||||
user={user}
|
||||
/>
|
||||
|
||||
{/* Live Chat Modal */}
|
||||
<LiveChatModal
|
||||
isOpen={isChatOpen}
|
||||
onClose={() => setIsChatOpen(false)}
|
||||
user={user}
|
||||
defaultChatUserId={chatTargetUserId}
|
||||
/>
|
||||
|
||||
{/* Friends Manager Modal */}
|
||||
<FriendsManagerModal
|
||||
isOpen={isFriendsOpen}
|
||||
onClose={() => setIsFriendsOpen(false)}
|
||||
user={user}
|
||||
onOpenChatWithUser={(userId) => {
|
||||
setChatTargetUserId(userId);
|
||||
setIsChatOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Floating GPS positioning button */}
|
||||
<button
|
||||
onClick={requestGpsPosition}
|
||||
className="absolute bottom-6 right-6 z-[1000] w-12 h-12 bg-[var(--surface)] hover:bg-[var(--background)] rounded-full shadow-2xl border border-[var(--border)] flex items-center justify-center text-blue-600 active:scale-95 transition-all pointer-events-auto cursor-pointer"
|
||||
title="Vị trí của tôi"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,23 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { LogIn, 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';
|
||||
import { MapProfileDropdown } from '../components/MapProfileDropdown';
|
||||
import { ProfileSettingsModal } from '../components/ProfileSettingsModal';
|
||||
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 { useTheme } from '../hooks/useTheme';
|
||||
import { compressImage } from '../utils/image';
|
||||
import { processAndResizeImage } from '../utils/imageProcessor';
|
||||
import { getDeviceLocation } from '../utils/geolocation';
|
||||
import { queueOfflineUpload } from '../utils/offlineQueue';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
|
||||
|
||||
interface LandingPageProps {
|
||||
onContinue?: () => void;
|
||||
@@ -15,10 +25,27 @@ interface LandingPageProps {
|
||||
onGoToMap?: () => void;
|
||||
onLoginSuccess?: (user: any) => void;
|
||||
isInitialSetup?: boolean;
|
||||
user?: any;
|
||||
onLogout?: () => void;
|
||||
onGoToDashboard?: (tab?: 'tours' | 'connections' | 'photos' | 'chats') => void;
|
||||
onOpenNavigation?: (payload: any) => void;
|
||||
}
|
||||
|
||||
export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMap, onLoginSuccess }) => {
|
||||
export const LandingPage: React.FC<LandingPageProps> = ({
|
||||
onGoToSignup,
|
||||
onGoToMap,
|
||||
onLoginSuccess,
|
||||
user,
|
||||
onLogout,
|
||||
onOpenNavigation,
|
||||
}) => {
|
||||
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
||||
const [isProfileSettingsOpen, setIsProfileSettingsOpen] = useState(false);
|
||||
const [isMyToursOpen, setIsMyToursOpen] = useState(false);
|
||||
const [isMyPhotosOpen, setIsMyPhotosOpen] = useState(false);
|
||||
const [isChatOpen, setIsChatOpen] = useState(false);
|
||||
const [isFriendsOpen, setIsFriendsOpen] = useState(false);
|
||||
const [chatTargetUserId, setChatTargetUserId] = useState<string | null>(null);
|
||||
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
||||
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false);
|
||||
const [isPhotoSourceModalOpen, setIsPhotoSourceModalOpen] = useState(false);
|
||||
@@ -26,11 +53,10 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
||||
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, lang, changeLanguage } = useTranslation();
|
||||
const { theme, changeTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
|
||||
const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
|
||||
@@ -112,7 +138,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
||||
const photoUrl = mainPhoto.imageUrl || mainPhoto.originalUrl || '/background.avif';
|
||||
const photoTitle = mainPhoto.metadata?.title || 'Travel Planner - Khám phá chuyến đi tuyệt vời';
|
||||
const photoDescription = mainPhoto.metadata?.description || `Được chia sẻ bởi ${mainPhoto.uploader?.name || 'một thành viên'}. Khám phá những hành trình tuyệt vời trên Travel Planner.`;
|
||||
|
||||
|
||||
// Update og:image
|
||||
let ogImage = document.querySelector('meta[property="og:image"]');
|
||||
if (!ogImage) {
|
||||
@@ -121,7 +147,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
||||
document.head.appendChild(ogImage);
|
||||
}
|
||||
ogImage.setAttribute('content', `https://yotrip.labz.io.vn${photoUrl}`);
|
||||
|
||||
|
||||
// Update og:title
|
||||
let ogTitle = document.querySelector('meta[property="og:title"]');
|
||||
if (!ogTitle) {
|
||||
@@ -130,7 +156,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
||||
document.head.appendChild(ogTitle);
|
||||
}
|
||||
ogTitle.setAttribute('content', photoTitle);
|
||||
|
||||
|
||||
// Update og:description
|
||||
let ogDescription = document.querySelector('meta[property="og:description"]');
|
||||
if (!ogDescription) {
|
||||
@@ -139,7 +165,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
||||
document.head.appendChild(ogDescription);
|
||||
}
|
||||
ogDescription.setAttribute('content', photoDescription);
|
||||
|
||||
|
||||
// Update twitter:image
|
||||
let twitterImage = document.querySelector('meta[name="twitter:image"]');
|
||||
if (!twitterImage) {
|
||||
@@ -157,6 +183,16 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
||||
fetchBlacklist();
|
||||
}, []);
|
||||
|
||||
// Listen for background-sync completion event dispatched by App.tsx
|
||||
useEffect(() => {
|
||||
const handleSyncComplete = () => {
|
||||
console.log('[LandingPage] Offline sync complete — refreshing public photos…');
|
||||
fetchPublicPhotos();
|
||||
};
|
||||
window.addEventListener('app:offlineSyncComplete', handleSyncComplete);
|
||||
return () => window.removeEventListener('app:offlineSyncComplete', handleSyncComplete);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (publicPhotos.length <= 1) return;
|
||||
const interval = setInterval(() => {
|
||||
@@ -165,117 +201,224 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 });
|
||||
|
||||
// Lưu file và location vào state pending, hiển thị modal tags
|
||||
setPendingPhotoFile(processedFile);
|
||||
setPendingPhotoLocation(location);
|
||||
|
||||
// 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;
|
||||
|
||||
setIsTagsModalOpen(false);
|
||||
|
||||
// ── OFFLINE GUARD ────────────────────────────────────────────────────────────
|
||||
if (!navigator.onLine) {
|
||||
try {
|
||||
const token = localStorage.getItem('token') || localStorage.getItem('guest_token') || '';
|
||||
const formFields: Record<string, string> = {};
|
||||
if (pendingPhotoLocation) {
|
||||
formFields['latitude'] = pendingPhotoLocation.latitude.toString();
|
||||
formFields['longitude'] = pendingPhotoLocation.longitude.toString();
|
||||
}
|
||||
if (selectedTags.length > 0) {
|
||||
formFields['tags'] = JSON.stringify(selectedTags);
|
||||
}
|
||||
|
||||
await queueOfflineUpload({
|
||||
endpoint: 'https://yotrip.labz.io.vn/api/v1/photos/upload-anonymous',
|
||||
authToken: token,
|
||||
fileBlob: pendingPhotoFile,
|
||||
fileName: pendingPhotoFile.name,
|
||||
formFields,
|
||||
});
|
||||
|
||||
notify({
|
||||
title: 'Ảnh đã được lưu tạm ngoại tuyến 📦',
|
||||
message: 'Kết nối lại mạng, ảnh sẽ tự động được tải lên bản đồ.',
|
||||
type: 'info',
|
||||
});
|
||||
} catch (err) {
|
||||
notify({ title: 'Lỗi', message: 'Không thể lưu ảnh ngoại tuyến.', type: 'error' });
|
||||
} finally {
|
||||
setPendingPhotoFile(null);
|
||||
setPendingPhotoLocation(null);
|
||||
if (photoPreviewUrl) {
|
||||
URL.revokeObjectURL(photoPreviewUrl);
|
||||
setPhotoPreviewUrl('');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ── ONLINE PATH (original logic) ────────────────────────────────────────────────
|
||||
notify({ title: 'Đang tải lên...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
|
||||
|
||||
try {
|
||||
// 1. Xóa token cũ nếu có để đảm bảo guest không bị nhầm lẫn
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
|
||||
// 2. Tạo tài khoản khách và lấy token
|
||||
let guestToken = localStorage.getItem('guest_token');
|
||||
let guestUser = JSON.parse(localStorage.getItem('guest_user') || 'null');
|
||||
|
||||
if (!guestToken) {
|
||||
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
|
||||
if (!guestRes.ok) throw new Error('Không thể tạo phiên khách.');
|
||||
const guestData = await guestRes.json();
|
||||
guestToken = guestData.access_token;
|
||||
guestUser = guestData.user;
|
||||
localStorage.setItem('guest_token', guestToken!);
|
||||
localStorage.setItem('guest_user', JSON.stringify(guestUser));
|
||||
// 1. Kiểm tra xem người dùng đã đăng nhập chưa
|
||||
const token = localStorage.getItem('token');
|
||||
const guestToken = localStorage.getItem('guest_token');
|
||||
const isRealUser = token && !guestToken;
|
||||
|
||||
let uploadToken = token;
|
||||
|
||||
// 2. Nếu là khách, tạo tài khoản khách và lấy token
|
||||
if (!isRealUser) {
|
||||
let currentGuestToken = guestToken;
|
||||
let guestUser = JSON.parse(localStorage.getItem('guest_user') || 'null');
|
||||
|
||||
if (!currentGuestToken) {
|
||||
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
|
||||
if (!guestRes.ok) throw new Error('Không thể tạo phiên khách.');
|
||||
const guestData = await guestRes.json();
|
||||
currentGuestToken = guestData.access_token;
|
||||
guestUser = guestData.user;
|
||||
localStorage.setItem('guest_token', currentGuestToken!);
|
||||
localStorage.setItem('guest_user', JSON.stringify(guestUser));
|
||||
}
|
||||
uploadToken = currentGuestToken;
|
||||
}
|
||||
|
||||
|
||||
// 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',
|
||||
headers: { 'Authorization': `Bearer ${guestToken!}` },
|
||||
headers: { 'Authorization': `Bearer ${uploadToken!}` },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (uploadRes.status === 401) {
|
||||
|
||||
if (uploadRes.status === 401 && !isRealUser) {
|
||||
console.warn('Guest token invalid or expired. Creating a new guest user and retrying...');
|
||||
localStorage.removeItem('guest_token');
|
||||
localStorage.removeItem('guest_user');
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
|
||||
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
|
||||
if (!guestRes.ok) throw new Error('Không thể tạo lại phiên khách.');
|
||||
const guestData = await guestRes.json();
|
||||
guestToken = guestData.access_token;
|
||||
guestUser = guestData.user;
|
||||
localStorage.setItem('guest_token', guestToken!);
|
||||
const newGuestToken = guestData.access_token;
|
||||
const guestUser = guestData.user;
|
||||
localStorage.setItem('guest_token', newGuestToken!);
|
||||
localStorage.setItem('guest_user', JSON.stringify(guestUser));
|
||||
|
||||
uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${guestToken!}` },
|
||||
headers: { 'Authorization': `Bearer ${newGuestToken!}` },
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
@@ -284,8 +427,6 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
||||
const errorData = await uploadRes.json();
|
||||
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');
|
||||
@@ -317,38 +458,39 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="h-dvh w-full overflow-hidden font-sans bg-[var(--background)] relative">
|
||||
return (
|
||||
<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 */}
|
||||
<div className={`absolute inset-0 image-pan-container ${activeSlot === 1 && fade1 ? 'block' : 'hidden'}`}>
|
||||
{bg1 && (
|
||||
<img
|
||||
<img
|
||||
key={bg1}
|
||||
src={bg1}
|
||||
src={bg1}
|
||||
draggable="false"
|
||||
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||
className={`h-full image-pan-element ${
|
||||
!isLoggedIn ? 'select-none pointer-events-none' : ''
|
||||
}`}
|
||||
alt="Travel Background 1"
|
||||
className={`h-full image-pan-element ${!isLoggedIn ? 'select-none pointer-events-none' : ''
|
||||
}`}
|
||||
alt="Travel Background 1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={`absolute inset-0 image-pan-container ${activeSlot === 2 && fade2 ? 'block' : 'hidden'}`}>
|
||||
{bg2 && (
|
||||
<img
|
||||
<img
|
||||
key={bg2}
|
||||
src={bg2}
|
||||
src={bg2}
|
||||
draggable="false"
|
||||
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||
className={`h-full image-pan-element ${
|
||||
!isLoggedIn ? 'select-none pointer-events-none' : ''
|
||||
}`}
|
||||
alt="Travel Background 2"
|
||||
className={`h-full image-pan-element ${!isLoggedIn ? 'select-none pointer-events-none' : ''
|
||||
}`}
|
||||
alt="Travel Background 2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -407,11 +549,10 @@ return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setCurrentBgIndex(idx)}
|
||||
className={`w-2 h-2 rounded-full transition-all ${
|
||||
currentBgIndex === idx
|
||||
? 'bg-emerald-500 w-6'
|
||||
className={`w-2 h-2 rounded-full transition-all ${currentBgIndex === idx
|
||||
? 'bg-emerald-500 w-6'
|
||||
: 'bg-white/40 hover:bg-white/60'
|
||||
}`}
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -423,45 +564,34 @@ return (
|
||||
<Compass className="w-7 h-7 sm:w-8 sm:h-8" />
|
||||
<span className="text-lg sm:text-xl font-black tracking-tighter uppercase hidden sm:block">YoTrip</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 sm:gap-3">
|
||||
{/* Language Selector */}
|
||||
<select
|
||||
value={lang}
|
||||
onChange={(e) => changeLanguage(e.target.value as any)}
|
||||
className="bg-black/40 hover:bg-black/60 border border-white/20 text-white rounded-full px-2 py-1 sm:px-3 sm:py-1.5 text-[11px] sm:text-xs font-bold focus:outline-none backdrop-blur-md cursor-pointer max-w-[80px] sm:max-w-none"
|
||||
>
|
||||
<option value="vi" className="text-black">Tiếng Việt</option>
|
||||
<option value="en" className="text-black">English</option>
|
||||
<option value="zh" className="text-black">中文</option>
|
||||
</select>
|
||||
|
||||
{/* Theme Selector */}
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => changeTheme(e.target.value as any)}
|
||||
className="bg-black/40 hover:bg-black/60 border border-white/20 text-white rounded-full px-2 py-1 sm:px-3 sm:py-1.5 text-[11px] sm:text-xs font-bold focus:outline-none backdrop-blur-md cursor-pointer max-w-[80px] sm:max-w-none"
|
||||
>
|
||||
<option value="light" className="text-black">{t('themeLight') || 'Sáng'}</option>
|
||||
<option value="dark" className="text-black">{t('themeDark') || 'Tối'}</option>
|
||||
<option value="system" className="text-black">{t('themeSystem') || 'Hệ thống'}</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => setIsReportModalOpen(true)}
|
||||
className="flex items-center justify-center gap-1.5 bg-red-600/80 backdrop-blur-md text-white font-bold py-1.5 px-2.5 sm:py-2 sm:px-3.5 rounded-full border border-red-500/30 hover:bg-red-500 transition-all shadow-md active:scale-95 text-xs sm:text-sm cursor-pointer"
|
||||
>
|
||||
<ShieldAlert className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||
<span className="hidden sm:inline">{t('reportBusinessBtn')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsLoginModalOpen(true)}
|
||||
className="flex items-center justify-center gap-1 sm:gap-2 bg-white/15 backdrop-blur-md text-white font-bold py-1.5 px-2.5 sm:py-2 sm:px-4 rounded-full border border-white/25 hover:bg-white/25 transition-all shadow-md active:scale-95 text-xs sm:text-sm cursor-pointer"
|
||||
>
|
||||
<LogIn className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||
<span>{t('login')}</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-3 pointer-events-auto">
|
||||
{user && !localStorage.getItem('guest_token') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setChatTargetUserId(null);
|
||||
setIsChatOpen(true);
|
||||
}}
|
||||
className="w-11 h-11 bg-slate-900 border border-slate-800 rounded-full flex items-center justify-center shadow-xl hover:bg-slate-805 text-slate-300 hover:text-white transition-all active:scale-95 cursor-pointer shrink-0 relative group"
|
||||
title="Trò chuyện trực tiếp"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 group-hover:scale-105 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-blue-500 rounded-full" />
|
||||
</button>
|
||||
)}
|
||||
<MapProfileDropdown
|
||||
user={user}
|
||||
onLogout={onLogout}
|
||||
onOpenSettings={() => setIsProfileSettingsOpen(true)}
|
||||
onOpenCreateTour={() => onGoToMap?.()}
|
||||
onOpenReport={() => setIsReportModalOpen(true)}
|
||||
onOpenLogin={() => setIsLoginModalOpen(true)}
|
||||
onOpenMyPhotos={() => setIsMyPhotosOpen(true)}
|
||||
onOpenMyTours={() => setIsMyToursOpen(true)}
|
||||
onOpenFriends={() => setIsFriendsOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -502,7 +632,7 @@ return (
|
||||
</div>
|
||||
|
||||
{/* Floating Blacklist Panel (Right Side on Desktop) */}
|
||||
<div className="absolute right-6 top-24 bottom-36 z-20 hidden md:flex flex-col w-72 bg-slate-900/60 backdrop-blur-md border border-white/10 rounded-[32px] p-5 text-white overflow-hidden shadow-2xl animate-in slide-in-from-right duration-500 animate-out duration-300">
|
||||
<div className="absolute right-6 top-24 bottom-36 z-10 hidden md:flex flex-col w-72 bg-slate-900/60 backdrop-blur-md border border-white/10 rounded-[32px] p-5 text-white overflow-hidden shadow-2xl animate-in slide-in-from-right duration-500 animate-out duration-300">
|
||||
<h3 className="text-base font-black flex items-center gap-2 mb-3 border-b border-white/10 pb-2 text-red-400">
|
||||
<ShieldAlert className="w-5 h-5 text-red-500" /> {t('blacklistTitle')}
|
||||
</h3>
|
||||
@@ -552,32 +682,32 @@ return (
|
||||
)}
|
||||
|
||||
{/* Input chọn file ẩn để chụp/chọn ảnh */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Camera input - capture="environment" for rear camera */}
|
||||
<input
|
||||
type="file"
|
||||
ref={cameraInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Gallery input - no capture attribute for file picker */}
|
||||
<input
|
||||
type="file"
|
||||
ref={galleryInputRef}
|
||||
onChange={handleFileChange}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Camera input - capture="environment" for rear camera */}
|
||||
<input
|
||||
type="file"
|
||||
ref={cameraInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Gallery input - no capture attribute for file picker */}
|
||||
<input
|
||||
type="file"
|
||||
ref={galleryInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Unified Bottom Container: Gallery thumbnails on top, action buttons below */}
|
||||
@@ -593,25 +723,22 @@ return (
|
||||
<button
|
||||
key={photo.id}
|
||||
onClick={() => setCurrentBgIndex(index)}
|
||||
className={`relative w-14 h-14 rounded-xl overflow-hidden transition-all active:scale-95 shrink-0 shadow-inner bg-slate-800 ${
|
||||
currentBgIndex === index ? 'scale-110 shadow-lg' : 'hover:scale-105'
|
||||
}`}
|
||||
className={`relative w-14 h-14 rounded-xl overflow-hidden transition-all active:scale-95 shrink-0 shadow-inner bg-slate-800 ${currentBgIndex === index ? 'scale-110 shadow-lg' : 'hover:scale-105'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={photo.imageUrl}
|
||||
alt="Community thumbnail"
|
||||
<img
|
||||
src={photo.imageUrl}
|
||||
alt="Community thumbnail"
|
||||
draggable="false"
|
||||
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||
className={`w-full h-full object-cover ${
|
||||
!isLoggedIn ? 'select-none pointer-events-none' : ''
|
||||
}`}
|
||||
className={`w-full h-full object-cover ${!isLoggedIn ? 'select-none pointer-events-none' : ''
|
||||
}`}
|
||||
/>
|
||||
{/* Border Overlay absolute to prevent border clipping or corner overlap */}
|
||||
{/* Inset by 1.5px so it does not get clipped by parent overflow-hidden border */}
|
||||
<div className={`absolute inset-[1.5px] rounded-[10px] border-2 pointer-events-none transition-colors ${
|
||||
currentBgIndex === index ? 'border-emerald-500' : 'border-white/20'
|
||||
}`} />
|
||||
<div className={`absolute inset-[1.5px] rounded-[10px] border-2 pointer-events-none transition-colors ${currentBgIndex === index ? 'border-emerald-500' : 'border-white/20'
|
||||
}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -620,7 +747,7 @@ return (
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="w-full flex gap-3 items-center justify-center">
|
||||
<button
|
||||
<button
|
||||
onClick={onGoToMap}
|
||||
className="flex-1 flex items-center justify-center gap-2 bg-emerald-600/90 hover:bg-emerald-500 text-white font-bold py-3.5 rounded-2xl transition-all shadow-2xl border border-emerald-500/30 active:scale-95 text-xs sm:text-sm whitespace-nowrap"
|
||||
>
|
||||
@@ -628,15 +755,15 @@ return (
|
||||
<span>{t('shortExplore') || 'Khám phá'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
<button
|
||||
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>
|
||||
|
||||
|
||||
<style>{`
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
@@ -647,13 +774,19 @@ return (
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login Modal Component */}
|
||||
<LoginModal
|
||||
isOpen={isLoginModalOpen}
|
||||
onClose={() => setIsLoginModalOpen(false)}
|
||||
<LoginModal
|
||||
isOpen={isLoginModalOpen}
|
||||
onClose={() => setIsLoginModalOpen(false)}
|
||||
onSwitchToSignup={onGoToSignup}
|
||||
onLoginSuccess={onLoginSuccess}
|
||||
onLoginSuccess={(loggedInUser) => {
|
||||
setIsLoginModalOpen(false);
|
||||
if (onLoginSuccess) {
|
||||
onLoginSuccess(loggedInUser);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Report Business Modal */}
|
||||
@@ -699,12 +832,16 @@ return (
|
||||
<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>
|
||||
@@ -716,7 +853,11 @@ return (
|
||||
<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"
|
||||
>
|
||||
@@ -732,6 +873,53 @@ return (
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Profile Settings Modal */}
|
||||
<ProfileSettingsModal
|
||||
isOpen={isProfileSettingsOpen}
|
||||
onClose={() => setIsProfileSettingsOpen(false)}
|
||||
user={user}
|
||||
onSaveSuccess={onLoginSuccess}
|
||||
/>
|
||||
|
||||
{/* My Tours Modal */}
|
||||
<MyToursModal
|
||||
isOpen={isMyToursOpen}
|
||||
onClose={() => setIsMyToursOpen(false)}
|
||||
user={user}
|
||||
onViewTour={(tourId) => {
|
||||
if (onGoToMap) {
|
||||
localStorage.setItem('viewTourOnLand', tourId);
|
||||
onGoToMap();
|
||||
}
|
||||
}}
|
||||
onOpenNavigation={onOpenNavigation}
|
||||
/>
|
||||
|
||||
{/* My Photos Modal */}
|
||||
<MyPhotosModal
|
||||
isOpen={isMyPhotosOpen}
|
||||
onClose={() => setIsMyPhotosOpen(false)}
|
||||
user={user}
|
||||
/>
|
||||
|
||||
{/* Live Chat Modal */}
|
||||
<LiveChatModal
|
||||
isOpen={isChatOpen}
|
||||
onClose={() => setIsChatOpen(false)}
|
||||
user={user}
|
||||
defaultChatUserId={chatTargetUserId}
|
||||
/>
|
||||
|
||||
{/* Friends Manager Modal */}
|
||||
<FriendsManagerModal
|
||||
isOpen={isFriendsOpen}
|
||||
onClose={() => setIsFriendsOpen(false)}
|
||||
user={user}
|
||||
onOpenChatWithUser={(userId) => {
|
||||
setChatTargetUserId(userId);
|
||||
setIsChatOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { io } from 'socket.io-client';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { BACKEND_URL } from '@/utils/backendEndpoint';
|
||||
import { jsPDF } from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import { robotoBase64, robotoBoldBase64 } from '../utils/pdfFont';
|
||||
@@ -399,6 +400,13 @@ export const TourDetailPage = ({
|
||||
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
|
||||
const [isAddPhotoOpen, setIsAddPhotoOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(window as any).activeTourChatTab = activeTab === 'chat';
|
||||
return () => {
|
||||
(window as any).activeTourChatTab = false;
|
||||
};
|
||||
}, [activeTab]);
|
||||
|
||||
const [isRatingModalOpen, setIsRatingModalOpen] = useState(false);
|
||||
const [ratingTargetUser, setRatingTargetUser] = useState<any>(null);
|
||||
const [ratingScores, setRatingScores] = useState({
|
||||
@@ -1566,7 +1574,7 @@ export const TourDetailPage = ({
|
||||
if (!currentTour) return;
|
||||
|
||||
const socket = Capacitor.isNativePlatform()
|
||||
? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn')
|
||||
? io(BACKEND_URL)
|
||||
: io(); // Kết nối qua Proxy của Vite (cùng origin)
|
||||
|
||||
socket.on('connect', () => {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Smart runtime backend URL resolver.
|
||||
*
|
||||
* Eliminates the need to manually update `.env` files when switching between:
|
||||
* - Docker/Linux Production Web Server → uses window.location.origin
|
||||
* - Android APK (Capacitor WebView) → forces absolute production domain
|
||||
*
|
||||
* Priority order:
|
||||
* 1. Explicit VITE_BACKEND_URL env variable (if set at build time)
|
||||
* 2. Android/Capacitor app detection → https://yotrip.labz.io.vn
|
||||
* 3. Production web server → window.location.origin
|
||||
*/
|
||||
|
||||
const PRODUCTION_DOMAIN = 'https://yotrip.labz.io.vn';
|
||||
|
||||
const resolveBackendEndpoint = (): string => {
|
||||
// 1. Honour explicit build-time env override first
|
||||
const envUrl = import.meta.env.VITE_BACKEND_URL as string | undefined;
|
||||
if (envUrl) {
|
||||
return envUrl;
|
||||
}
|
||||
|
||||
const origin = window.location.origin;
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
|
||||
// 2. Detect Android APK / Capacitor WebView environment
|
||||
// These shells run on local/file:// origins, not the production domain.
|
||||
const isAndroidApp =
|
||||
origin.startsWith('http://localhost') ||
|
||||
origin.startsWith('capacitor://') ||
|
||||
origin.startsWith('file://') ||
|
||||
(userAgent.includes('android') && !origin.includes('yotrip.labz.io.vn'));
|
||||
|
||||
if (isAndroidApp) {
|
||||
console.log('[Backend] 📱 Android/Capacitor environment detected — using production domain.');
|
||||
return PRODUCTION_DOMAIN;
|
||||
}
|
||||
|
||||
// 3. Web production server: the origin IS the backend (same Docker host)
|
||||
return origin;
|
||||
};
|
||||
|
||||
/** Resolved backend base URL for the current runtime environment. */
|
||||
export const BACKEND_URL: string = resolveBackendEndpoint();
|
||||
|
||||
console.log(`[Backend] 🌐 Active endpoint: ${BACKEND_URL}`);
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* backgroundSync.ts
|
||||
* ───────────────────────────────────────────────────────────────────────────
|
||||
* Background sync engine:
|
||||
* • Reads all pending items from IndexedDB when connectivity is restored
|
||||
* • Replays each upload as a multipart/form-data POST
|
||||
* • Removes the item from the queue on server 2xx acknowledgement
|
||||
* • Calls the optional `onSynced` callback after all items are flushed
|
||||
* (so the map/photo list can refresh reactively)
|
||||
*
|
||||
* Also exports `initNetworkStatusListeners` to wire everything up once at
|
||||
* application startup.
|
||||
*/
|
||||
|
||||
import { getPendingUploads, getPendingCount, removePendingUpload } from './offlineQueue';
|
||||
|
||||
let isSyncing = false; // guard against concurrent sync runs
|
||||
|
||||
// ─── Core sync engine ───────────────────────────────────────────────────────
|
||||
|
||||
export const executeBackgroundSyncEngine = async (
|
||||
onSynced?: () => void
|
||||
): Promise<void> => {
|
||||
if (isSyncing) return; // already running
|
||||
if (!navigator.onLine) return;
|
||||
|
||||
const pending = await getPendingUploads();
|
||||
if (pending.length === 0) return;
|
||||
|
||||
isSyncing = true;
|
||||
console.log(
|
||||
`🔄 [BackgroundSync] Internet restored — syncing ${pending.length} pending upload(s) to server…`
|
||||
);
|
||||
|
||||
let syncedCount = 0;
|
||||
|
||||
for (const item of pending) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
|
||||
// Re-attach the image file
|
||||
formData.append('images', item.fileBlob, item.fileName);
|
||||
|
||||
// Re-attach all serialised form fields (lat, lng, tags, capturedAt …)
|
||||
for (const [key, value] of Object.entries(item.formFields)) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
|
||||
const response = await fetch(item.endpoint, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${item.authToken}` },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await removePendingUpload(item.id);
|
||||
syncedCount++;
|
||||
console.log(`✅ [BackgroundSync] Photo "${item.fileName}" (${item.id}) synced.`);
|
||||
} else {
|
||||
const body = await response.text();
|
||||
console.warn(
|
||||
`⚠️ [BackgroundSync] Server rejected ${item.id} (${response.status}): ${body}`
|
||||
);
|
||||
// Don't break — try remaining items; server-rejected items stay in queue
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`✖ [BackgroundSync] Network error on ${item.id} — will retry on next reconnect:`,
|
||||
error
|
||||
);
|
||||
// Network error mid-sync: stop and wait for the next 'online' event
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
isSyncing = false;
|
||||
|
||||
if (syncedCount > 0 && onSynced) {
|
||||
console.log(`🗺 [BackgroundSync] ${syncedCount} photo(s) synced — refreshing map pins…`);
|
||||
onSynced();
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Pending count helper (for optional UI badge) ───────────────────────────
|
||||
|
||||
export const getOfflinePendingCount = getPendingCount;
|
||||
|
||||
// ─── App startup hook ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Call once during App mount.
|
||||
* Attaches the `window.online` listener AND does an immediate
|
||||
* flush in case the device was offline while the app was closed.
|
||||
*/
|
||||
export const initNetworkStatusListeners = (onSynced?: () => void): (() => void) => {
|
||||
const handleOnline = () => {
|
||||
console.log('🌐 [BackgroundSync] Network restored — triggering sync…');
|
||||
executeBackgroundSyncEngine(onSynced);
|
||||
};
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
|
||||
// Startup guard: if we're already online, attempt an immediate flush
|
||||
if (navigator.onLine) {
|
||||
// Defer slightly so React tree is fully mounted before map refreshes
|
||||
setTimeout(() => executeBackgroundSyncEngine(onSynced), 2000);
|
||||
}
|
||||
|
||||
// Return cleanup function for useEffect
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,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 });
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth';
|
||||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||||
import { BACKEND_URL } from './backendEndpoint';
|
||||
|
||||
function rewriteUrls(obj: any, backendUrl: string): any {
|
||||
if (obj === null || obj === undefined) return obj;
|
||||
@@ -25,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 {
|
||||
@@ -47,7 +49,7 @@ if (Capacitor.isNativePlatform()) {
|
||||
console.error('[LocalNotifications] Failed to request permissions:', e);
|
||||
}
|
||||
|
||||
const backendUrl = import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn';
|
||||
const backendUrl = BACKEND_URL;
|
||||
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* offlineQueue.ts
|
||||
* ───────────────────────────────────────────────────────────────────────────
|
||||
* Offline photo upload queue backed by native IndexedDB.
|
||||
* No external libraries — works in all modern browsers and Capacitor WebViews.
|
||||
*
|
||||
* Stored schema per item:
|
||||
* id — UUID generated at queue time
|
||||
* endpoint — full upload URL (e.g. https://yotrip.labz.io.vn/api/v1/photos/upload-anonymous)
|
||||
* authToken — Bearer token captured at queue time
|
||||
* fileBlob — raw Blob of the processed image
|
||||
* fileName — original filename (e.g. "photo-1718000000.jpg")
|
||||
* formFields — key-value pairs: latitude, longitude, capturedAt, tags …
|
||||
* timestamp — epoch ms when the item was queued
|
||||
*/
|
||||
|
||||
const DB_NAME = 'yotrip-offline-db';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'pending-uploads';
|
||||
|
||||
export interface OfflineUploadItem {
|
||||
id: string;
|
||||
endpoint: string;
|
||||
authToken: string;
|
||||
fileBlob: Blob;
|
||||
fileName: string;
|
||||
formFields: Record<string, string>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// ─── Internal DB bootstrap ──────────────────────────────────────────────────
|
||||
|
||||
const getDB = (): Promise<IDBDatabase> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
req.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Stash a failed upload into the local IndexedDB queue.
|
||||
* Returns the generated item id.
|
||||
*/
|
||||
export const queueOfflineUpload = async (
|
||||
data: Omit<OfflineUploadItem, 'id' | 'timestamp'>
|
||||
): Promise<string> => {
|
||||
const db = await getDB();
|
||||
const id = crypto.randomUUID();
|
||||
const item: OfflineUploadItem = { ...data, id, timestamp: Date.now() };
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.put(item);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
|
||||
console.log(`📦 [OfflineQueue] Photo "${item.fileName}" (${id}) queued for background sync.`);
|
||||
return id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve all pending items ordered by timestamp (oldest first).
|
||||
*/
|
||||
export const getPendingUploads = async (): Promise<OfflineUploadItem[]> => {
|
||||
const db = await getDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.getAll();
|
||||
req.onsuccess = () =>
|
||||
resolve((req.result as OfflineUploadItem[]).sort((a, b) => a.timestamp - b.timestamp));
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the total count of pending items (for UI badge).
|
||||
*/
|
||||
export const getPendingCount = async (): Promise<number> => {
|
||||
const db = await getDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.count();
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a successfully synced item from the queue.
|
||||
*/
|
||||
export const removePendingUpload = async (id: string): Promise<void> => {
|
||||
const db = await getDB();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.delete(id);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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",
|
||||
@@ -1120,6 +1121,7 @@
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1137,6 +1139,7 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1154,6 +1157,7 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1171,6 +1175,7 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1188,6 +1193,7 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1205,6 +1211,7 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1222,6 +1229,7 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1239,6 +1247,7 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1256,6 +1265,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1273,6 +1283,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1290,6 +1301,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1307,6 +1319,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1324,6 +1337,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1341,6 +1355,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1358,6 +1373,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1375,6 +1391,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1392,6 +1409,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1409,6 +1427,7 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1426,6 +1445,7 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1443,6 +1463,7 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1460,6 +1481,7 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1477,6 +1499,7 @@
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1494,6 +1517,7 @@
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1511,6 +1535,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1528,6 +1553,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1545,6 +1571,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7035,6 +7062,7 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build --workspace=backend && npm run build --workspace=frontend",
|
||||
"build:android": "cd frontend/android && ./gradlew assembleRelease && cd ../.. && sh scripts/deploy-apk.sh",
|
||||
"start:backend": "npm run start:dev --workspace=backend",
|
||||
"start:frontend": "npm run start:dev --workspace=frontend",
|
||||
"start:dev": "concurrently -n \"BACKEND,FRONTEND\" -c \"magenta,cyan\" \"npm run start:dev --workspace=backend\" \"npm run dev --workspace=frontend\"",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Define relative path coordinates (adjusted for monorepo structure)
|
||||
ANDROID_OUTPUT_PATH="./frontend/android/app/build/outputs/apk/release/app-release.apk"
|
||||
BACKEND_TARGET_DIR="./backend/public/downloads"
|
||||
TARGET_FILE_NAME="yotrip-latest.apk"
|
||||
|
||||
echo "🚀 Starting automated post-build Android deployment pipeline..."
|
||||
|
||||
# 1. Verify compiler target exists
|
||||
if [ -f "$ANDROID_OUTPUT_PATH" ]; then
|
||||
# 2. Ensure target storage folder structure is active
|
||||
mkdir -p "$BACKEND_TARGET_DIR"
|
||||
|
||||
# 3. Copy and force overwrite the old production bundle with the updated version
|
||||
cp -f "$ANDROID_OUTPUT_PATH" "$BACKEND_TARGET_DIR/$TARGET_FILE_NAME"
|
||||
|
||||
echo "✅ Success! New build copied safely to $BACKEND_TARGET_DIR/$TARGET_FILE_NAME"
|
||||
echo "🔗 Direct Download Link Active: /downloads/$TARGET_FILE_NAME"
|
||||
else
|
||||
echo "❌ Critical Error: Android build output artifact not found at $ANDROID_OUTPUT_PATH"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,117 @@
|
||||
# To AI Agent: Implement Server-Hosted Android APK Download Button and Automated Build Deployment Pipeline
|
||||
|
||||
## 1. Context & Feature Objective
|
||||
We are adding a native Android app distribution workflow directly from our self-hosted server backend. Instead of relying purely on app stores, users visiting the web version from an Android device must be able to download the official compiled `.apk` file directly.
|
||||
|
||||
**Objective:** 1. **Backend Asset Exposure:** Configure a secure, static file directory on the Node.js/Express server to host the production `.apk` binary.
|
||||
2. **Build Pipeline Link Automation:** Create a post-build deployment shell script. Every time a new production Android APK is generated (`release`), the script must automatically rename and copy it to the backend's public distribution folder under a persistent file pointer name (`yotrip-latest.apk`).
|
||||
3. **Frontend Action Button:** Add an interactive "Tải ứng dụng Android" action row with a download icon inside both the Member and Guest profile menu sheets.
|
||||
|
||||
---
|
||||
|
||||
## 2. Technical Architecture & Implementation Steps
|
||||
|
||||
[Android Build Output] ➔ [deploy-apk.sh Script] ➔ [Backend public/downloads/yotrip-latest.apk]
|
||||
▲
|
||||
[Frontend UI Button] ➔ ➔ ➔ [Triggers HTTP GET Request] ➔ ➔ ➔ ➔ ➔ ┛
|
||||
|
||||
### Step 1: Configure Backend Static Asset Folder
|
||||
Locate the core server setup file (e.g., `server.ts`, `app.ts`, or `index.js`). Ensure a dedicated folder path named `public/downloads` is created and mapped to express static file serving handlers:
|
||||
|
||||
```typescript
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
|
||||
const app = express();
|
||||
|
||||
// Ensure the directory exists: public/downloads/
|
||||
const downloadsDir = path.join(__dirname, '../public/downloads');
|
||||
|
||||
/* ✅ BACKEND STATIC MIDDLEWARE REGISTRATION
|
||||
This exposes the file at: [https://yourdomain.com/downloads/yotrip-latest.apk](https://yourdomain.com/downloads/yotrip-latest.apk)
|
||||
*/
|
||||
app.use('/downloads', express.static(downloadsDir, {
|
||||
setHeaders: (res) => {
|
||||
// Force browser engines to download the file directly instead of trying to parse it
|
||||
res.set('Content-Type', 'application/vnd.android.package-archive');
|
||||
res.set('Content-Disposition', 'attachment; filename="yotrip-latest.apk"');
|
||||
}
|
||||
}));
|
||||
|
||||
### Step 2: Automate APK Release Mapping Link (Post-Build Script)
|
||||
Create an automation script file named scripts/deploy-apk.sh in the root environment. This script runs instantly after your Android compiler output is generated (e.g., via Gradle ./gradlew assembleRelease or Capacitor/Cordova build actions):
|
||||
|
||||
#!/bin/bash
|
||||
|
||||
# Define relative path coordinates
|
||||
ANDROID_OUTPUT_PATH="./android/app/build/outputs/apk/release/app-release.apk"
|
||||
BACKEND_TARGET_DIR="./backend/public/downloads"
|
||||
TARGET_FILE_NAME="yotrip-latest.apk"
|
||||
|
||||
echo "🚀 Starting automated post-build Android deployment pipeline..."
|
||||
|
||||
# 1. Verify compiler target exists
|
||||
if [ -f "$ANDROID_OUTPUT_PATH" ]; then
|
||||
# 2. Ensure target storage folder structure is active
|
||||
mkdir -p "$BACKEND_TARGET_DIR"
|
||||
|
||||
# 3. Copy and force overwrite the old production bundle with the updated version
|
||||
cp -f "$ANDROID_OUTPUT_PATH" "$BACKEND_TARGET_DIR/$TARGET_FILE_NAME"
|
||||
|
||||
echo "✅ Success! New build copied safely to $BACKEND_TARGET_DIR/$TARGET_FILE_NAME"
|
||||
echo "🔗 Direct Download Link Active: /downloads/$TARGET_FILE_NAME"
|
||||
else
|
||||
echo "❌ Critical Error: Android build output artifact not found at $ANDROID_OUTPUT_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Add "build:android": "cd android && ./gradlew assembleRelease && cd .. && sh scripts/deploy-apk.sh" inside package.json scripts matrix for unified execution hooks.
|
||||
|
||||
### Step 3: Add Download Trigger into Frontend UI (MapProfileDropdown.tsx)
|
||||
Locate the unified dropdown menu component created in the previous layout consolidation phase. Inject the direct-download operational action rows:
|
||||
|
||||
// Define the static destination asset link helper
|
||||
const APK_DOWNLOAD_URL = `${process.env.REACT_APP_API_BASE_URL || ''}/downloads/yotrip-latest.apk`;
|
||||
|
||||
/* --- INSIDE AUTHENTICATED MEMBER STACK SECTION --- */
|
||||
<div className="flex flex-col space-y-1">
|
||||
{/* Existing Dashboard, Profile, Create Tour rows... */}
|
||||
|
||||
{/* NEW: ANDROID APK DIRECT DOWNLOAD BUTTON */}
|
||||
<a
|
||||
href={APK_DOWNLOAD_URL}
|
||||
download="yotrip.apk"
|
||||
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-lg text-left text-xs text-slate-200 transition-colors"
|
||||
>
|
||||
<svg xmlns="[http://www.w3.org/2000/svg](http://www.w3.org/2000/svg)" className="w-4 h-4 text-green-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<span>Tải ứng dụng Android (APK)</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
/* --- INSIDE ANONYMOUS GUEST STACK SECTION --- */
|
||||
<div className="flex flex-col space-y-1">
|
||||
{/* Existing Guest language/theme configurations... */}
|
||||
|
||||
<div className="h-[1px] bg-slate-800 my-1 mx-2" />
|
||||
|
||||
{/* NEW: GUEST STATE ANDROID APK DOWNLOAD BUTTON */}
|
||||
<a
|
||||
href={APK_DOWNLOAD_URL}
|
||||
download="yotrip.apk"
|
||||
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-lg text-left text-xs text-slate-200 transition-colors"
|
||||
>
|
||||
<svg xmlns="[http://www.w3.org/2000/svg](http://www.w3.org/2000/svg)" className="w-4 h-4 text-green-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>Cài đặt bản Android (.APK)</span>
|
||||
</a>
|
||||
|
||||
{/* Existing Register/Login Button row below... */}
|
||||
</div>
|
||||
|
||||
## 3. Verification & Acceptance Criteria for AI Agent
|
||||
[ ] Deployment Script Validation: Run the build sequence script. Confirm that backend/public/downloads/yotrip-latest.apk updates its file modification timestamp matching the compiler execution timing logs.
|
||||
[ ] Direct Download Header Safety: Trigger a request to GET /downloads/yotrip-latest.apk. The network tab response must show content-type: application/vnd.android.package-archive to guarantee mobile devices instantly trigger package installation workflows.
|
||||
[ ] UI Integrity Test: Open the drop menu layout panel on a mobile simulator frame. Confirm that clicking the text icon row acts as a standard link target that downloads the binary file smoothly without breaking route navigation states.
|
||||