From 4ee46371fa092a8cf187b3bd786ea6e6e46acbf6 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Sat, 27 Jun 2026 16:53:19 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20g=E1=BB=99p=20c=C3=A1c=20n=C3=BAt=20th?= =?UTF-8?q?=C3=A0nh=20vi=C3=AAn=20v=C3=A0=20b=E1=BB=8F=20MemberDashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGE_BUTTON.md | 226 -------- MEMBER_BUTTON.md | 129 +++++ backend/src/main.ts | 10 +- frontend/src/App.tsx | 92 +--- .../src/components/MapProfileDropdown.tsx | 97 ++-- frontend/src/components/MyPhotosModal.tsx | 206 +++++++ frontend/src/components/MyToursModal.tsx | 179 ++++++ .../components/modals/FriendsManagerModal.tsx | 442 +++++++++++++++ .../src/components/modals/LiveChatModal.tsx | 514 ++++++++++++++++++ .../src/components/modals/MyToursModal.tsx | 308 +++++++++++ .../components/modals/PhotoGalleryModal.tsx | 496 +++++++++++++++++ frontend/src/pages/ExploreMap.tsx | 76 ++- frontend/src/pages/LandingPage.tsx | 348 ++++++------ frontend/src/pages/MemberDashboard.tsx | 12 +- 14 files changed, 2638 insertions(+), 497 deletions(-) delete mode 100644 CHANGE_BUTTON.md create mode 100644 MEMBER_BUTTON.md create mode 100644 frontend/src/components/MyPhotosModal.tsx create mode 100644 frontend/src/components/MyToursModal.tsx create mode 100644 frontend/src/components/modals/FriendsManagerModal.tsx create mode 100644 frontend/src/components/modals/LiveChatModal.tsx create mode 100644 frontend/src/components/modals/MyToursModal.tsx create mode 100644 frontend/src/components/modals/PhotoGalleryModal.tsx diff --git a/CHANGE_BUTTON.md b/CHANGE_BUTTON.md deleted file mode 100644 index 4c73d4f..0000000 --- a/CHANGE_BUTTON.md +++ /dev/null @@ -1,226 +0,0 @@ -# To AI Agent: Consolidate Map Top-Bar into Unified Profile/Guest Dropdown Menu and Create Profile Modals - -## 1. Context & Architectural Refactor Goal -We are optimizing the overcrowded top-bar control deck on the main Map Explorer page (`image_55397c.png`). Currently, a long row of utility buttons compromises mobile readability and viewport real estate. - -**Objective:** Consolidate all secondary control buttons into a single **Avatar/Guest Profile Button**. -- When clicked, it will open an adaptive dropdown action menu (or a smooth mobile bottom-sheet) customized dynamically based on the session authentication state (**Authenticated Member** vs. **Anonymous Guest**). -- Migrate the global "Language" and "Theme/Interface" buttons from the old top-bar directly into this new profile configuration lifecycle. - ---- - -## 2. Authentication State Menus Specification - -### 2.1. State A: Authenticated Member Dropdown (User is logged in) -Display the user's fetched avatar image (`user.avatarUrl`). Clicking it reveals these options: -1. **Bảng điều khiển (Dashboard):** [Icon: LayoutDashboard] Redirects to user command center. *Note: Global language/theme quick-toggles are moved inside here/settings.* -2. **Cài đặt cá nhân (Settings):** [Icon: Settings] Opens the comprehensive **`ProfileSettingsModal.tsx`**. -3. **Tạo tour (Create Tour):** [Icon: Compass / MapPin] Launches the step-by-step route wizard. -4. **Báo cáo vi phạm (Report):** [Icon: ShieldAlert] Opens an infraction query form. ---- (Visual Divider Line) --- -5. **Đăng xuất (Logout):** [Icon: LogOut] Standard session termination trigger. - -### 2.2. State B: Anonymous Guest Dropdown (User is not logged in) -Display a default vector Guest Icon. Clicking it reveals these options: -1. **Cài đặt (Settings):** [Icon: Sliders] Opens a lightweight configuration block allowing the Guest to dynamically choose **Ngôn ngữ** (Language) and **Giao diện** (Light/Dark Theme) stored in local storage cache. ---- (Visual Divider Line) --- -2. **Đăng ký / Đăng nhập (Auth):** [Icon: LogIn] Spawns the central authentication entry shield modal. - ---- - -## 3. Component Blueprints & Implementation Steps - -### Step 1: Create the Unified Top-Bar Controller (`MapProfileDropdown.tsx`) -Deploy this highly accessible responsive menu container. On screen dimensions mimicking mobile/Android viewports, it is highly recommended to render this as a slick **Bottom-Sheet Overlay** for better thumb-touch accuracy. - -```typescript -import React, { useState } from 'react'; -import { useAuth } from '../context/AuthContext'; // Leverage existing state system -import { LayoutDashboard, Settings, Compass, ShieldAlert, LogOut, LogIn, Sliders } from 'lucide-react'; - -export const MapProfileDropdown: React.FC<{ onOpenSettings: () => void }> = ({ onOpenSettings }) => { - const { isAuthenticated, user, logout, loginRedirect } = useAuth(); - const [isOpen, setIsOpen] = useState(false); - - return ( -
- {/* TRIGGER BUTTON */} - - - {/* DROPDOWN MENU / MOBILE BOTTOM SHEET LAYER */} - {isOpen && ( - <> - {/* Backdrop screen closer click-catcher */} -
setIsOpen(false)} /> - -
- {isAuthenticated ? ( - /* --- LOGGED IN MENU OPTION ROW STACK --- */ -
- - - - -
- -
- ) : ( - /* --- GUEST ANONYMOUS MENU OPTION ROW STACK --- */ -
-
Tùy chỉnh nhanh
- {/* Guest Quick Config Modules */} -
-
- Ngôn ngữ: - -
-
- Giao diện: - -
-
-
- -
- )} -
- - )} -
- ); -}; - -### Step 2: Implement the Main Profile Modification Panel (ProfileSettingsModal.tsx) -Create this secure responsive form layer. It must conform to absolute constraint logic (such as locking email changes) and include select toggles for app themes. - -import React, { useState } from 'react'; -import { X, Camera } from 'lucide-react'; - -export const ProfileSettingsModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose }) => { - if (!isOpen) return null; - - return ( -
-
- - {/* Header bar */} -
- Chỉnh sửa hồ sơ cá nhân - -
- - {/* Scrollable Form Workspace Canvas */} -
- - {/* Avatar Upload Container Component */} -
-
- Profile View - -
- Chạm ảnh để tải lên hình đại diện mới -
- - {/* Text Input Row Fields */} -
-
- - -
-
- - -
-
- -
- - -
- - {/* EMAIL COMPONENT BOUNDARY - CRITICAL REQUIREMENT: DISABLED CHANGING */} -
- - -
- - {/* Security Password Mutation Stack */} -
- Thay đổi mật khẩu đăng nhập -
- - -
-
- - {/* Core Configuration Toggles (Moved from Top-Bar into profile settings context) */} -
-
- - -
-
- - -
-
- -
- - {/* Action Bottom Save Trigger */} -
- - -
- -
-
- ); -}; - -## 4. Quality Verification & Acceptance Checklist for AI Agent - - [ ] Top-Bar Streamlining: Verify that old utility layout buttons boxed inside the top-bar (image_55397c.png) are safely stripped out, leaving only the Back button, Title, and the new single Dropdown icon asset. - - [ ] Cross-Platform Android Touch Targets: On mobile browser layouts, clicking the avatar must expand a screen-bottom popup block container (fixed bottom-0 left-0 right-0) ensuring an native-app tactile interaction. - - [ ] Email Immutability Guard: Inspect the editing form inside ProfileSettingsModal.tsx. Confirm the input node for the user's profile email address possess the disabled state attribute flag so typing alterations are completely locked out. - - [ ] System State Isolation: Confirm changes inside language and theme inputs successfully bubble triggers upwards to refresh local storage parameters instantly without dropping routing view connections. \ No newline at end of file diff --git a/MEMBER_BUTTON.md b/MEMBER_BUTTON.md new file mode 100644 index 0000000..c353753 --- /dev/null +++ b/MEMBER_BUTTON.md @@ -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 `` 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 ( +
+ + {/* 1. Nút "Tạo tour" */} + + + {/* 2. Nút "Hành trình của tôi" */} + + + {/* 3. Nút "Thư viện ảnh" */} + + + {/* 4. Nút "Cài đặt" */} + + + {/* 5. Nút "Báo cáo vi phạm" */} + + +
+ + {/* 6. Nút "Danh sách bạn bè" */} + + + {/* 7. Nút "Đăng xuất" */} + + +
+ ); +}; + +## 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. diff --git a/backend/src/main.ts b/backend/src/main.ts index 7067ca1..8342255 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -2184,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); @@ -2324,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({ @@ -2347,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({ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5b34fb2..ae3890a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,7 +6,6 @@ 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'; @@ -139,7 +138,7 @@ function App() { ); const [currentTourId, setCurrentTourId] = useState(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); useEffect(() => { @@ -161,7 +160,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) { @@ -191,7 +189,7 @@ function App() { if (loggedInUser.isAdmin) { setCurrentPage('admin'); } else { - setCurrentPage('dashboard'); + setCurrentPage('landing'); } } else { setCurrentPage('landing'); @@ -210,14 +208,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 } }; @@ -228,10 +219,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'); }; @@ -296,30 +288,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 = () => { @@ -347,32 +320,7 @@ function App() { return ( - ); - } - - 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 ( - setCurrentPage('signup')} - /> - ); - } - - return ( - setCurrentPage('explore')} - onViewTour={handleViewTour} - onOpenMyPhotos={() => setCurrentPage('myPhotos')} + onNavigate={(page) => setCurrentPage(page as any)} /> ); } @@ -384,7 +332,10 @@ function App() { onBack={handleBackFromTourDetail} isPublicView={isPublicTourView} onOpenNotes={() => setCurrentPage('notes')} - onOpenNavigationPage={handleOpenNavigationPage} + onOpenNavigationPage={(routeData) => handleOpenNavigationPage({ + tourId: currentTourId!, + ...routeData + })} /> ); } @@ -413,9 +364,9 @@ function App() { onLogout={handleLogout} user={user} onViewTour={handleViewTour} - onOpenMyPhotos={() => setCurrentPage('myPhotos')} onLoginSuccess={handleLoginSuccess} onGoToDashboard={handleGoToDashboard} + onOpenNavigation={handleOpenNavigationPage} /> ); } @@ -457,7 +408,18 @@ function App() { ); } - return setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />; + return ( + setCurrentPage('explore')} + onGoToSignup={() => setCurrentPage('signup')} + onGoToMap={() => setCurrentPage('explore')} + onLoginSuccess={handleLoginSuccess} + user={user} + onLogout={handleLogout} + onGoToDashboard={handleGoToDashboard} + onOpenNavigation={handleOpenNavigationPage} + /> + ); })()} diff --git a/frontend/src/components/MapProfileDropdown.tsx b/frontend/src/components/MapProfileDropdown.tsx index 4edd553..5360c6d 100644 --- a/frontend/src/components/MapProfileDropdown.tsx +++ b/frontend/src/components/MapProfileDropdown.tsx @@ -1,29 +1,31 @@ import React, { useState } from 'react'; -import { LayoutDashboard, Settings, Compass, ShieldAlert, LogOut, LogIn, Globe, Sun, Moon, Image as ImageIcon } from 'lucide-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; - onGoToDashboard?: () => void; onOpenSettings: () => void; onOpenCreateTour: () => void; onOpenReport: () => void; onOpenLogin: () => void; - onOpenMyPhotos?: () => void; + onOpenMyPhotos: () => void; + onOpenMyTours: () => void; + onOpenFriends: () => void; onOpenAdmin?: () => void; } export const MapProfileDropdown: React.FC = ({ user, onLogout, - onGoToDashboard, onOpenSettings, onOpenCreateTour, onOpenReport, onOpenLogin, onOpenMyPhotos, + onOpenMyTours, + onOpenFriends, onOpenAdmin, }) => { const [isOpen, setIsOpen] = useState(false); @@ -44,6 +46,11 @@ export const MapProfileDropdown: React.FC = ({ ); }; + const handleItemClick = (callback: () => void) => { + setIsOpen(false); + callback(); + }; + return (
{/* TRIGGER BUTTON */} @@ -69,17 +76,18 @@ export const MapProfileDropdown: React.FC = ({ {isOpen && ( <>
setIsOpen(false)} />
{isAuthenticated ? (
+ {/* User info header */}
{user?.avatar ? ( @@ -94,57 +102,73 @@ export const MapProfileDropdown: React.FC = ({
+ {/* Extra System Admin option */} {user?.isAdmin && ( )} + {/* 1. Tạo tour */} + {/* 2. Hành trình của tôi */} - - + {/* 3. Thư viện ảnh */} + {/* 4. Cài đặt */} -
- + {/* 5. Báo cáo vi phạm */} + + {/* STRICT VISUAL DIVIDER LINE */} +
+ + {/* 6. Danh sách bạn bè */} + + + {/* 7. Đăng xuất */} +
) : ( @@ -188,17 +212,10 @@ export const MapProfileDropdown: React.FC = ({
- -
+
+ + {/* Content body */} +
+ {isLoading ? ( +
+ + Đang tải thư viện ảnh của bạn... +
+ ) : photos.length === 0 ? ( +
+
+ +
+
+
Thư viện ảnh trống
+

Bạn chưa đăng tải bức ảnh nào trong các chuyến đi của mình.

+
+
+ ) : ( +
+ {photos.map((photo) => { + const dateStr = photo.capturedAt ? new Date(photo.capturedAt).toLocaleDateString('vi-VN') : ''; + return ( +
+ Gallery Asset + + {/* Hover controls overlay */} +
+
+ + +
+ +
+ {photo.tour?.title && ( +
+ + {photo.tour.title} +
+ )} +
+ + {dateStr} +
+
+
+
+ ); + })} +
+ )} +
+ +
+ + {/* Fullscreen Preview overlay */} + {selectedPhoto && ( +
+ {/* Close trigger top bar */} +
+
+ {selectedPhoto.tour?.title || 'Xem ảnh'} +
+ +
+ + {/* Fullscreen Photo view */} +
+ Fullscreen Preview +
+ + {/* Action bottom bar */} +
+ +
+
+ )} +
+ ); +}; diff --git a/frontend/src/components/MyToursModal.tsx b/frontend/src/components/MyToursModal.tsx new file mode 100644 index 0000000..a45506d --- /dev/null +++ b/frontend/src/components/MyToursModal.tsx @@ -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 = ({ + 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(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 ( +
+
+ + {/* Header */} +
+ + Hành trình của tôi + + +
+ + {/* Content body */} +
+ {isLoading ? ( +
+ + Đang tải danh sách chuyến đi... +
+ ) : myTours.length === 0 ? ( +
+
+ +
+
+
Chưa có hành trình nào
+

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!

+
+
+ ) : ( +
+ {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 ( +
+
+
+

{tour.title}

+
+ + + {startDateStr} - {endDateStr} + + + + {tour.participants?.length || 0} thành viên + +
+
+
+ +
+ {isOwner && ( + + )} + + +
+
+ ); + })} +
+ )} +
+ +
+
+ ); +}; diff --git a/frontend/src/components/modals/FriendsManagerModal.tsx b/frontend/src/components/modals/FriendsManagerModal.tsx new file mode 100644 index 0000000..c00c095 --- /dev/null +++ b/frontend/src/components/modals/FriendsManagerModal.tsx @@ -0,0 +1,442 @@ +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 = ({ + 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([]); + const [isSearching, setIsSearching] = useState(false); + + // Connection Lists + const [connections, setConnections] = useState([]); + 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(); + setConnections(data || []); + } + } catch (e) { + console.error('[FriendsManagerModal] Error fetching connections:', e); + } 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 = connections.filter((c: any) => c.status === 'ACCEPTED'); + + // Received pending requests + const pendingRequests = connections.filter((c: any) => c.status === 'PENDING' && c.targetUser?.id === user?.id); + + const getStatusText = (targetUserId: string) => { + const existing = connections.find( + c => c.targetUser?.id === targetUserId || c.requester?.id === targetUserId + ); + 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 ( +
+
+ + {/* Header */} +
+ + Danh sách bạn bè + + +
+ + {/* Tab Selection Header */} +
+ {[ + { 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 ( + + ); + })} +
+ + {/* Content body */} +
+ {isLoading && activeTab !== 'search' ? ( +
+ + Đang tải dữ liệu... +
+ ) : activeTab === 'friends' ? ( + activeFriends.length === 0 ? ( +
+
+ +
+
+
Chưa có bạn bè kết nối
+

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.

+
+
+ ) : ( +
+ {activeFriends.map((conn) => { + const friend = conn.targetUser?.id === user?.id ? conn.requester : conn.targetUser; + return ( +
+
+
+ {friend.avatar ? ( + Avatar + ) : ( + {friend.name.charAt(0).toUpperCase()} + )} +
+
+
{friend.name}
+

{friend.email}

+
+
+ +
+ + +
+
+ ); + })} +
+ ) + ) : activeTab === 'pending' ? ( + pendingRequests.length === 0 ? ( +
+
+ +
+
+
Không có lời mời kết bạn
+

Bạn không có lời mời kết bạn nào đang chờ duyệt.

+
+
+ ) : ( +
+ {pendingRequests.map((conn) => { + const requester = conn.requester; + return ( +
+
+
+ {requester.avatar ? ( + Avatar + ) : ( + {requester.name.charAt(0).toUpperCase()} + )} +
+
+
{requester.name}
+

{requester.email}

+
+
+ +
+ + +
+
+ ); + })} +
+ ) + ) : ( + // Search Tab panel +
+
+
+ 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" + /> + +
+ +
+ +
+ {isSearching ? ( +
+ +
+ ) : searchResults.length === 0 ? ( + searchQuery.trim() && ( +
+ Không tìm thấy kết quả phù hợp. +
+ ) + ) : ( + searchResults.map((u) => { + const status = getStatusText(u.id); + return ( +
+
+
+ {u.avatar ? ( + Avatar + ) : ( + {u.name.charAt(0).toUpperCase()} + )} +
+
+
{u.name}
+

{u.email}

+
+
+ +
+ {status === 'FRIEND' ? ( + + Đã kết nối + + ) : status === 'SENT' || u.pendingSent ? ( + + Đã gửi lời mời + + ) : status === 'RECEIVED' ? ( + + Chờ bạn duyệt + + ) : ( + + )} +
+
+ ); + }) + )} +
+
+ )} +
+ +
+
+ ); +}; diff --git a/frontend/src/components/modals/LiveChatModal.tsx b/frontend/src/components/modals/LiveChatModal.tsx new file mode 100644 index 0000000..3e4047f --- /dev/null +++ b/frontend/src/components/modals/LiveChatModal.tsx @@ -0,0 +1,514 @@ +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 = ({ + isOpen, + onClose, + user, + defaultChatUserId, +}) => { + const notify = useNotification(); + + const [connections, setConnections] = useState([]); + const [activeChatUser, setActiveChatUser] = useState(null); + const [chatMessages, setChatMessages] = useState([]); + 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(null); + const [attachedImageUrl, setAttachedImageUrl] = useState(null); + const [attachedLocation, setAttachedLocation] = useState<{ latitude: number; longitude: number } | null>(null); + + const fileInputRef = useRef(null); + const messagesEndRef = useRef(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 activeConns = data.filter((c: any) => c.status === 'ACCEPTED'); + setConnections(activeConns); + } + } catch (e) { + console.error('[LiveChatModal] Error fetching connections:', e); + } 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) => { + 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 = connections.filter((conn: any) => { + const friend = conn.targetUser?.id === user?.id ? conn.requester : conn.targetUser; + return friend?.name?.toLowerCase().includes(searchQuery.toLowerCase()); + }); + + return ( +
+
+ + {/* Header */} +
+ + Trò chuyện trực tiếp + + +
+ + {/* Workspace panel */} +
+ + {/* Left Column - Contacts Sidebar */} +
+
+
+ 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" + /> + +
+
+ +
+ {isLoadingContacts ? ( +
+ +
+ ) : filteredConnections.length === 0 ? ( +
+ Không tìm thấy bạn bè nào. +
+ ) : ( + filteredConnections.map((conn: any) => { + const friend = conn.targetUser?.id === user?.id ? conn.requester : conn.targetUser; + const isActive = activeChatUser?.id === friend.id; + + return ( + + ); + }) + )} +
+
+ + {/* Right Column - Active Chat View */} +
+ {activeChatUser ? ( + <> + {/* Active contact bar */} +
+
+ {activeChatUser.avatar ? ( + Avatar + ) : ( + {activeChatUser.name.charAt(0).toUpperCase()} + )} +
+
+
{activeChatUser.name}
+
+ Đang hoạt động +
+
+
+ + {/* Messages stream */} +
+ {isLoadingMessages ? ( +
+ +
+ ) : chatMessages.length === 0 ? ( +
+ + Hãy gửi tin nhắn để bắt đầu cuộc trò chuyện. +
+ ) : ( + 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 ( +
+
+
+ {/* Attached Image */} + {msg.mediaUrl && ( +
+ Chat attachment window.open(msg.mediaUrl, '_blank')} + /> +
+ )} + + {/* Attached Location */} + {msg.latitude && msg.longitude && ( + + + Vị trí: {msg.latitude.toFixed(5)}, {msg.longitude.toFixed(5)} + + )} + + {msg.content &&

{msg.content}

} +
+ + {dateStr} + +
+
+ ); + }) + )} +
+
+ + {/* Bottom Input Area */} +
+ {/* Attachment Previews */} + {(attachedImageUrl || attachedLocation) && ( +
+ {attachedImageUrl && ( +
+ Preview + +
+ )} + + {attachedLocation && ( +
+ + Vị trí GPS + +
+ )} +
+ )} + +
+ + + + + + 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" + /> + + +
+
+ + ) : ( +
+
+ +
+
+
Chưa chọn bạn hội thoại
+

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.

+
+
+ )} +
+ +
+ +
+
+ ); +}; diff --git a/frontend/src/components/modals/MyToursModal.tsx b/frontend/src/components/modals/MyToursModal.tsx new file mode 100644 index 0000000..a58c624 --- /dev/null +++ b/frontend/src/components/modals/MyToursModal.tsx @@ -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 = ({ + 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(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 ( +
+
+ + {/* Header */} +
+ + Hành trình của tôi + + +
+ + {/* Tab Selection Header */} +
+ {[ + { 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 ( + + ); + })} +
+ + {/* Content body */} +
+ {isLoading ? ( +
+ + Đang tải danh sách chuyến đi... +
+ ) : currentList.length === 0 ? ( +
+
+ +
+
+
Chưa có hành trình nào
+

Bạn không có chuyến đi nào trong mục này.

+
+
+ ) : ( +
+ {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 ( +
+ {/* Banner cover */} +
+ Cover Banner +
+ +
+

{tour.title}

+

{startDateStr} - {endDateStr}

+
+
+ + {/* Progress details */} +
+ {activeTab === 'ongoing' && ( +
+
+ Tiến độ hành trình + {progress}% +
+
+
+
+
+ )} + +
+ + + {tour.participants?.length || 0} thành viên + + + + {tour.legs?.length || 0} chặng đi + +
+ +
+ {isOwner && ( + + )} + + + + +
+
+
+ ); + })} +
+ )} +
+ +
+
+ ); +}; diff --git a/frontend/src/components/modals/PhotoGalleryModal.tsx b/frontend/src/components/modals/PhotoGalleryModal.tsx new file mode 100644 index 0000000..b4f87ca --- /dev/null +++ b/frontend/src/components/modals/PhotoGalleryModal.tsx @@ -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 = ({ + isOpen, + onClose, + user, +}) => { + const notify = useNotification(); + const confirm = useConfirm(); + + const [photos, setPhotos] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + // Filtering states + const [filterTourId, setFilterTourId] = useState('all'); + const [filterTag, setFilterTag] = useState('all'); + + // Preview / Editor States + const [selectedPhoto, setSelectedPhoto] = useState(null); + const [editingPhotoId, setEditingPhotoId] = useState(null); + const [editTitle, setEditTitle] = useState(''); + const [editDescription, setEditDescription] = useState(''); + const [editTags, setEditTags] = useState([]); + const [isSaving, setIsSaving] = useState(false); + const [isDeletingId, setIsDeletingId] = useState(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 ( +
+
+ + {/* Header */} +
+ + Thư viện ảnh + + +
+ + {/* Dual Filter Header Select Pinned Matrix */} +
+
+ + +
+ +
+ + +
+
+ + {/* Content body grid workspace */} +
+ {isLoading ? ( +
+ + Đang tải thư viện ảnh... +
+ ) : filteredPhotos.length === 0 ? ( +
+
+ +
+
+
Không tìm thấy bức ảnh nào
+

Không có ảnh nào khớp với bộ lọc hiện tại.

+
+
+ ) : ( +
+ {filteredPhotos.map((photo) => { + const photoTagsList = photo.metadata?.tags || []; + + return ( +
+ Gallery Item + + {/* Standard tags badge dot count */} + {photoTagsList.length > 0 && ( +
+ + {photoTagsList.length} tags +
+ )} + + {/* Hover controls overlay */} +
+
+ + + +
+ +
+
+ {photo.metadata?.title || 'Chưa đặt tiêu đề'} +
+ {photo.tour?.title && ( +
+ + {photo.tour.title} +
+ )} +
+
+
+ ); + })} +
+ )} +
+ +
+ + {/* Editor & Tag modification Dialog */} + {editingPhotoId && ( +
+
+
+ Chỉnh sửa thông tin ảnh + +
+ +
+
+ + 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" + /> +
+ +
+ +