14 Commits

Author SHA1 Message Date
3dtours e34d197dd0 fix: sửa lỗi hiển thị thumbnail khi chia sẻ trên facebook 2026-06-20 14:44:31 +07:00
3dtours fdf41e05fc fix: sửa lỗi hiển thị trên browser của điện thoại 2026-06-20 12:19:30 +07:00
3dtours ae74035ad5 fix: cho phép người đã tải ảnh lên được quyền chỉnh sửa 2026-06-20 08:27:58 +07:00
3dtours ac11bb56db fix: bố cục lại cách hiển thị thông tin ảnh 2026-06-20 08:04:12 +07:00
3dtours 5081546cdf feat: cho phép người dùng chọn tọa độ trên bản đồ khi sửa ảnh 2026-06-20 07:40:50 +07:00
3dtours 9da8a9494d feat: cho phép người dùng chỉnh sửa thông tin ảnh đã upload 2026-06-20 07:37:01 +07:00
3dtours 4373ed684a fix: ảnh phải giữ nguyên hướng chụp 2026-06-20 07:28:28 +07:00
3dtours def0e0d0f9 fix: sửa lỗi các nút bị che dưới thanh địa chỉ của chrome, hỗ trợ iphone upload ảnh heic 2026-06-20 07:05:25 +07:00
3dtours 36e8658dad fix: sửa lỗi iphone ko thể upload 2026-06-19 22:49:51 +07:00
3dtours 6c1b77d7d2 feat: tải ảnh lên bằng tài khoản public và cho phép bình luận 2026-06-19 22:38:16 +07:00
3dtours 4640526e4b fix: thay đổi ảnh nền trang chủ 2026-06-19 12:38:17 +07:00
3dtours c7692ab9b6 fix: thay đổi giao diện trang chủ 2026-06-19 12:00:19 +07:00
3dtours 36fbbb9386 fix: remove leaflet liên kết trên bản đồ 2026-06-19 10:34:03 +07:00
3dtours ef11309399 feat: chuẩn bị backend cho tính năng anonymous user upload ảnh không cần đăng nhập 2026-06-19 08:46:59 +07:00
42 changed files with 4519 additions and 345 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
{
"continue.enableConsole": true
"continue.enableConsole": true,
"remote.autoForwardPortsFallback": 0
}
+3 -2
View File
@@ -5,8 +5,8 @@ export declare class JwtStrategy extends JwtStrategy_base {
constructor(prisma: PrismaService);
validate(payload: any): Promise<{
id: string;
email: string;
passwordHash: string;
email: string | null;
passwordHash: string | null;
name: string | null;
phone: string | null;
address: string | null;
@@ -14,6 +14,7 @@ export declare class JwtStrategy extends JwtStrategy_base {
createdAt: Date;
isAdmin: boolean;
isBlocked: boolean;
isAnonymous: boolean;
}>;
}
export {};
+2
View File
@@ -24,5 +24,7 @@ export declare class CommentGateway implements OnGatewayConnection {
server: Server;
handleConnection(client: Socket): void;
handleJoinTour(client: Socket, tourId: string): void;
handleJoinPhoto(client: Socket, photoId: string): void;
notifyNewComment(tourId: string, data: any): void;
notifyNewPhotoComment(photoId: string, data: any): void;
}
+791 -23
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long
+80
View File
@@ -0,0 +1,80 @@
> backend@0.0.1 start:dev
> nest start --watch
[10:46:05 PM] Starting compilation in watch mode...
[10:46:08 PM] Found 0 errors. Watching for file changes.
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [NestFactory] Starting Nest application...
--- [PRISMA CHECK] ---
DATABASE_URL nhận được: ĐÃ ĐỌC THÀNH CÔNG ✔️
----------------------
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] ConfigHostModule dependencies initialized +40ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] JwtModule dependencies initialized +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] ConfigModule dependencies initialized +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] CacheModule dependencies initialized +15ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] AppModule dependencies initialized +2ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [WebSocketsController] CommentGateway subscribed to the "joinTour" message +11ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [WebSocketsController] CommentGateway subscribed to the "joinPhoto" message +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] AppController {/api/v1}: +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1, GET} route +2ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] AuthController {/api/v1/auth}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/convert-guest, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/create-guest, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/status, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/login, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/signup/request, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/signup/verify, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] PublicTourController {/api/v1/tours}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id/public, GET} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] TourController {/api/v1/tours}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/locations, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/start-point, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/end-point, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/legs/batch, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/legs, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id, PATCH} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/explore, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/members, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests, GET} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests/:requestId/accept, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests/:requestId/reject, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/members/:userId, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/photos, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] UserController {/api/v1/users}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/me/photos, GET} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/:id, PATCH} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/block/:id, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] RoutingController {/api/v1/routing}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/routing/optimize/:legId, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] LegController {/api/v1/legs}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/legs/:id, PATCH} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/legs/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] LocationController {/api/v1/locations}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:id, PATCH} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:id, DELETE} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] CommentController {/api/v1/locations}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:locationId/comments, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:locationId/comments, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] PhotoController {/api/v1/photos}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/photos/upload-anonymous, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/photos/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] PublicPhotoController {/api/v1/public-photos}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/public-photos, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/public-photos/:photoId/comments, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/public-photos/:photoId/comments, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] AdminOtpController {/api/v1/admin/otp}: +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/admin/otp/send, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/admin/otp/verify, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [NestApplication] Nest application successfully started +16ms
🚀 Server is running on: http://localhost:3001
[EmailService] ✅ Kết nối SMTP thành công. Sẵn sàng gửi mail.
[WS] Client connected: sIV1IoQvXb-xaa9iAAAC
[WS] Client sIV1IoQvXb-xaa9iAAAC joined room: photo_557913e6-fcb4-4dae-8cff-a6ff52da3386
+2
View File
@@ -34,6 +34,8 @@
"cache-manager": "^7.2.8",
"cache-manager-redis-yet": "^5.1.5",
"dotenv": "^17.4.2",
"exifr": "^7.1.3",
"heic-convert": "^2.1.0",
"nodemailer": "^9.0.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
@@ -0,0 +1,15 @@
-- DropForeignKey
ALTER TABLE "Photo" DROP CONSTRAINT "Photo_tourId_fkey";
-- AlterTable
ALTER TABLE "Photo" ADD COLUMN "originalUrl" TEXT,
ALTER COLUMN "tourId" DROP NOT NULL,
ALTER COLUMN "imageUrl" DROP NOT NULL;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isAnonymous" BOOLEAN NOT NULL DEFAULT false,
ALTER COLUMN "email" DROP NOT NULL,
ALTER COLUMN "passwordHash" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Comment" ADD COLUMN "photoId" TEXT,
ALTER COLUMN "locationId" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_photoId_fkey" FOREIGN KEY ("photoId") REFERENCES "Photo"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+8 -4
View File
@@ -57,8 +57,8 @@ enum PrivacyLevel {
model User {
id String @id @default(uuid())
email String @unique
passwordHash String
email String? @unique
passwordHash String?
name String?
phone String?
address String?
@@ -66,6 +66,7 @@ model User {
createdAt DateTime @default(now())
isAdmin Boolean @default(false)
isBlocked Boolean @default(false)
isAnonymous Boolean @default(false)
createdTours Tour[] @relation("TourCreator")
tourParticipations TourParticipant[]
@@ -192,14 +193,17 @@ model Photo {
tour Tour? @relation(fields: [tourId], references: [id], onDelete: SetNull)
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
uploader User @relation(fields: [uploaderId], references: [id])
comments Comment[]
}
model Comment {
id String @id @default(uuid())
content String
createdAt DateTime @default(now())
locationId String
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
locationId String?
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
photoId String?
photo Photo? @relation(fields: [photoId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
+19
View File
@@ -0,0 +1,19 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
import * as dotenv from 'dotenv';
import * as path from 'path';
const envPath = path.resolve(process.cwd(), '..', '.env');
dotenv.config({ path: envPath });
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
async function main() {
const users = await prisma.user.findMany();
console.log('USERS:', users);
}
main().finally(() => pool.end());
+818 -26
View File
@@ -6,8 +6,10 @@ import 'reflect-metadata';
import * as zlib from 'zlib';
import { promisify } from 'util';
import sharp from 'sharp';
import exifr from 'exifr';
import heicConvert from 'heic-convert';
import { NestFactory } from '@nestjs/core';
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException, Injectable, UseInterceptors, UploadedFiles, Inject, HttpException, HttpStatus } from '@nestjs/common';
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Res, Query, ForbiddenException, Injectable, UseInterceptors, UploadedFiles, Inject, HttpException, HttpStatus } from '@nestjs/common';
import { NestExpressApplication } from '@nestjs/platform-express';
import { FilesInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
@@ -127,8 +129,12 @@ export class TourRoleGuard implements CanActivate {
tourId = loc.leg.tourId;
} else {
// Thử xem resourceId có phải là photoId không
const photo = await this.prisma.photo.findUnique({ where: { id: resourceId }, select: { tourId: true } });
if (photo) tourId = photo.tourId;
const photo = await this.prisma.photo.findUnique({ where: { id: resourceId }, select: { tourId: true, uploaderId: true } });
if (photo) {
if (user && photo.uploaderId === user.id) return true; // Cho phép chủ sở hữu ảnh đi qua
if (!photo.tourId) return true; // Cho phép đi qua nếu ảnh không thuộc tour nào (ví dụ ảnh ẩn danh public)
tourId = photo.tourId;
}
}
}
}
@@ -262,6 +268,81 @@ class AuthController {
@Inject(CACHE_MANAGER) private cacheManager: Cache
) {}
@Post('convert-guest')
async convertGuestToOfficial(@Body() body: any) {
const { guestId, email, password, name } = body;
if (!guestId || !email || !password) {
throw new BadRequestException('Vui lòng cung cấp đủ guestId, email và mật khẩu.');
}
// 1. Kiểm tra xem email đã tồn tại với một tài khoản chính thức khác chưa
const existingOfficialUser = await this.prisma.user.findFirst({
where: {
email: email,
isAnonymous: false,
},
});
if (existingOfficialUser) {
throw new BadRequestException('Email này đã được một tài khoản khác sử dụng.');
}
// 2. Tìm tài khoản khách
const guestUser = await this.prisma.user.findUnique({
where: { id: guestId },
});
if (!guestUser || !guestUser.isAnonymous) {
throw new NotFoundException('Không tìm thấy tài khoản khách hoặc tài khoản này đã được chuyển đổi.');
}
// 3. Mã hóa mật khẩu và cập nhật người dùng
const passwordHash = await bcrypt.hash(password, 10);
const updatedUser = await this.prisma.user.update({
where: { id: guestId },
data: {
email: email,
passwordHash: passwordHash,
name: name || guestUser.name, // Cập nhật tên mới nếu có, nếu không giữ lại tên ẩn danh cũ
isAnonymous: false, // Đánh dấu đây là tài khoản chính thức
},
});
// 4. Tạo và trả về token để người dùng đăng nhập ngay lập tức
const payload = { email: updatedUser.email, sub: updatedUser.id };
return {
access_token: this.jwtService.sign(payload),
user: {
id: updatedUser.id,
email: updatedUser.email,
name: updatedUser.name,
isAdmin: updatedUser.isAdmin,
},
};
}
@Post('create-guest')
async createGuestUser() {
const user = await this.prisma.user.create({
data: {
isAnonymous: true,
name: `Lữ khách #${Math.floor(1000 + Math.random() * 9000)}`,
},
});
const payload = { sub: user.id, isAnonymous: true };
return {
access_token: this.jwtService.sign(payload),
user: {
id: user.id,
name: user.name,
isAnonymous: user.isAnonymous,
},
};
}
@Get('status')
async getStatus() {
const userCount = await this.prisma.user.count();
@@ -319,8 +400,8 @@ class AuthController {
}
@Post('signup/verify')
async signupVerify(@Body() body: { email: string; otp: string }) {
const { email, otp } = body;
async signupVerify(@Body() body: { email: string; otp: string; guestId?: string }) {
const { email, otp, guestId } = body;
const storedOtp = await this.cacheManager.get<string>(`signup_otp:${email}`);
if (!storedOtp || storedOtp !== otp) {
@@ -333,15 +414,48 @@ class AuthController {
}
const { password, name, phone, address } = JSON.parse(cachedDataStr);
const passwordHash = await bcrypt.hash(password, 10);
let user;
if (guestId) {
// Tìm tài khoản khách
const guestUser = await this.prisma.user.findUnique({
where: { id: guestId }
});
if (guestUser && guestUser.isAnonymous) {
// Kiểm tra xem email này đã được sử dụng bởi một tài khoản chính thức khác chưa
const existingUser = await this.prisma.user.findFirst({
where: { email, isAnonymous: false }
});
if (existingUser) {
throw new BadRequestException('Email này đã được đăng ký bởi tài khoản khác.');
}
// Cập nhật thông tin của tài khoản khách
user = await this.prisma.user.update({
where: { id: guestId },
data: {
email,
passwordHash,
name,
phone,
address,
isAnonymous: false,
},
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
});
}
}
if (!user) {
const userCount = await this.prisma.user.count();
const shouldBeAdmin = userCount === 0;
const passwordHash = await bcrypt.hash(password, 10);
const user = await this.prisma.user.create({
user = await this.prisma.user.create({
data: { email, passwordHash, name, phone, address, isAdmin: shouldBeAdmin },
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
});
}
// Xóa dữ liệu cache sau khi đăng ký thành công
await Promise.all([
@@ -371,7 +485,13 @@ class PublicTourController {
}
}
},
photos: true,
photos: {
include: {
uploader: {
select: { id: true, name: true }
}
}
},
legs: {
orderBy: { sequence: 'asc' },
include: {
@@ -800,7 +920,13 @@ class TourController {
}
}
},
photos: true,
photos: {
include: {
uploader: {
select: { id: true, name: true }
}
}
},
legs: {
orderBy: { sequence: 'asc' },
include: {
@@ -1055,20 +1181,68 @@ class TourController {
if (!fs.existsSync(memberOriginalDir)) fs.mkdirSync(memberOriginalDir, { recursive: true });
if (!fs.existsSync(tourDisplayPath)) fs.mkdirSync(tourDisplayPath, { recursive: true });
// Trích xuất tọa độ GPS dự phòng từ request body gửi từ frontend
const bodyLat = req.body.latitude ? parseFloat(req.body.latitude) : undefined;
const bodyLng = req.body.longitude ? parseFloat(req.body.longitude) : undefined;
return Promise.all(files.map(async (file) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const extension = path.extname(file.originalname).toLowerCase() || '.jpg';
const filename = `${uniqueSuffix}${extension}`;
const originalExtension = path.extname(file.originalname).toLowerCase() || '.jpg';
const originalFilename = `${uniqueSuffix}${originalExtension}`;
const displayFilename = `${uniqueSuffix}.jpg`;
const originalFilePath = path.join(memberOriginalDir, filename);
const displayFilePath = path.join(tourDisplayPath, filename);
const originalFilePath = path.join(memberOriginalDir, originalFilename);
const displayFilePath = path.join(tourDisplayPath, displayFilename);
// 1. Lưu ảnh gốc nguyên bản vào thư mục riêng của thành viên
await fs.promises.writeFile(originalFilePath, file.buffer);
// 2. Xử lý ảnh để hiển thị (Độ phân giải 2K: tối đa 2560px)
// 2. Trích xuất GPS từ EXIF
let lat: number | undefined;
let lng: number | undefined;
try {
const gps = await exifr.gps(file.buffer);
if (gps && typeof gps.latitude === 'number' && typeof gps.longitude === 'number') {
lat = gps.latitude;
lng = gps.longitude;
}
} catch (e) {
console.warn('[EXIF GPS] Không thể giải nén GPS từ EXIF ảnh:', e.message);
}
// 3. Nếu EXIF không có GPS, dùng GPS dự phòng của thiết bị gửi từ Frontend
if (lat === undefined || lng === undefined) {
if (bodyLat !== undefined && !isNaN(bodyLat) && bodyLng !== undefined && !isNaN(bodyLng)) {
lat = bodyLat;
lng = bodyLng;
}
}
// 4. Nếu vẫn không có vị trí nào, ghim tại vị trí mặc định [10.7769, 106.7009]
if (lat === undefined || lng === undefined) {
lat = 10.7769;
lng = 106.7009;
}
// 5. Xử lý ảnh để hiển thị (Độ phân giải 2K: tối đa 2560px)
let processBuffer = file.buffer;
const isHeic = file.originalname.toLowerCase().endsWith('.heic') || file.originalname.toLowerCase().endsWith('.heif') || file.mimetype === 'image/heic' || file.mimetype === 'image/heif';
if (isHeic) {
try {
processBuffer = await heicConvert({
buffer: file.buffer,
format: 'JPEG',
quality: 1
});
console.log(`[HEIC] Converted original HEIC image to JPEG for display`);
} catch (e) {
console.warn('[HEIC] Failed to convert HEIC to JPEG, attempting to process anyway:', e.message);
}
}
// Sử dụng Sharp để resize và tối ưu dung lượng ảnh
await sharp(file.buffer)
await sharp(processBuffer)
.rotate()
.resize(2560, 2560, {
fit: 'inside', // Giữ nguyên tỷ lệ, không vượt quá khung 2K
withoutEnlargement: true // Nếu ảnh nhỏ hơn 2K thì giữ nguyên, không làm vỡ ảnh
@@ -1076,14 +1250,18 @@ class TourController {
.jpeg({ quality: 85 }) // Tối ưu chất lượng/dung lượng
.toFile(displayFilePath);
// 3. Lưu thông tin vào Database (Lưu cả 2 đường dẫn)
// 6. Lưu thông tin vào Database (Lưu cả 2 đường dẫn và metadata GPS)
return this.prisma.photo.create({
data: {
tourId: tourId,
uploaderId: uploaderId,
imageUrl: `/uploads/tours/${filename}`, // URL ảnh 2K dùng để render
originalUrl: `/uploads/members/${uploaderId}/originals/${filename}`, // URL ảnh gốc để tải xuống
imageUrl: `/uploads/tours/${displayFilename}`, // URL ảnh 2K dùng để render (luôn là .jpg)
originalUrl: `/uploads/members/${uploaderId}/originals/${originalFilename}`, // URL ảnh gốc để tải xuống
privacy: 'TOUR_ONLY',
metadata: {
lat: lat,
lng: lng
}
}
});
}));
@@ -1371,6 +1549,111 @@ class RoutingController {
class PhotoController {
constructor(private prisma: PrismaService) {}
@Post('upload-anonymous')
@UseGuards(JwtAuthGuard) // Vẫn dùng Guard để lấy user từ token
@UseInterceptors(FilesInterceptor('images', 1)) // Chỉ cho phép 1 ảnh mỗi lần
async uploadAnonymousPhoto(@UploadedFiles() files: any[], @Req() req: any) {
if (!files || files.length === 0) {
throw new BadRequestException('Vui lòng chọn một ảnh.');
}
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.');
}
const file = files[0];
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const originalExtension = path.extname(file.originalname).toLowerCase() || '.jpg';
const originalFilename = `${uniqueSuffix}${originalExtension}`;
const displayFilename = `${uniqueSuffix}.jpg`;
const memberOriginalDir = path.join(UPLOAD_ROOT, 'members', uploaderId, 'originals');
const displayFilePath = path.join(UPLOAD_ROOT, 'tours', displayFilename);
const originalFilePath = path.join(memberOriginalDir, originalFilename);
// Đảm bảo thư mục lưu trữ ảnh gốc tồn tại
if (!fs.existsSync(memberOriginalDir)) {
fs.mkdirSync(memberOriginalDir, { recursive: true });
}
// 1. Lưu ảnh gốc nguyên bản vào thư mục riêng của khách
await fs.promises.writeFile(originalFilePath, file.buffer);
// 2. Trích xuất GPS từ EXIF bằng exifr
let lat: number | undefined;
let lng: number | undefined;
try {
const gps = await exifr.gps(file.buffer);
if (gps && typeof gps.latitude === 'number' && typeof gps.longitude === 'number') {
lat = gps.latitude;
lng = gps.longitude;
console.log(`[EXIF GPS] Đã tìm thấy tọa độ từ EXIF: lat=${lat}, lng=${lng}`);
}
} catch (e) {
console.warn('[EXIF GPS] Không thể giải nén GPS từ EXIF ảnh:', e.message);
}
// 3. Nếu EXIF không có tọa độ, dùng tọa độ dự phòng gửi từ frontend
if (lat === undefined || lng === undefined) {
const bodyLat = req.body.latitude ? parseFloat(req.body.latitude) : undefined;
const bodyLng = req.body.longitude ? parseFloat(req.body.longitude) : undefined;
if (bodyLat !== undefined && !isNaN(bodyLat) && bodyLng !== undefined && !isNaN(bodyLng)) {
lat = bodyLat;
lng = bodyLng;
console.log(`[EXIF GPS] Sử dụng tọa độ dự phòng từ Frontend: lat=${lat}, lng=${lng}`);
}
}
// 4. Nếu vẫn không có, sử dụng vị trí mặc định (TP.HCM)
if (lat === undefined || lng === undefined) {
lat = 10.7769;
lng = 106.7009;
console.log(`[EXIF GPS] Không tìm thấy tọa độ nào, ghim tại TP.HCM mặc định: lat=${lat}, lng=${lng}`);
}
// 5. Chuyển đổi HEIC nếu cần để lưu hiển thị
let processBuffer = file.buffer;
const isHeic = file.originalname.toLowerCase().endsWith('.heic') || file.originalname.toLowerCase().endsWith('.heif') || file.mimetype === 'image/heic' || file.mimetype === 'image/heif';
if (isHeic) {
try {
processBuffer = await heicConvert({
buffer: file.buffer,
format: 'JPEG',
quality: 1
});
console.log(`[HEIC] Converted anonymous HEIC image to JPEG for display`);
} catch (e) {
console.warn('[HEIC] Failed to convert HEIC to JPEG, attempting to process anyway:', e.message);
}
}
// 6. Xử lý ảnh để hiển thị (kích thước tối đa 2K: 2560px)
await sharp(processBuffer)
.rotate()
.resize(2560, 2560, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 85 })
.toFile(displayFilePath);
return this.prisma.photo.create({
data: {
uploaderId: uploaderId,
imageUrl: `/uploads/tours/${displayFilename}`,
originalUrl: `/uploads/members/${uploaderId}/originals/${originalFilename}`,
privacy: 'PUBLIC',
metadata: {
lat: lat,
lng: lng
}
},
});
}
@Delete(':id')
async deletePhoto(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
const photo = await this.prisma.photo.findUnique({
@@ -1381,8 +1664,8 @@ class PhotoController {
throw new NotFoundException('Không tìm thấy ảnh.');
}
// Chỉ người tải lên mới có quyền xóa ảnh của họ
if (photo.uploaderId !== req.user.id) {
// Chỉ người tải lên hoặc Admin mới có quyền xóa ảnh
if (photo.uploaderId !== req.user.id && !req.user.isAdmin) {
throw new ForbiddenException('Bạn không có quyền xóa ảnh này.');
}
@@ -1405,16 +1688,100 @@ class PhotoController {
await this.prisma.photo.delete({ where: { id } });
return { message: 'Ảnh đã được xóa thành công.' };
}
@Patch(':id')
async updatePhoto(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: { title?: string; description?: string; latitude?: number; longitude?: number },
@Req() req: any
) {
const photo = await this.prisma.photo.findUnique({
where: { id }
});
if (!photo) {
throw new NotFoundException('Không tìm thấy ảnh.');
}
// Chỉ người tải lên hoặc Admin mới có quyền sửa thông tin ảnh
if (photo.uploaderId !== req.user.id && !req.user.isAdmin) {
throw new ForbiddenException('Bạn không có quyền chỉnh sửa thông tin của bức ảnh này.');
}
const currentMetadata = (photo.metadata as any) || {};
const updatedMetadata = {
...currentMetadata,
lat: body.latitude !== undefined ? body.latitude : currentMetadata.lat,
lng: body.longitude !== undefined ? body.longitude : currentMetadata.lng,
title: body.title !== undefined ? body.title : currentMetadata.title,
description: body.description !== undefined ? body.description : currentMetadata.description,
};
return this.prisma.photo.update({
where: { id },
data: {
metadata: updatedMetadata
}
});
}
@Post(':id/toggle-like')
@UseGuards(JwtAuthGuard)
async toggleLikePhoto(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
const userId = req.user.id;
const photo = await this.prisma.photo.findUnique({
where: { id }
});
if (!photo) {
throw new NotFoundException('Không tìm thấy ảnh.');
}
const currentMetadata = (photo.metadata as any) || {};
const likedUserIds = Array.isArray(currentMetadata.likedUserIds)
? currentMetadata.likedUserIds
: [];
const index = likedUserIds.indexOf(userId);
let updatedLikedUserIds = [...likedUserIds];
if (index > -1) {
// Unlike
updatedLikedUserIds.splice(index, 1);
} else {
// Like
updatedLikedUserIds.push(userId);
}
const updatedMetadata = {
...currentMetadata,
likedUserIds: updatedLikedUserIds
};
return this.prisma.photo.update({
where: { id },
data: {
metadata: updatedMetadata
},
include: {
uploader: {
select: { id: true, name: true }
}
}
});
}
}
@UseGuards(JwtAuthGuard)
@Controller('users')
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for User management (Admin/Manager)
class UserController {
constructor(private prisma: PrismaService) {}
@Get()
@UseGuards(AdminGuard)
async getAllUsers(@Req() req: any, @Query('q') q?: string) {
const currentUserId = req.user?.sub;
const currentUserId = req.user?.id;
const users = await this.prisma.user.findMany({
where: q
? {
@@ -1444,11 +1811,36 @@ class UserController {
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for updating user (Admin/Manager)
@Patch(':id')
async updateUser(@Param('id', ParseUUIDPipe) id: string, @Body() data: any) {
async updateUser(@Param('id', ParseUUIDPipe) id: string, @Body() data: any, @Req() req: any) {
const requestingUser = req.user;
// Fetch target user from DB
const targetUser = await this.prisma.user.findUnique({ where: { id } });
if (!targetUser) {
throw new NotFoundException('Không tìm thấy người dùng');
}
// 1. If target user is an Admin, only an Admin can update them.
// (A manager/normal user cannot reset/change password of an Admin)
if (targetUser.isAdmin && !requestingUser.isAdmin) {
throw new ForbiddenException('Không có quyền thay đổi thông tin hoặc reset password của Quản trị viên');
}
// 2. A non-admin can only update their own profile.
if (!requestingUser.isAdmin && requestingUser.id !== id) {
throw new ForbiddenException('Bạn không có quyền thực hiện hành động này');
}
if (data.password) {
data.passwordHash = await bcrypt.hash(data.password, 10);
delete data.password;
}
// Safety: prevent non-admins from promoting anyone to admin
if (!requestingUser.isAdmin && data.isAdmin !== undefined) {
delete data.isAdmin;
}
return this.prisma.user.update({
where: { id },
data,
@@ -1458,7 +1850,8 @@ class UserController {
@Roles(ParticipantRole.OWNER) // Only owner can delete user
@Delete(':id')
async deleteUser(@Param('id', ParseUUIDPipe) id: string) {
@UseGuards(AdminGuard)
async deleteUser(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException('Không tìm thấy người dùng');
if (user.isAdmin) {
@@ -1496,9 +1889,16 @@ class UserController {
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for blocking user (Admin/Manager)
@Post('block/:id')
async toggleBlock(@Param('id', ParseUUIDPipe) id: string) {
@UseGuards(AdminGuard)
async toggleBlock(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException('Người dùng không tồn tại');
// Safety check: Cannot block an Admin
if (user.isAdmin) {
throw new BadRequestException('Không thể khóa tài khoản Quản trị viên');
}
const updated = await this.prisma.user.update({
where: { id },
data: { isBlocked: !user.isBlocked },
@@ -1522,10 +1922,21 @@ export class CommentGateway implements OnGatewayConnection {
console.log(`[WS] Client ${client.id} joined room: tour_${tourId}`);
}
@SubscribeMessage('joinPhoto')
handleJoinPhoto(client: Socket, photoId: string) {
client.join(`photo_${photoId}`);
console.log(`[WS] Client ${client.id} joined room: photo_${photoId}`);
}
notifyNewComment(tourId: string, data: any) {
// Gửi thông báo tới tất cả client trong phòng của Tour này
this.server.to(`tour_${tourId}`).emit('commentAdded', data);
}
notifyNewPhotoComment(photoId: string, data: any) {
// Gửi thông báo tới tất cả client trong phòng của Ảnh này
this.server.to(`photo_${photoId}`).emit('photoCommentAdded', data);
}
}
@Controller('locations')
@@ -1693,6 +2104,387 @@ class AdminOtpController {
}
}
@Controller('admin/trash-photos')
@UseGuards(JwtAuthGuard, AdminGuard)
class AdminController {
constructor(private prisma: PrismaService) {}
@Get()
async getTrashPhotos() {
// 1. Lấy tất cả ảnh trong DB
const dbPhotos = await this.prisma.photo.findMany({
select: {
id: true,
imageUrl: true,
originalUrl: true,
privacy: true,
tourId: true,
}
});
const dbImageUrls = new Set(
dbPhotos.map(p => p.imageUrl).filter(Boolean)
);
const dbOriginalUrls = new Set(
dbPhotos.map(p => p.originalUrl).filter(Boolean)
);
// 2. Tìm tất cả tệp tin vật lý trên đĩa
const allDiskFiles: string[] = [];
const getFilesRecursively = (dir: string) => {
if (!fs.existsSync(dir)) return;
const list = fs.readdirSync(dir);
for (const file of list) {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
getFilesRecursively(fullPath);
} else {
allDiskFiles.push(fullPath);
}
}
};
getFilesRecursively(UPLOAD_ROOT);
const trashPhotos: any[] = [];
// 3. Kiểm tra các tệp tin trên đĩa xem có trong DB không
for (const filePath of allDiskFiles) {
const relativePath = '/uploads' + filePath.substring(UPLOAD_ROOT.length).replace(/\\/g, '/');
if (path.basename(filePath).startsWith('.')) continue;
const isUsed = dbImageUrls.has(relativePath) || dbOriginalUrls.has(relativePath);
if (!isUsed) {
let size = 0;
try {
size = fs.statSync(filePath).size;
} catch (e) {}
trashPhotos.push({
filePath: filePath,
url: relativePath,
size: size,
reason: 'Tệp tin không tồn tại trong cơ sở dữ liệu (ảnh mồ côi)',
type: 'file_only'
});
}
}
// 4. Kiểm tra các bản ghi DB mồ côi (không có tourId và privacy không phải PUBLIC)
const orphanedDbPhotos = dbPhotos.filter(p => !p.tourId && p.privacy !== 'PUBLIC');
for (const dbPhoto of orphanedDbPhotos) {
let size = 0;
if (dbPhoto.imageUrl) {
const fullPath = path.join(process.cwd(), dbPhoto.imageUrl.replace(/^\//, ''));
try {
if (fs.existsSync(fullPath)) {
size += fs.statSync(fullPath).size;
}
} catch (e) {}
}
if (dbPhoto.originalUrl) {
const fullPath = path.join(process.cwd(), dbPhoto.originalUrl.replace(/^\//, ''));
try {
if (fs.existsSync(fullPath)) {
size += fs.statSync(fullPath).size;
}
} catch (e) {}
}
trashPhotos.push({
id: dbPhoto.id,
url: dbPhoto.imageUrl,
size: size,
reason: 'Bản ghi ảnh riêng tư không gắn với chuyến đi nào',
type: 'db_orphaned'
});
}
return trashPhotos;
}
@Delete('clean')
async cleanTrashPhotos() {
const trashList = await this.getTrashPhotos();
let deletedCount = 0;
let freedSpace = 0;
for (const item of trashList) {
if (item.type === 'file_only') {
if (item.filePath && fs.existsSync(item.filePath)) {
try {
fs.unlinkSync(item.filePath);
deletedCount++;
freedSpace += item.size;
} catch (e) {}
}
} else if (item.type === 'db_orphaned') {
const photo = await this.prisma.photo.findUnique({ where: { id: item.id } });
if (photo) {
if (photo.imageUrl) {
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
if (fs.existsSync(displayFilePath)) {
try { fs.unlinkSync(displayFilePath); } catch (e) {}
}
}
if (photo.originalUrl) {
const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, ''));
if (fs.existsSync(originalFilePath)) {
try { fs.unlinkSync(originalFilePath); } catch (e) {}
}
}
try {
await this.prisma.photo.delete({ where: { id: item.id } });
deletedCount++;
freedSpace += item.size;
} catch (e) {}
}
}
}
return {
success: true,
message: `Đã dọn dẹp thành công. Đã xóa ${deletedCount} mục, giải phóng ${(freedSpace / (1024 * 1024)).toFixed(2)} MB.`,
deletedCount,
freedSpace
};
}
@Delete('delete-single')
async deleteSingleTrash(@Body() body: { type: 'file_only' | 'db_orphaned'; filePath?: string; id?: string }) {
const { type, filePath, id } = body;
if (type === 'file_only') {
if (!filePath) throw new BadRequestException('Đường dẫn tệp tin không hợp lệ');
const resolvedPath = path.resolve(filePath);
if (!resolvedPath.startsWith(UPLOAD_ROOT)) {
throw new ForbiddenException('Không được phép xóa tệp tin ngoài thư mục uploads');
}
if (fs.existsSync(resolvedPath)) {
fs.unlinkSync(resolvedPath);
return { success: true, message: 'Đã xóa tệp tin thành công' };
} else {
throw new NotFoundException('Tệp tin không tồn tại');
}
} else if (type === 'db_orphaned') {
if (!id) throw new BadRequestException('ID bản ghi không hợp lệ');
const photo = await this.prisma.photo.findUnique({ where: { id } });
if (!photo) throw new NotFoundException('Bản ghi không tồn tại trong cơ sở dữ liệu');
if (photo.imageUrl) {
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
if (fs.existsSync(displayFilePath)) {
try { fs.unlinkSync(displayFilePath); } catch (e) {}
}
}
if (photo.originalUrl) {
const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, ''));
if (fs.existsSync(originalFilePath)) {
try { fs.unlinkSync(originalFilePath); } catch (e) {}
}
}
await this.prisma.photo.delete({ where: { id } });
return { success: true, message: 'Đã xóa bản ghi và các tệp liên quan thành công' };
}
throw new BadRequestException('Yêu cầu không hợp lệ');
}
}
@Controller('public-photos')
class PublicPhotoController {
constructor(
private prisma: PrismaService,
private commentGateway: CommentGateway
) {}
@Get()
async getPublicPhotos() {
return this.prisma.photo.findMany({
where: { privacy: 'PUBLIC' },
select: {
id: true,
imageUrl: true,
originalUrl: true,
capturedAt: true,
metadata: true,
uploaderId: true,
uploader: {
select: {
id: true,
name: true,
}
}
},
orderBy: { capturedAt: 'desc' }
});
}
@Get(':photoId/comments')
async getPhotoComments(@Param('photoId', ParseUUIDPipe) photoId: string) {
return this.prisma.comment.findMany({
where: { photoId },
include: {
user: {
select: { id: true, name: true }
}
},
orderBy: { createdAt: 'asc' }
});
}
@UseGuards(JwtAuthGuard)
@Post(':photoId/comments')
async addPhotoComment(
@Param('photoId', ParseUUIDPipe) photoId: string,
@Body() body: { content: string },
@Req() req: any
) {
const { content } = body;
if (!content || content.trim() === '') {
throw new BadRequestException('Nội dung bình luận không được để trống.');
}
const photo = await this.prisma.photo.findUnique({
where: { id: photoId }
});
if (!photo) {
throw new NotFoundException('Không tìm thấy ảnh.');
}
const comment = await this.prisma.comment.create({
data: {
content: content.trim(),
photoId,
userId: req.user.id
},
include: {
user: {
select: { id: true, name: true }
}
}
});
// Phát tín hiệu WebSocket cho các client đang xem ảnh này
this.commentGateway.notifyNewPhotoComment(photoId, comment);
return comment;
}
@Get(':photoId/share')
async sharePhoto(
@Param('photoId', ParseUUIDPipe) photoId: string,
@Req() req: any,
@Res() res: any
) {
const photo = await this.prisma.photo.findUnique({
where: { id: photoId }
});
if (!photo) {
throw new NotFoundException('Không tìm thấy ảnh.');
}
let host = req.headers['x-forwarded-host'] || req.headers.host || '';
// Nếu host là localhost/127.0.0.1 nhưng request có header proxy (nghĩa là đang chạy qua Nginx proxy ở production)
const isProxied = !!(req.headers['x-forwarded-proto'] || req.headers['x-forwarded-for']);
const isLocalhost = host.includes('localhost') || host.includes('127.0.0.1');
if (isLocalhost && isProxied) {
host = 'yotrip.labz.io.vn';
}
const isLocal = host.includes('localhost') || host.includes('127.0.0.1') || host.startsWith('192.168.') || host.startsWith('10.');
const protocol = isLocal ? (req.headers['x-forwarded-proto'] || 'http') : 'https';
const baseUrl = `${protocol}://${host}`;
// Lấy thông tin metadata
let title = 'YoTrip - Xem ảnh công khai';
let description = 'Xem hình ảnh chia sẻ công khai trên bản đồ hành trình YoTrip.';
if (photo.metadata && typeof photo.metadata === 'object') {
const meta = photo.metadata as any;
if (meta.title && meta.title.trim()) {
title = meta.title;
}
if (meta.description && meta.description.trim()) {
description = meta.description;
}
}
// Xây dựng đường dẫn ảnh tuyệt đối để Facebook hiển thị thumbnail
const fullImageUrl = photo.imageUrl
? (photo.imageUrl.startsWith('http') ? photo.imageUrl : `${baseUrl}${photo.imageUrl}`)
: '';
const shareUrl = `${baseUrl}/api/v1/public-photos/${photoId}/share`;
const redirectUrl = `${baseUrl}/?photoId=${photoId}`;
// Lấy kích thước thực tế của ảnh bằng sharp để Facebook hiển thị chuẩn xác không cần chờ xử lý bất đồng bộ
let imageWidth = 1200;
let imageHeight = 630;
try {
if (photo.imageUrl) {
const localImagePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
if (fs.existsSync(localImagePath)) {
const imageMeta = await sharp(localImagePath).metadata();
if (imageMeta.width && imageMeta.height) {
imageWidth = imageMeta.width;
imageHeight = imageMeta.height;
}
}
}
} catch (err) {
console.warn('[Share] Không thể lấy kích thước ảnh:', err.message);
}
const htmlContent = `<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<title>${title}</title>
<meta name="description" content="${description}">
<!-- Open Graph / Facebook -->
<meta property="og:site_name" content="YoTrip">
<meta property="og:locale" content="vi_VN">
<meta property="og:type" content="website">
<meta property="og:url" content="${shareUrl}">
<meta property="og:title" content="${title}">
<meta property="og:description" content="${description}">
<meta property="og:image" content="${fullImageUrl}">
<meta property="og:image:secure_url" content="${fullImageUrl}">
<meta property="og:image:type" content="image/jpeg">
<meta property="og:image:width" content="${imageWidth}">
<meta property="og:image:height" content="${imageHeight}">
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:url" content="${shareUrl}">
<meta property="twitter:title" content="${title}">
<meta property="twitter:description" content="${description}">
<meta property="twitter:image" content="${fullImageUrl}">
<!-- Tự động chuyển hướng người dùng sang frontend chi tiết -->
<script>
window.location.href = "${redirectUrl}";
</script>
</head>
<body>
<div style="font-family: sans-serif; text-align: center; margin-top: 100px; color: #334155;">
<h2>Đang chuyển hướng bạn đến YoTrip...</h2>
<p>Nếu trang không tự động tải, <a href="${redirectUrl}">nhấn vào đây</a>.</p>
</div>
</body>
</html>`;
res.type('text/html').send(htmlContent);
}
}
@Module({
imports: [
ConfigModule.forRoot({
@@ -1716,7 +2508,7 @@ class AdminOtpController {
signOptions: { expiresIn: '1d' },
}) as any,
],
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, AdminOtpController],
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController, AdminController],
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector, EmailService, ConfigService],
exports: [PrismaService]
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

+14
View File
@@ -0,0 +1,14 @@
<!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" />
<title>Travel Planner</title>
<script type="module" crossorigin src="/assets/index-CtrQmjY1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-i0kJVU1C.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>Travel Planner</title>
</head>
Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

+1
View File
@@ -112,6 +112,7 @@ function App() {
user={user}
onViewTour={handleViewTour}
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
onLoginSuccess={handleLoginSuccess}
/>
);
}
+2 -2
View File
@@ -399,8 +399,8 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
</div>
</div>
<MapContainer center={currentCoords} zoom={13} className="h-full w-full" zoomControl={false}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<MapContainer center={currentCoords} zoom={13} className="h-full w-full" zoomControl={false} attributionControl={false}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="" />
<Marker position={currentCoords} />
<MapPicker center={currentCoords} onPick={handlePickLocation} />
</MapContainer>
+21
View File
@@ -70,11 +70,32 @@ 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))
]);
const formData = new FormData();
selectedFiles.forEach(file => {
formData.append('images', file);
});
if (location) {
formData.append('latitude', location.coords.latitude.toString());
formData.append('longitude', location.coords.longitude.toString());
}
const response = await fetch(`/api/v1/tours/${tourId}/photos`, {
method: 'POST',
headers: {
@@ -0,0 +1,163 @@
import React, { useState, useEffect } from 'react';
import { X, MapPin } from 'lucide-react';
import { MapContainer, TileLayer, Marker, useMap, useMapEvents } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
// Fix Leaflet default marker icon bug
const DefaultIcon = L.icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
});
interface CoordinateSelectModalProps {
isOpen: boolean;
onClose: () => void;
initialLat?: number;
initialLng?: number;
onSelect: (lat: number, lng: number) => void;
}
function MapClickEvents({ onClick }: { onClick: (lat: number, lng: number) => void }) {
useMapEvents({
click: (e) => {
onClick(e.latlng.lat, e.latlng.lng);
}
});
return null;
}
function MapInvalidator() {
const map = useMap();
useEffect(() => {
const timer = setTimeout(() => {
map.invalidateSize();
}, 250);
return () => clearTimeout(timer);
}, [map]);
return null;
}
function RecenterMap({ position }: { position: [number, number] }) {
const map = useMap();
useEffect(() => {
map.setView(position, map.getZoom());
}, [position, map]);
return null;
}
export const CoordinateSelectModal: React.FC<CoordinateSelectModalProps> = ({
isOpen,
onClose,
initialLat,
initialLng,
onSelect
}) => {
const defaultCenter: [number, number] = [10.7769, 106.7009]; // TP.HCM default
const [position, setPosition] = useState<[number, number]>(defaultCenter);
const [hasSelected, setHasSelected] = useState(false);
useEffect(() => {
if (isOpen) {
if (typeof initialLat === 'number' && typeof initialLng === 'number' && !isNaN(initialLat) && !isNaN(initialLng)) {
setPosition([initialLat, initialLng]);
setHasSelected(true);
} else {
setPosition(defaultCenter);
setHasSelected(false);
}
}
}, [isOpen, initialLat, initialLng]);
if (!isOpen) return null;
const handleMapClick = (lat: number, lng: number) => {
setPosition([lat, lng]);
setHasSelected(true);
};
const handleConfirm = () => {
onSelect(position[0], position[1]);
onClose();
};
return (
<div className="fixed inset-0 z-[6000] flex items-center justify-center p-4 animate-in fade-in duration-200">
{/* Backdrop */}
<div
className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Content */}
<div className="relative w-full max-w-2xl h-[550px] bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col border border-gray-100 animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="p-4 border-b border-gray-100 flex items-center justify-between bg-white shrink-0">
<div className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-blue-500" />
<div className="text-left">
<h3 className="font-extrabold text-sm text-gray-900">Chọn vị trí trên bản đ</h3>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Click lên bản đ đ chọn tọa đ</p>
</div>
</div>
<button
onClick={onClose}
className="p-1.5 hover:bg-gray-100 rounded-full transition-all text-gray-400 hover:text-gray-600"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Map Body */}
<div className="flex-1 bg-gray-50 relative min-h-[300px]" style={{ zIndex: 10 }}>
<MapContainer
center={position}
zoom={13}
style={{ width: '100%', height: '100%', zIndex: 1 }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapClickEvents onClick={handleMapClick} />
<MapInvalidator />
<RecenterMap position={position} />
{hasSelected && (
<Marker position={position} icon={DefaultIcon} />
)}
</MapContainer>
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-100 flex items-center justify-between bg-white shrink-0">
<div className="text-xs text-gray-500">
{hasSelected ? (
<span className="font-semibold text-gray-700">
Tọa đ: {position[0].toFixed(6)}, {position[1].toFixed(6)}
</span>
) : (
<span className="italic text-gray-400">Chưa chọn vị trí</span>
)}
</div>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 border border-gray-200 hover:bg-gray-50 text-gray-700 rounded-xl text-xs font-bold transition-all"
>
Hủy
</button>
<button
onClick={handleConfirm}
disabled={!hasSelected}
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed text-white rounded-xl text-xs font-bold transition-all shadow-md shadow-blue-500/10"
>
Xác nhận
</button>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,734 @@
import React, { useState, useEffect, useRef } from 'react';
import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit, Heart } from 'lucide-react';
import { io } from 'socket.io-client';
import { CoordinateSelectModal } from './CoordinateSelectModal';
interface Comment {
id: string;
userName: string;
content: string;
createdAt: string;
userId: string;
}
interface PublicPhotoModalProps {
isOpen: boolean;
onClose: () => void;
photo: {
id: string;
imageUrl: string;
originalUrl?: string;
capturedAt: string;
metadata?: {
lat?: number;
lng?: number;
title?: string;
description?: string;
};
uploader?: {
id: string;
name: string;
};
uploaderId?: string;
};
photoGroup?: any[];
onSelectPhoto?: (photo: any) => void;
onLoginSuccess?: (user: any) => void;
onUpdatePhoto?: (updatedPhoto: any) => void;
}
export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
isOpen,
onClose,
photo,
photoGroup = [],
onSelectPhoto,
onLoginSuccess,
onUpdatePhoto
}) => {
const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isSending, setIsSending] = useState(false);
const commentsEndRef = useRef<HTMLDivElement>(null);
const [currentUser, setCurrentUser] = useState<any>(null);
const [isEditing, setIsEditing] = useState(false);
const [editTitle, setEditTitle] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editLat, setEditLat] = useState<number | ''>('');
const [editLng, setEditLng] = useState<number | ''>('');
const [isSavingEdit, setIsSavingEdit] = useState(false);
const [isMapOpen, setIsMapOpen] = useState(false);
const [resolvedAddress, setResolvedAddress] = useState<string>('');
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const lat = photo?.metadata?.lat;
const lng = photo?.metadata?.lng;
if (typeof lat === 'number' && typeof lng === 'number') {
setResolvedAddress('Đang xác định địa điểm...');
fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&accept-language=vi`)
.then(res => {
if (!res.ok) throw new Error();
return res.json();
})
.then(data => {
if (data && data.display_name) {
const shortAddress = data.display_name.split(',').slice(0, 3).join(',').trim();
setResolvedAddress(shortAddress || data.display_name);
} else {
setResolvedAddress(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
}
})
.catch(() => {
setResolvedAddress(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
});
} else {
setResolvedAddress('Chưa xác định tọa độ');
}
}, [photo?.id, photo?.metadata?.lat, photo?.metadata?.lng]);
const checkCurrentUser = () => {
const userStr = localStorage.getItem('user') || localStorage.getItem('guest_user');
if (userStr) {
try {
setCurrentUser(JSON.parse(userStr));
} catch (e) {}
} else {
setCurrentUser(null);
}
};
useEffect(() => {
checkCurrentUser();
}, [isOpen]);
useEffect(() => {
if (photo) {
setEditTitle(photo.metadata?.title || '');
setEditDescription(photo.metadata?.description || '');
setEditLat(photo.metadata?.lat ?? '');
setEditLng(photo.metadata?.lng ?? '');
setIsEditing(false); // Reset editing mode when selected photo changes
}
}, [photo]);
const handleSaveEdit = async () => {
if (editLat !== '' && (isNaN(editLat) || editLat < -90 || editLat > 90)) {
alert('Vĩ độ không hợp lệ (-90 đến 90)');
return;
}
if (editLng !== '' && (isNaN(editLng) || editLng < -180 || editLng > 180)) {
alert('Kinh độ không hợp lệ (-180 đến 180)');
return;
}
setIsSavingEdit(true);
try {
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
const res = await fetch(`/api/v1/photos/${photo.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
title: editTitle,
description: editDescription,
latitude: editLat === '' ? undefined : editLat,
longitude: editLng === '' ? undefined : editLng
})
});
if (res.ok) {
const updatedPhoto = await res.json();
setIsEditing(false);
if (onUpdatePhoto) {
onUpdatePhoto(updatedPhoto);
}
} else {
const err = await res.json();
alert(err.message || 'Lỗi khi cập nhật thông tin ảnh.');
}
} catch (error) {
console.error('Lỗi khi cập nhật thông tin ảnh:', error);
alert('Không thể kết nối đến máy chủ.');
} finally {
setIsSavingEdit(false);
}
};
const likedUserIds = photo.metadata && Array.isArray((photo.metadata as any).likedUserIds)
? (photo.metadata as any).likedUserIds
: [];
const isLiked = currentUser && likedUserIds.includes(currentUser.id);
const likeCount = likedUserIds.length;
const handleToggleLike = async () => {
let token = localStorage.getItem('token') || localStorage.getItem('guest_token');
let userObj = currentUser;
if (!token) {
try {
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
if (guestRes.ok) {
const guestData = await guestRes.json();
token = guestData.access_token;
userObj = guestData.user;
localStorage.setItem('token', token!);
localStorage.setItem('user', JSON.stringify(userObj));
if (onLoginSuccess) {
onLoginSuccess(userObj);
}
setCurrentUser(userObj);
} else {
return;
}
} catch (e) {
console.error('Không thể tạo phiên khách:', e);
return;
}
}
try {
const res = await fetch(`/api/v1/photos/${photo.id}/toggle-like`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (res.ok) {
const updatedPhoto = await res.json();
if (onUpdatePhoto) {
onUpdatePhoto(updatedPhoto);
}
}
} catch (error) {
console.error('Lỗi khi thích ảnh:', error);
}
};
const fetchComments = async () => {
setIsLoading(true);
try {
const res = await fetch(`/api/v1/public-photos/${photo.id}/comments`);
if (res.ok) {
const data = await res.json();
setComments(
data.map((c: any) => ({
id: c.id,
userName: c.user?.name || 'Ẩn danh',
content: c.content,
createdAt: c.createdAt,
userId: c.userId
}))
);
}
} catch (error) {
console.error('Lỗi khi tải bình luận:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (!isOpen || !photo.id) return;
fetchComments();
const socket = io();
socket.emit('joinPhoto', photo.id);
socket.on('photoCommentAdded', (newCommentData: any) => {
if (newCommentData.photoId === photo.id) {
setComments(prev => {
if (prev.find(c => c.id === newCommentData.id)) return prev;
return [
...prev,
{
id: newCommentData.id,
userName: newCommentData.user?.name || 'Ẩn danh',
content: newCommentData.content,
createdAt: newCommentData.createdAt,
userId: newCommentData.userId
}
];
});
}
});
return () => {
socket.disconnect();
};
}, [isOpen, photo.id]);
useEffect(() => {
commentsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [comments]);
const handleSend = async () => {
if (!newComment.trim()) return;
setIsSending(true);
try {
let token = localStorage.getItem('token');
let currentUser = JSON.parse(localStorage.getItem('user') || 'null');
// Nếu chưa có token, tự động tạo tài khoản khách
if (!token) {
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
if (!guestRes.ok) throw new Error('Không thể tạo tài khoản khách tự động.');
const guestData = await guestRes.json();
token = guestData.access_token;
currentUser = guestData.user;
localStorage.setItem('guest_token', token!);
localStorage.setItem('guest_user', JSON.stringify(currentUser));
localStorage.setItem('token', token!);
localStorage.setItem('user', JSON.stringify(currentUser));
if (onLoginSuccess) {
onLoginSuccess(currentUser);
}
}
let res = await fetch(`/api/v1/public-photos/${photo.id}/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ content: newComment })
});
if (res.status === 401) {
console.warn('Token invalid or expired. Creating a new guest user and retrying comment...');
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();
token = guestData.access_token;
currentUser = guestData.user;
localStorage.setItem('guest_token', token!);
localStorage.setItem('guest_user', JSON.stringify(currentUser));
localStorage.setItem('token', token!);
localStorage.setItem('user', JSON.stringify(currentUser));
if (onLoginSuccess) {
onLoginSuccess(currentUser);
}
res = await fetch(`/api/v1/public-photos/${photo.id}/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ content: newComment })
});
}
if (res.ok) {
setNewComment('');
fetchComments();
} else {
const err = await res.json();
console.error('Lỗi khi gửi bình luận:', err.message);
}
} catch (error) {
console.error('Lỗi khi gửi bình luận:', error);
} finally {
setIsSending(false);
}
};
const isAuthorized = currentUser?.isAdmin ||
(currentUser && photo.uploader && currentUser.id === photo.uploader.id) ||
(currentUser && photo.uploaderId && currentUser.id === photo.uploaderId);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[5000] md:flex md:items-center md:justify-center md:p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-300"
onClick={onClose}
/>
{/* Container */}
<div className="fixed inset-0 overflow-y-auto flex flex-col bg-slate-900 text-slate-100 md:relative md:inset-auto md:overflow-hidden md:rounded-[32px] md:shadow-2xl md:border md:border-slate-800 md:w-full md:max-w-5xl md:h-[85vh] md:flex-row animate-in zoom-in-95 duration-300">
{/* Close Button Mobile/Desktop */}
<button
onClick={onClose}
className="fixed md:absolute top-[calc(1rem+env(safe-area-inset-top,0px))] right-4 md:top-4 md:right-4 z-50 p-2 bg-slate-950/60 hover:bg-slate-800/80 border border-slate-700/50 rounded-full text-slate-300 hover:text-white transition-colors"
>
<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>
</div>
{/* Like Button Overlay */}
<button
onClick={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"
onClick={(e) => {
e.preventDefault();
setIsFullscreen(true);
}}
>
<img
src={photo.imageUrl}
alt="Public Map Upload"
className="w-full h-auto max-h-[85vh] object-contain md:h-full md:max-h-none"
/>
</a>
</div>
{/* Info & Timeline overlay inside photo panel */}
<div className="relative 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 */}
{photoGroup && photoGroup.length > 1 && (
<div className="flex flex-col gap-2 border-b border-slate-800/80 pb-3">
<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">
{photoGroup.map((p) => {
const isActive = p.id === photo.id;
return (
<button
key={p.id}
onClick={() => 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" className="w-full h-full object-cover" />
<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>
)}
{isEditing ? (
<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)}
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"
/>
</div>
<div>
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1"> 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"
/>
</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"> đ</label>
<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"
/>
</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}
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"
/>
</div>
</div>
<div className="flex justify-start">
<button
type="button"
onClick={() => setIsMapOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-750 border border-slate-700/50 text-slate-300 hover:text-white rounded-xl text-[10px] font-bold transition-all"
>
<MapPin className="w-3.5 h-3.5 text-rose-500" />
Chọn trên bản đ
</button>
</div>
</div>
<div className="flex justify-end gap-2 mt-2">
<button
onClick={() => 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={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 tiêu đ</span>
)}
{photo.metadata?.description ? (
<p className="text-xs text-slate-300 leading-relaxed mt-1 max-h-20 overflow-y-auto no-scrollbar break-words">
{photo.metadata.description}
</p>
) : (
<span className="text-[10px] text-slate-500 italic block mt-1">Chưa tả</span>
)}
</div>
{isAuthorized && (
<button
onClick={() => 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" />
Bình luận cộng đng
</h3>
<p className="text-xs text-slate-400 mt-1">nh chia sẻ công khai trên bản đ</p>
</div>
</div>
{/* Comments list scroll area */}
<div className="md:flex-1 md:overflow-y-auto p-6 space-y-4 bg-slate-900/50">
{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">Đang tải bình luận...</span>
</div>
) : comments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-500 gap-3">
<div className="p-4 bg-slate-800/40 rounded-full text-slate-600">
<MessageSquare className="w-8 h-8" />
</div>
<span className="text-sm font-semibold italic">Chưa bình luận nào. Hãy bắt đu cuộc trò chuyện!</span>
</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>
<span className="text-[9px] font-medium text-slate-500">
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</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">
<div className="relative flex items-center gap-2">
<input
type="text"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !isSending && handleSend()}
placeholder="Viết bình luận công khai..."
className="flex-1 bg-slate-800/65 border border-slate-700/70 text-slate-100 placeholder-slate-500 rounded-2xl px-4 py-3 text-base md:text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
disabled={isSending}
/>
<button
onClick={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"
>
{isSending ? (
<Loader2 className="w-4.5 h-4.5 animate-spin" />
) : (
<Send className="w-4.5 h-4.5" />
)}
</button>
</div>
</div>
</div>
</div>
<CoordinateSelectModal
isOpen={isMapOpen}
onClose={() => setIsMapOpen(false)}
initialLat={typeof editLat === 'number' ? editLat : undefined}
initialLng={typeof editLng === 'number' ? editLng : undefined}
onSelect={(lat, lng) => {
setEditLat(lat);
setEditLng(lng);
}}
/>
{isFullscreen && (
<div
className="fixed inset-0 z-[9999] bg-black/95 flex items-center justify-center cursor-zoom-out animate-in fade-in duration-200"
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"
className="max-w-full max-h-full object-contain select-none animate-in zoom-in-95 duration-200"
/>
</div>
)}
</div>
);
};
+274 -9
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { X, User, Trash2, Shield, ShieldAlert, Lock, Unlock, Loader2, UserPlus } from 'lucide-react';
import { X, User, Trash2, Shield, ShieldAlert, Lock, Unlock, Loader2, Image as ImageIcon } from 'lucide-react';
interface UserManagementModalProps {
isOpen: boolean;
@@ -7,8 +7,14 @@ interface UserManagementModalProps {
}
export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen, onClose }) => {
const [activeTab, setActiveTab] = useState<'users' | 'photos' | 'trash'>('users');
const [users, setUsers] = useState<any[]>([]);
const [photos, setPhotos] = useState<any[]>([]);
const [trashPhotos, setTrashPhotos] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [photosLoading, setPhotosLoading] = useState(false);
const [trashLoading, setTrashLoading] = useState(false);
const [cleaning, setCleaning] = useState(false);
const [error, setError] = useState('');
const fetchUsers = async () => {
@@ -27,9 +33,89 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
}
};
const fetchPhotos = async () => {
setPhotosLoading(true);
try {
const response = await fetch(`/api/v1/public-photos`);
if (!response.ok) throw new Error('Không thể tải danh sách ảnh công cộng');
const data = await response.json();
setPhotos(data);
} catch (err: any) {
setError(err.message);
} finally {
setPhotosLoading(false);
}
};
const fetchTrashPhotos = async () => {
setTrashLoading(true);
try {
const response = await fetch(`/api/v1/admin/trash-photos`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!response.ok) throw new Error('Không thể tải danh sách ảnh rác');
const data = await response.json();
setTrashPhotos(data);
} catch (err: any) {
setError(err.message);
} finally {
setTrashLoading(false);
}
};
const handleCleanTrash = async () => {
if (!confirm('Bạn có chắc muốn dọn sạch tất cả ảnh rác? Thao tác này sẽ xóa vĩnh viễn các tệp vật lý và dọn dẹp các bản ghi mồ côi.')) return;
setCleaning(true);
try {
const response = await fetch(`/api/v1/admin/trash-photos/clean`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Dọn dẹp thất bại');
alert(data.message);
fetchTrashPhotos();
} catch (err: any) {
alert(err.message);
} finally {
setCleaning(false);
}
};
const handleDeleteSingleTrash = async (item: any) => {
if (!confirm('Xóa mục này vĩnh viễn?')) return;
try {
const response = await fetch(`/api/v1/admin/trash-photos/delete-single`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: item.type,
filePath: item.filePath,
id: item.id
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Xóa mục thất bại');
fetchTrashPhotos();
} catch (err: any) {
alert(err.message);
}
};
useEffect(() => {
if (isOpen) fetchUsers();
}, [isOpen]);
if (isOpen) {
if (activeTab === 'users') {
fetchUsers();
} else if (activeTab === 'photos') {
fetchPhotos();
} else if (activeTab === 'trash') {
fetchTrashPhotos();
}
}
}, [isOpen, activeTab]);
const handleToggleBlock = async (id: string) => {
try {
@@ -60,29 +146,83 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
}
};
const handleDeletePhoto = async (id: string) => {
if (!confirm('Bạn có chắc chắn muốn xóa bức ảnh công khai này?')) return;
try {
const res = await fetch(`/api/v1/photos/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message);
}
fetchPhotos();
} catch (err: any) {
alert(err.message);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-4xl bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div className="relative w-full max-w-4xl bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col h-[85vh]">
{/* Header */}
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<div>
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Shield className="w-6 h-6 text-blue-600" /> Quản người dùng
<Shield className="w-6 h-6 text-blue-600" /> Hệ thống quản trị
</h2>
<p className="text-sm text-gray-500">Quản trị viên quyền thêm, sửa, xóa hoặc khóa tài khoản.</p>
<p className="text-sm text-gray-500">Quản thành viên các tài nguyên công cộng của ng dụng.</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
<X className="w-6 h-6 text-gray-400" />
</button>
</div>
{/* Tab Selection */}
<div className="flex border-b border-gray-100 bg-gray-50/20 px-6">
<button
onClick={() => setActiveTab('users')}
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 ${
activeTab === 'users' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
}`}
>
<User className="w-4 h-4" />
Thành viên
</button>
<button
onClick={() => setActiveTab('photos')}
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 ${
activeTab === 'photos' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
}`}
>
<ImageIcon className="w-4 h-4" />
nh công cộng
</button>
<button
onClick={() => setActiveTab('trash')}
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 ${
activeTab === 'trash' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
}`}
>
<Trash2 className="w-4 h-4" />
nh rác
</button>
</div>
{/* Content Body */}
<div className="flex-1 overflow-y-auto p-6">
{loading ? (
{error && (
<div className="p-4 bg-red-50 text-red-600 rounded-xl font-bold mb-4">{error}</div>
)}
{activeTab === 'users' ? (
loading ? (
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
) : error ? (
<div className="p-4 bg-red-50 text-red-600 rounded-xl font-bold">{error}</div>
) : (
<table className="w-full text-left border-collapse">
<thead>
@@ -143,6 +283,131 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
))}
</tbody>
</table>
)
) : activeTab === 'photos' ? (
photosLoading ? (
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
) : photos.length === 0 ? (
<div className="text-center py-20 text-gray-400 italic">Chưa nh công cộng nào đưc tải lên.</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6">
{photos.map(p => (
<div key={p.id} className="relative group bg-gray-50 border border-gray-100 rounded-2xl overflow-hidden shadow-sm hover:shadow-md transition-all flex flex-col justify-between">
<div className="aspect-square bg-slate-900 overflow-hidden flex items-center justify-center relative">
<img src={p.imageUrl} alt="Public content" className="w-full h-full object-cover transition-transform group-hover:scale-105" />
{/* Delete button shown on hover/focus */}
<button
onClick={() => handleDeletePhoto(p.id)}
className="absolute top-2 right-2 p-2 bg-red-600 hover:bg-red-500 text-white rounded-xl shadow-lg transition-all active:scale-95 opacity-0 group-hover:opacity-100 focus:opacity-100 z-10"
title="Xóa ảnh công cộng"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="p-3 bg-white">
<div className="font-bold text-xs text-gray-800 truncate" title={p.uploader?.name || 'Ẩn danh'}>
Đăng bởi: {p.uploader?.name || 'Ẩn danh'}
</div>
<div className="text-[10px] text-gray-400 mt-1">
{new Date(p.capturedAt).toLocaleDateString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})}
</div>
</div>
</div>
))}
</div>
)
) : (
trashLoading ? (
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
) : (
<div className="space-y-4">
<div className="flex justify-between items-center bg-gray-50 p-4 rounded-2xl border border-gray-100">
<div>
<span className="text-sm font-bold text-gray-800">
Tổng số lượng: <span className="text-red-500 font-extrabold text-base">{trashPhotos.length}</span> mục nh rác
</span>
<span className="mx-2 text-gray-300">|</span>
<span className="text-sm font-bold text-gray-800">
Dung lượng ưc tính: <span className="text-blue-600 font-extrabold text-base">
{(trashPhotos.reduce((acc, curr) => acc + (curr.size || 0), 0) / (1024 * 1024)).toFixed(2)} MB
</span>
</span>
</div>
{trashPhotos.length > 0 && (
<button
onClick={handleCleanTrash}
disabled={cleaning}
className="px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white rounded-xl font-bold text-xs uppercase tracking-wider flex items-center gap-2 shadow-md transition-all active:scale-95 disabled:opacity-50"
>
{cleaning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Dọn sạch nh rác
</button>
)}
</div>
{trashPhotos.length === 0 ? (
<div className="text-center py-20 text-gray-400 italic">Không nh rác nào. Hệ thống của bạn sạch sẽ!</div>
) : (
<div className="border border-gray-100 rounded-2xl overflow-hidden shadow-sm">
<table className="w-full text-left border-collapse bg-white">
<thead>
<tr className="text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100 bg-gray-50/50">
<th className="py-3 px-4 font-bold">Hình nh</th>
<th className="py-3 px-4 font-bold">Thông tin</th>
<th className="py-3 px-4 font-bold"> do</th>
<th className="py-3 px-4 font-bold">Kích thước</th>
<th className="py-3 px-4 font-bold text-right">Thao tác</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{trashPhotos.map((item, index) => (
<tr key={index} className="group hover:bg-gray-50/50 transition-colors">
<td className="py-3 px-4">
{item.url ? (
<div className="w-12 h-12 rounded-lg bg-gray-900 overflow-hidden shadow-sm border border-gray-100 flex items-center justify-center">
<img src={item.url} alt="Trash preview" className="w-full h-full object-cover" onError={(e) => { (e.target as HTMLElement).style.display = 'none'; }} />
</div>
) : (
<div className="w-12 h-12 rounded-lg bg-gray-100 border border-gray-100 flex items-center justify-center text-gray-400 text-[10px] font-bold">
N/A
</div>
)}
</td>
<td className="py-3 px-4 max-w-[200px] truncate">
<span className="text-xs font-mono text-gray-500 block truncate" title={item.url || item.filePath}>
{item.url || item.filePath}
</span>
<span className="text-[10px] font-black uppercase text-gray-400 tracking-wider">
{item.type === 'file_only' ? 'Tệp tin mồ côi' : 'Bản ghi mồ côi'}
</span>
</td>
<td className="py-3 px-4">
<span className="text-xs text-red-500 font-semibold">{item.reason}</span>
</td>
<td className="py-3 px-4 text-xs font-semibold text-gray-700">
{((item.size || 0) / 1024).toFixed(1)} KB
</td>
<td className="py-3 px-4 text-right">
<button
onClick={() => handleDeleteSingleTrash(item)}
className="p-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-all shadow-sm active:scale-95"
title="Xóa vĩnh viễn"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
)}
</div>
</div>
+159 -8
View File
@@ -1,14 +1,15 @@
import React, { useEffect, useState } from 'react';
import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents, Tooltip } from 'react-leaflet';
import { MapContainer, TileLayer, Marker, useMap, 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, Edit2, Trash2, Share2, Filter, Tag as TagIcon, MapPin, Loader2 } from 'lucide-react';
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Share2, Filter, MapPin, Loader2 } from 'lucide-react';
import { UserManagementModal } from '@/components/UserManagementModal';
import { useNotification } from '@/hooks/useNotification';
import { CreateTourModal } from '../components/CreateTourModal';
import { PublicPhotoModal } from '../components/PublicPhotoModal';
// Fix lỗi icon mặc định của Leaflet
const DefaultIcon = L.icon({
@@ -56,7 +57,7 @@ function MapTracker() {
return null;
}
export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void, onOpenMyPhotos: () => void }) => {
export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos, onLoginSuccess }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void, onOpenMyPhotos: () => void, onLoginSuccess?: (user: any) => void }) => {
// 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);
@@ -76,6 +77,45 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
const [userPos, setUserPos] = useState<[number, number]>(initialViewState?.center || [10.7769, 106.7009]);
const [mapZoom] = useState(initialViewState?.zoom || 13);
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null);
const [selectedPhotoGroup, setSelectedPhotoGroup] = useState<any[]>([]);
const groupedPhotos = React.useMemo(() => {
const groups: { [key: string]: any[] } = {};
publicPhotos.forEach((photo) => {
const lat = photo.metadata?.lat;
const lng = photo.metadata?.lng;
if (typeof lat === 'number' && typeof lng === 'number') {
const key = `${lat.toFixed(5)},${lng.toFixed(5)}`;
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(photo);
}
});
// Sort each group by likes descending
Object.values(groups).forEach((group) => {
group.sort((a, b) => {
const likesA = a.metadata?.likedUserIds?.length || 0;
const likesB = b.metadata?.likedUserIds?.length || 0;
return likesB - likesA;
});
});
return Object.values(groups);
}, [publicPhotos]);
const fetchPublicPhotos = async () => {
try {
const response = await fetch('/api/v1/public-photos');
if (response.ok) {
const data = await response.json();
setPublicPhotos(data);
}
} catch (error) {
console.error('Error fetching public photos:', error);
}
};
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); // State để điều khiển hiển thị dropdown lọc
@@ -194,7 +234,10 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
}, [publicTours, selectedFilterTag]);
useEffect(() => {
// Chỉ fetch dữ liệu khi người dùng đã đăng nhập và có token
// Luôn tải danh sách ảnh công khai để hiển thị trên bản đồ cho tất cả mọi người
fetchPublicPhotos();
// Chỉ tải danh sách tour khi người dùng đã đăng nhập và có token
if (user || localStorage.getItem('token')) {
fetchPublicTours();
}
@@ -210,15 +253,45 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
() => console.log("Không thể lấy vị trí người dùng")
);
} else {
// Cập nhật store để đồng bộ với vị trí khởi tạo từ cache
setMapCenter(initialViewState.center);
}
}, []);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const photoId = params.get('photoId');
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 group = publicPhotos.filter((p) => {
const pLat = p.metadata?.lat;
const pLng = p.metadata?.lng;
return typeof pLat === 'number' && typeof pLng === 'number' &&
Math.abs(pLat - lat) < 0.00001 &&
Math.abs(pLng - lng) < 0.00001;
});
group.sort((a, b) => {
const likesA = a.metadata?.likedUserIds?.length || 0;
const likesB = b.metadata?.likedUserIds?.length || 0;
return likesB - likesA;
});
setSelectedPhoto(foundPhoto);
setSelectedPhotoGroup(group);
} else {
setSelectedPhoto(foundPhoto);
setSelectedPhotoGroup([foundPhoto]);
}
}
}
}, [publicPhotos]);
return (
<div className="h-screen w-full relative">
<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="absolute top-4 left-4 right-4 z-[1002] flex items-center justify-between pointer-events-none">
<div className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] left-4 right-4 z-[1002] flex items-center justify-between pointer-events-none">
{/* Nhóm bên trái: Quay lại và Thông tin vị trí */}
<div className="flex items-center gap-3 pointer-events-auto">
<button
@@ -369,10 +442,10 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
zoom={mapZoom}
className="h-full w-full"
preferCanvas={true}
attributionControl={false}
>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; OpenStreetMap contributors'
/>
{/* Theo dõi di chuyển bản đồ */}
@@ -449,6 +522,49 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
);
})}
</MarkerClusterGroup>
{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;
return (
<Marker
key={latestPhoto.id}
position={[lat, lng]}
eventHandlers={{
click: () => {
setSelectedPhoto(latestPhoto);
setSelectedPhotoGroup(photoGroup);
const params = new URLSearchParams(window.location.search);
params.set('photoId', latestPhoto.id);
window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`);
}
}}
icon={L.divIcon({
className: 'custom-photo-bubble',
html: `
<a href="${window.location.origin}/api/v1/public-photos/${latestPhoto.id}/share" onclick="event.preventDefault();" class="relative group block">
<div class="w-12 h-12 rounded-full border-4 border-emerald-500 shadow-lg overflow-hidden transition-transform group-hover:scale-110">
<img src="${latestPhoto.imageUrl}" class="w-full h-full object-cover" />
</div>
<div class="absolute -bottom-1 -right-1 bg-emerald-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
📸
</div>
${photoGroup.length > 1 ? `
<div class="absolute -top-1 -left-1 bg-rose-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[9px] font-black text-white shadow-md animate-bounce">
${photoGroup.length}
</div>
` : ''}
</a>
`,
iconSize: [48, 48],
iconAnchor: [24, 24]
})}
/>
);
})}
</MapContainer>
{/* Context Menu Chia sẻ */}
@@ -498,6 +614,41 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
onViewTour(tour.id);
}}
/>
{selectedPhoto && (
<PublicPhotoModal
isOpen={!!selectedPhoto}
onClose={() => {
setSelectedPhoto(null);
setSelectedPhotoGroup([]);
const params = new URLSearchParams(window.location.search);
if (params.has('photoId')) {
params.delete('photoId');
const newSearch = params.toString();
const newUrl = `${window.location.pathname}${newSearch ? `?${newSearch}` : ''}`;
window.history.replaceState({}, '', newUrl);
}
}}
photo={selectedPhoto}
photoGroup={selectedPhotoGroup}
onSelectPhoto={(photo) => {
setSelectedPhoto(photo);
const params = new URLSearchParams(window.location.search);
params.set('photoId', photo.id);
window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`);
}}
onLoginSuccess={onLoginSuccess}
onUpdatePhoto={(updatedPhoto) => {
setPublicPhotos((prev) =>
prev.map((p) => (p.id === updatedPhoto.id ? updatedPhoto : p))
);
setSelectedPhoto(updatedPhoto);
setSelectedPhotoGroup((prev) =>
prev.map((p) => (p.id === updatedPhoto.id ? updatedPhoto : p))
);
}}
/>
)}
</div>
);
};
+287 -117
View File
@@ -1,15 +1,7 @@
import React, { useState, useMemo } from 'react';
import { LogIn, Compass, ArrowRight, Map as MapIcon, UserPlus, ShieldCheck } from 'lucide-react';
import React, { useState, useRef, useEffect } from 'react';
import { LogIn, Compass, Map as MapIcon, Camera } from 'lucide-react';
import { LoginModal } from '../components/LoginModal';
const TRAVEL_IMAGES = [
"https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1503220317375-aaad61436b1b?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1513581166391-887a96df91e7?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1530789253388-582c481c54b0?auto=format&fit=crop&q=80"
];
import { useNotification } from '@/hooks/useNotification';
interface LandingPageProps {
onContinue?: () => void;
@@ -19,135 +11,313 @@ interface LandingPageProps {
isInitialSetup?: boolean;
}
// Component Animation cho người lữ hành
const TravelerAnimation = ({ onClick }: { onClick?: () => void }) => {
return <button
onClick={onClick}
className="relative w-48 h-48 flex items-center justify-center transition-transform active:scale-95 focus:outline-none"
style={{
animation: 'gentle-shake 5s ease-in-out infinite',
}}
aria-label="Khám phá bản đồ"
>
{/* Giảm kích thước icon xuống 75% (w-24 -> w-18, md:w-28 -> md:w-21) */}
<Camera className="w-18 h-18 md:w-21 md:h-21 text-white/80 drop-shadow-2xl" strokeWidth={1.5} />
{/* Keyframes cho animation rung lắc */}
<style>{`
@keyframes gentle-shake {
0%, 100% {
transform: rotate(0deg) scale(1);
}
50% {
transform: rotate(2deg) scale(1.05);
}
}
`}</style>
</button>;
};
export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSignup, onGoToMap, onLoginSuccess, isInitialSetup }) => {
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const notify = useNotification();
// Chọn ngẫu nhiên một hình ảnh khi người dùng truy cập trang
const backgroundImage = useMemo(() => {
return TRAVEL_IMAGES[Math.floor(Math.random() * TRAVEL_IMAGES.length)];
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
const [currentBgIndex, setCurrentBgIndex] = useState(0);
const [bg1, setBg1] = useState('/background.avif');
const [bg2, setBg2] = useState('');
const [fade1, setFade1] = useState(true);
const [fade2, setFade2] = useState(false);
const [activeSlot, setActiveSlot] = useState<1 | 2>(1);
useEffect(() => {
const nextUrl = publicPhotos.length > 0 ? publicPhotos[currentBgIndex]?.imageUrl : '/background.avif';
if (!nextUrl) return;
const currentUrl = activeSlot === 1 ? bg1 : bg2;
if (nextUrl === currentUrl) return;
if (activeSlot === 1) {
setBg2(nextUrl);
setFade2(true);
setFade1(false);
setActiveSlot(2);
} else {
setBg1(nextUrl);
setFade1(true);
setFade2(false);
setActiveSlot(1);
}
}, [currentBgIndex, publicPhotos]);
const fetchPublicPhotos = async () => {
try {
const res = await fetch('/api/v1/public-photos');
if (res.ok) {
const data = await res.json();
const sortedData = data.sort((a: any, b: any) => {
const likesA = a.metadata?.likedUserIds?.length || 0;
const likesB = b.metadata?.likedUserIds?.length || 0;
return likesB - likesA;
});
setPublicPhotos(sortedData);
}
} catch (e) {
console.error('Lỗi khi tải ảnh công khai:', e);
}
};
useEffect(() => {
fetchPublicPhotos();
}, []);
useEffect(() => {
if (publicPhotos.length <= 1) return;
const interval = setInterval(() => {
setCurrentBgIndex((prev) => (prev + 1) % publicPhotos.length);
}, 12000);
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' });
try {
// 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 }
);
}
}),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 4500))
]);
// 1. 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));
}
// 2. Tải ảnh lên
const formData = new FormData();
formData.append('images', file);
if (location) {
formData.append('latitude', location.coords.latitude.toString());
formData.append('longitude', location.coords.longitude.toString());
}
let uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
method: 'POST',
headers: { 'Authorization': `Bearer ${guestToken!}` },
body: formData,
});
if (uploadRes.status === 401) {
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!);
localStorage.setItem('guest_user', JSON.stringify(guestUser));
uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
method: 'POST',
headers: { 'Authorization': `Bearer ${guestToken!}` },
body: formData,
});
}
if (!uploadRes.ok) {
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' });
// Cập nhật lại danh sách ảnh lập tức
await fetchPublicPhotos();
setCurrentBgIndex(0);
// Đăng nhập luôn bằng tài khoản khách này để người dùng xem được ảnh của mình trên bản đồ
localStorage.setItem('token', guestToken!);
localStorage.setItem('user', JSON.stringify(guestUser));
if (onLoginSuccess) {
onLoginSuccess(guestUser);
}
} 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 = '';
}
};
return (
<div className="flex h-screen w-full flex-col md:flex-row overflow-hidden font-sans bg-gray-50 relative">
{/* Logo/Brand Header ở góc trên bên trái */}
<div className="absolute top-8 left-8 z-20 flex items-center gap-3 text-white md:text-white drop-shadow-lg">
<Compass className="w-10 h-10" />
<span className="text-2xl font-black tracking-tighter uppercase">Travel Planner</span>
</div>
{/* Nửa bên trái: Hiển thị một hình ảnh duy nhất */}
<div className="hidden md:block md:w-1/2 relative h-full bg-blue-900">
<div className="h-dvh w-full overflow-hidden font-sans bg-gray-900 relative">
{/* Background Image - Hiển thị trên mọi thiết bị với hiệu ứng Crossfade & Panning */}
<div className="absolute inset-0 z-0 bg-gray-950 overflow-hidden">
{/* Slot 1 */}
{bg1 && (
<img
src={backgroundImage}
className="w-full h-full object-cover opacity-70"
alt="Travel Background"
key={bg1}
src={bg1}
className="absolute inset-y-0 left-0 h-full w-[120%] max-w-none object-cover transition-opacity duration-[1200ms] ease-in-out animate-panning"
style={{
opacity: fade1 ? 0.8 : 0,
}}
alt="Travel Background 1"
/>
<div className="absolute inset-0 bg-gradient-to-br from-black/40 to-transparent pointer-events-none" />
)}
{/* Thumbnail Grid ở góc dưới bên trái */}
<div className="absolute bottom-12 left-8 z-20 flex flex-col gap-2 items-start drop-shadow-2xl">
{/* Hàng trên cùng: 1 thumbnail */}
<div className="flex gap-2">
<img src={TRAVEL_IMAGES[0]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 1" />
</div>
{/* Hàng thứ 2: 2 thumbnail */}
<div className="flex gap-2">
<img src={TRAVEL_IMAGES[1]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 2" />
<img src={TRAVEL_IMAGES[2]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 3" />
</div>
{/* Hàng dưới cùng: 3 thumbnail */}
<div className="flex gap-2">
<img src={TRAVEL_IMAGES[3]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 4" />
<img src={TRAVEL_IMAGES[4]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 5" />
<img src={TRAVEL_IMAGES[5]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 6" />
</div>
</div>
</div>
{/* Nửa bên phải: Nội dung chào mừng và Hành động */}
<div className="flex-1 md:w-1/2 flex flex-col justify-center items-center bg-white p-8 lg:p-16 h-full overflow-y-auto">
<div className="max-w-md w-full space-y-12">
<div className="space-y-4">
{isInitialSetup ? (
<>
<div className="inline-flex items-center px-3 py-1 rounded-full bg-amber-50 text-amber-700 text-xs font-bold border border-amber-100 mb-2">
<ShieldCheck className="w-3 h-3 mr-1" /> CẤU HÌNH HỆ THỐNG LẦN ĐU
</div>
<h1 className="text-4xl font-extrabold text-gray-900 leading-tight">
Thiết lập Quản trị viên
</h1>
<p className="text-lg text-gray-500 leading-relaxed">
Chào mừng! Hệ thống vừa đưc cài đt. Vui lòng tạo tài khoản đu tiên đ quản vận hành trang web.
</p>
</>
) : (
<>
<h1 className="text-4xl font-extrabold text-gray-900 leading-tight">
Lên kế hoạch cho hành trình tiếp theo
</h1>
<p className="text-lg text-gray-500 leading-relaxed">
Khám phá các đa điểm nổi bật qua bản đ cộng đng hoặc đăng nhập đ bắt đu tự tạo chuyến đi cho riêng mình.
</p>
</>
{/* Slot 2 */}
{bg2 && (
<img
key={bg2}
src={bg2}
className="absolute inset-y-0 left-0 h-full w-[120%] max-w-none object-cover transition-opacity duration-[1200ms] ease-in-out animate-panning"
style={{
opacity: fade2 ? 0.8 : 0,
}}
alt="Travel Background 2"
/>
)}
</div>
<div className="grid grid-cols-1 gap-4">
{isInitialSetup && (
{/* Keyframes cho hiệu ứng panning từ trái sang phải */}
<style>{`
@keyframes pan-left-to-right {
0% {
transform: translateX(0) translateZ(0);
}
100% {
transform: translateX(-16.667%) translateZ(0);
}
}
.animate-panning {
animation: pan-left-to-right 13500ms linear forwards;
will-change: transform;
}
`}</style>
{/* Top Bar - Thanh điều hướng trên cùng */}
<div className="absolute top-0 left-0 right-0 z-20 p-4 pt-[calc(1rem+env(safe-area-inset-top,0px))] flex justify-between items-center">
<div className="flex items-center gap-2 text-white drop-shadow-lg">
<Compass className="w-8 h-8" />
<span className="text-xl font-black tracking-tighter uppercase hidden sm:block">Travel Planner</span>
</div>
<button
onClick={onGoToSignup}
className="flex items-center justify-center gap-3 bg-blue-600 hover:bg-blue-700 text-white font-bold py-5 px-8 rounded-2xl transition-all shadow-xl shadow-blue-100 animate-bounce-subtle mb-2"
onClick={() => setIsLoginModalOpen(true)}
className="flex items-center justify-center gap-2 bg-white/10 backdrop-blur-md text-white font-bold py-2 px-4 rounded-full border border-white/20 hover:bg-white/20 transition-all"
>
<UserPlus className="w-5 h-5" />
Bắt đu thiết lập ngay
<LogIn className="w-4 h-4" />
<span className="text-sm">Đăng nhập</span>
</button>
</div>
{/* Body: Animation người lữ hành */}
<div className="absolute inset-0 flex items-center justify-center z-10" >
{/* Kích hoạt click vào file input để chọn hoặc chụp ảnh */}
<TravelerAnimation onClick={() => fileInputRef.current?.click()} />
</div>
{/* Input chọn file ẩn để chụp/chọn ảnh */}
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
capture="environment"
className="hidden"
/>
{/* Community Gallery Previews */}
{publicPhotos.length > 0 && (
<div className="absolute bottom-[calc(7rem+env(safe-area-inset-bottom,0px))] left-0 right-0 z-20 px-4 flex flex-col items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-white/70 drop-shadow-md">
Khoảnh khắc từ cộng đng ({publicPhotos.length})
</span>
<div className="flex gap-3 overflow-x-auto max-w-full no-scrollbar pb-2 px-4 justify-center">
{publicPhotos.slice(0, 8).map((photo, index) => (
<button
key={photo.id}
onClick={() => setCurrentBgIndex(index)}
className={`relative w-14 h-14 rounded-xl overflow-hidden border-2 transition-all active:scale-95 shrink-0 ${
currentBgIndex === index ? 'border-emerald-500 scale-110 shadow-lg' : 'border-white/20 hover:border-white/50'
}`}
>
<img src={photo.imageUrl} alt="Community thumbnail" className="w-full h-full object-cover" />
</button>
))}
</div>
<style>{`
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
`}</style>
</div>
)}
{/* Bottom Bar - Nút hành động chính */}
<div className="absolute bottom-[calc(2rem+env(safe-area-inset-bottom,0px))] left-1/2 -translate-x-1/2 z-20 w-full px-4">
<button
onClick={onGoToMap}
className="flex items-center justify-center gap-3 bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-4 px-8 rounded-2xl transition-all shadow-lg shadow-indigo-100"
className="w-full max-w-md mx-auto flex items-center justify-center gap-3 bg-white/20 backdrop-blur-lg text-white font-bold py-4 px-8 rounded-2xl transition-all shadow-2xl border border-white/30 hover:bg-white/30 active:scale-95"
>
<MapIcon className="w-5 h-5" />
Khám phá các hành trình du lịch
</button>
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => setIsLoginModalOpen(true)}
className="flex items-center justify-center gap-2 bg-blue-50 hover:bg-blue-100 text-blue-700 font-bold py-4 rounded-2xl transition-all"
>
<LogIn className="w-5 h-5" />
Đăng nhập
</button>
<button
onClick={onGoToSignup}
className="flex items-center justify-center gap-2 bg-gray-50 hover:bg-gray-100 text-gray-700 font-bold py-4 rounded-2xl border border-gray-100 transition-all"
>
<UserPlus className="w-5 h-5" />
Đăng
</button>
</div>
<button
onClick={onContinue}
className="flex items-center justify-center gap-3 text-gray-400 hover:text-gray-600 py-2 transition-all text-sm font-medium"
>
Tiếp tục tham quan nh
<ArrowRight className="w-5 h-5" />
</button>
</div>
<div className="pt-12 flex items-center gap-4 text-sm text-gray-400 border-t border-gray-100">
<div className="flex -space-x-3">
{[1, 2, 3, 4].map(i => (
<img key={i} className="w-10 h-10 rounded-full border-4 border-white" src={`https://i.pravatar.cc/100?img=${i+20}`} alt="user" />
))}
</div>
<p className="font-medium">Tham gia cùng +2,400 người du lịch khác</p>
</div>
</div>
</div>
{/* Login Modal Component */}
+335 -17
View File
@@ -1,7 +1,17 @@
import React, { useEffect, useState, useMemo } from 'react';
import { ChevronLeft, Image as ImageIcon, Download, Calendar, MapPin, Loader2, Filter, X, Trash2 } from 'lucide-react';
import { useEffect, useState, useMemo } from 'react';
import { ChevronLeft, Image as ImageIcon, Download, Calendar, MapPin, Loader2, Filter, X, Trash2, Edit, Heart } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
import { useConfirm } from '@/hooks/useConfirm';
import { CoordinateSelectModal } from '../components/CoordinateSelectModal';
const getMostLikedPhoto = (photos: any[]) => {
if (!photos || photos.length === 0) return null;
return [...photos].sort((a, b) => {
const likesA = a.metadata?.likedUserIds?.length || 0;
const likesB = b.metadata?.likedUserIds?.length || 0;
return likesB - likesA;
})[0];
};
export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
const [photos, setPhotos] = useState<any[]>([]);
@@ -13,12 +23,140 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
const notify = useNotification();
const confirm = useConfirm();
const [isEditing, setIsEditing] = useState(false);
const [editTitle, setEditTitle] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editLat, setEditLat] = useState<number | ''>('');
const [editLng, setEditLng] = useState<number | ''>('');
const [isSavingEdit, setIsSavingEdit] = useState(false);
const [isMapOpen, setIsMapOpen] = useState(false);
const [resolvedAddress, setResolvedAddress] = useState<string>('');
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const lat = selectedPhotoForDisplay?.metadata?.lat;
const lng = selectedPhotoForDisplay?.metadata?.lng;
if (typeof lat === 'number' && typeof lng === 'number') {
setResolvedAddress('Đang xác định địa điểm...');
fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&accept-language=vi`)
.then(res => {
if (!res.ok) throw new Error();
return res.json();
})
.then(data => {
if (data && data.display_name) {
const shortAddress = data.display_name.split(',').slice(0, 3).join(',').trim();
setResolvedAddress(shortAddress || data.display_name);
} else {
setResolvedAddress(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
}
})
.catch(() => {
setResolvedAddress(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
});
} else {
setResolvedAddress('Chưa xác định tọa độ');
}
}, [selectedPhotoForDisplay?.id, selectedPhotoForDisplay?.metadata?.lat, selectedPhotoForDisplay?.metadata?.lng]);
useEffect(() => {
if (selectedPhotoForDisplay) {
setEditTitle(selectedPhotoForDisplay.metadata?.title || '');
setEditDescription(selectedPhotoForDisplay.metadata?.description || '');
setEditLat(selectedPhotoForDisplay.metadata?.lat ?? '');
setEditLng(selectedPhotoForDisplay.metadata?.lng ?? '');
setIsEditing(false);
}
}, [selectedPhotoForDisplay]);
const handleSaveEdit = async () => {
if (editLat !== '' && (isNaN(editLat) || editLat < -90 || editLat > 90)) {
notify({ title: 'Lỗi', message: 'Vĩ độ không hợp lệ (-90 đến 90).', type: 'error' });
return;
}
if (editLng !== '' && (isNaN(editLng) || editLng < -180 || editLng > 180)) {
notify({ title: 'Lỗi', message: 'Kinh độ không hợp lệ (-180 đến 180).', type: 'error' });
return;
}
setIsSavingEdit(true);
try {
const response = await fetch(`/api/v1/photos/${selectedPhotoForDisplay.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
},
body: JSON.stringify({
title: editTitle,
description: editDescription,
latitude: editLat === '' ? undefined : editLat,
longitude: editLng === '' ? undefined : editLng
})
});
if (!response.ok) throw new Error('Failed to update photo info');
const updatedPhoto = await response.json();
notify({ title: 'Thành công', message: 'Thông tin ảnh đã được cập nhật.', type: 'success' });
// Update photos list
setPhotos(prev => prev.map(p => p.id === updatedPhoto.id ? { ...p, metadata: updatedPhoto.metadata } : p));
// Update selectedPhotoForDisplay
setSelectedPhotoForDisplay((prev: any) => prev ? ({ ...prev, metadata: updatedPhoto.metadata }) : null);
setIsEditing(false);
} catch (error) {
notify({ title: 'Lỗi', message: 'Không thể cập nhật thông tin ảnh. Vui lòng thử lại.', type: 'error' });
} finally {
setIsSavingEdit(false);
}
};
const currentUser = useMemo(() => {
const userStr = localStorage.getItem('user') || localStorage.getItem('guest_user');
if (!userStr) return null;
try {
return JSON.parse(userStr);
} catch (e) {
return null;
}
}, []);
const likedUserIds = selectedPhotoForDisplay && selectedPhotoForDisplay.metadata && Array.isArray(selectedPhotoForDisplay.metadata.likedUserIds)
? selectedPhotoForDisplay.metadata.likedUserIds
: [];
const isLiked = currentUser && likedUserIds.includes(currentUser.id);
const likeCount = likedUserIds.length;
const handleToggleLike = async () => {
if (!selectedPhotoForDisplay) return;
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
if (!token) return;
try {
const response = await fetch(`/api/v1/photos/${selectedPhotoForDisplay.id}/toggle-like`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const updatedPhoto = await response.json();
setPhotos(prev => prev.map(p => p.id === updatedPhoto.id ? { ...p, metadata: updatedPhoto.metadata } : p));
setSelectedPhotoForDisplay((prev: any) => prev ? ({ ...prev, metadata: updatedPhoto.metadata }) : null);
}
} catch (error) {
console.error('Lỗi khi thích ảnh:', error);
}
};
useEffect(() => {
const fetchPhotos = async () => {
try {
const response = await fetch('/api/v1/users/me/photos', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
}
});
if (!response.ok) throw new Error('Failed to fetch photos');
@@ -66,7 +204,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
// Effect để thiết lập ảnh được chọn hiển thị hoặc reset nếu ảnh hiện tại không còn trong danh sách lọc
useEffect(() => {
if (filteredPhotos.length > 0 && (!selectedPhotoForDisplay || !filteredPhotos.some(p => p.id === selectedPhotoForDisplay.id))) {
setSelectedPhotoForDisplay(filteredPhotos[0]);
setSelectedPhotoForDisplay(getMostLikedPhoto(filteredPhotos));
} else if (filteredPhotos.length === 0) {
setSelectedPhotoForDisplay(null);
} else if (selectedPhotoForDisplay) {
@@ -89,7 +227,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
const response = await fetch(`/api/v1/photos/${photoId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
}
});
@@ -215,45 +353,196 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
<div className="md:flex-1 bg-white rounded-2xl shadow-lg border border-gray-100 p-4 flex flex-col items-center justify-center min-h-[300px]">
{selectedPhotoForDisplay ? (
<div className="relative w-full h-full flex flex-col items-center justify-center">
<div className="relative overflow-hidden rounded-xl shadow-md max-w-full max-h-[calc(100vh-350px)] group">
<img
src={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl}
alt="Selected Photo"
className="max-w-full max-h-[calc(100vh-350px)] object-contain rounded-xl shadow-md"
className="max-w-full max-h-[calc(100vh-350px)] object-contain cursor-zoom-in"
onClick={() => setIsFullscreen(true)}
/>
{/* Overlays (Only show when not editing) */}
{!isEditing && (
<>
{/* Like (Heart) button overlay */}
<button
onClick={handleToggleLike}
className="absolute top-4 left-4 z-10 flex items-center gap-1.5 bg-black/60 hover:bg-black/75 border border-white/10 text-white font-bold py-1.5 px-3 rounded-full transition-all active:scale-95 text-[11px] backdrop-blur-md"
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-gray-300 hover:text-rose-450'
}`} />
<span>{likeCount}</span>
</button>
{/* Delete button overlay */}
<button
onClick={(e) => {
e.stopPropagation();
handleDeletePhoto(selectedPhotoForDisplay.id);
}}
className="absolute top-4 right-4 p-2 bg-red-500/80 backdrop-blur-sm text-white rounded-full shadow-lg hover:bg-red-600 transition-all active:scale-90"
className="absolute top-4 right-4 z-10 p-2 bg-black/60 hover:bg-red-600 border border-white/10 text-white rounded-full transition-all active:scale-95 backdrop-blur-md"
title="Xóa ảnh này"
>
<Trash2 className="w-5 h-5" />
<Trash2 className="w-4 h-4" />
</button>
<div className="mt-6 w-full flex items-center justify-between px-2">
{/* Bottom metadata details gradient panel overlay */}
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent p-6 text-white flex flex-col gap-2 text-left">
<div className="flex justify-between items-start gap-4">
<div className="flex-1 min-w-0">
{selectedPhotoForDisplay.metadata?.title ? (
<h3 className="text-base font-extrabold text-white break-words drop-shadow-md">
{selectedPhotoForDisplay.metadata.title}
</h3>
) : (
<span className="text-xs text-gray-350 italic block mb-1 drop-shadow-md">Chưa tiêu đ</span>
)}
{selectedPhotoForDisplay.metadata?.description ? (
<p className="text-xs text-gray-250 leading-relaxed mt-1 break-words drop-shadow-sm max-h-16 overflow-y-auto no-scrollbar">
{selectedPhotoForDisplay.metadata.description}
</p>
) : (
<span className="text-[11px] text-gray-350 italic block mt-1 drop-shadow-sm">Chưa tả</span>
)}
</div>
{/* Edit Button Overlay */}
<button
onClick={() => setIsEditing(true)}
className="p-2 bg-white/10 hover:bg-white/20 border border-white/15 rounded-xl text-white hover:text-gray-200 transition-all shrink-0 backdrop-blur-sm"
title="Chỉnh sửa thông tin"
>
<Edit className="w-4 h-4" />
</button>
</div>
{/* Additional metadata info inside bottom overlay */}
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-white/10 pt-3 text-xs text-gray-200">
<div className="space-y-1">
<div className="flex items-center gap-2 text-gray-900 font-bold">
<MapPin className="w-4 h-4 text-blue-500" />
{selectedPhotoForDisplay.tour?.title || 'Không rõ hành trình'}
</div>
<div className="flex items-center gap-2 text-gray-400 text-xs font-medium uppercase tracking-wider">
<div className="flex items-center gap-2 text-gray-300 text-[10px] font-bold uppercase tracking-wider">
<Calendar className="w-3.5 h-3.5" />
{new Date(selectedPhotoForDisplay.capturedAt).toLocaleDateString('vi-VN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
Ngày chụp: {new Date(selectedPhotoForDisplay.capturedAt).toLocaleDateString('vi-VN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
</div>
<div className="flex items-center gap-2 font-bold" title={selectedPhotoForDisplay.metadata?.lat && selectedPhotoForDisplay.metadata?.lng ? `${selectedPhotoForDisplay.metadata.lat.toFixed(6)}, ${selectedPhotoForDisplay.metadata.lng.toFixed(6)}` : ''}>
<MapPin className="w-3.5 h-3.5 text-rose-500" />
Đa điểm: {resolvedAddress}
</div>
{selectedPhotoForDisplay.tour?.title && (
<div className="text-[10px] text-gray-400">
Hành trình: {selectedPhotoForDisplay.tour.title}
</div>
)}
</div>
{selectedPhotoForDisplay.originalUrl && (
<a
href={selectedPhotoForDisplay.originalUrl}
download
className="flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-2xl font-black uppercase tracking-widest text-[10px] transition-all shadow-lg shadow-blue-900/20 active:scale-95"
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold uppercase tracking-widest text-[9px] transition-all shadow-md active:scale-95 shrink-0"
>
<Download className="w-4 h-4" /> Tải xuống nh gốc
<Download className="w-3.5 h-3.5 animate-pulse" /> Tải nh gốc
</a>
)}
</div>
</div>
</>
)}
</div>
{isEditing && (
<div className="mt-6 w-full flex flex-col gap-4 px-2">
<div className="space-y-3 bg-gray-50 border border-gray-150 p-4 rounded-2xl w-full">
<h4 className="text-xs font-black uppercase tracking-wider text-blue-605">Chỉnh sửa thông tin nh</h4>
<div className="space-y-2">
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1">Tiêu đ</label>
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
placeholder="Nhập tiêu đề..."
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1"> tả</label>
<textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Nhập mô tả..."
rows={2}
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1"> đ</label>
<input
type="number"
step="any"
value={editLat}
onChange={(e) => setEditLat(e.target.value === '' ? '' : parseFloat(e.target.value))}
placeholder="Vĩ độ..."
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1">Kinh đ</label>
<input
type="number"
step="any"
value={editLng}
onChange={(e) => setEditLng(e.target.value === '' ? '' : parseFloat(e.target.value))}
placeholder="Kinh độ..."
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
</div>
<div className="flex justify-start">
<button
type="button"
onClick={() => setIsMapOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 border border-gray-200 text-gray-700 hover:text-gray-900 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={() => setIsEditing(false)}
disabled={isSavingEdit}
className="px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg text-xs font-bold transition-all"
>
Hủy
</button>
<button
onClick={handleSaveEdit}
disabled={isSavingEdit}
className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition-all"
>
{isSavingEdit ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Đang lưu...
</>
) : (
'Lưu lại'
)}
</button>
</div>
</div>
</div>
)}
</div>
) : (
<div className="text-center text-gray-400 py-20">
<ImageIcon className="w-16 h-16 mx-auto mb-4 opacity-20" />
@@ -296,6 +585,35 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
</div>
{}
</div>
<CoordinateSelectModal
isOpen={isMapOpen}
onClose={() => setIsMapOpen(false)}
initialLat={typeof editLat === 'number' ? editLat : undefined}
initialLng={typeof editLng === 'number' ? editLng : undefined}
onSelect={(lat, lng) => {
setEditLat(lat);
setEditLng(lng);
}}
/>
{isFullscreen && selectedPhotoForDisplay && (
<div
className="fixed inset-0 z-[9999] bg-black/95 flex items-center justify-center cursor-zoom-out animate-in fade-in duration-200"
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={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl}
alt="Fullscreen photo"
className="max-w-full max-h-full object-contain select-none animate-in zoom-in-95 duration-200"
/>
</div>
)}
</div>
);
};
+11 -2
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { User, Mail, Lock, ArrowRight, ChevronLeft, Compass, Phone, MapPin, ShieldCheck } from 'lucide-react';
import { User, Mail, Lock, ArrowRight, ChevronLeft, Phone, MapPin, ShieldCheck } from 'lucide-react';
interface SignupPageProps {
onBack: () => void;
@@ -54,17 +54,26 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
setStep('otp');
} else {
// Xác thực mã OTP bước hoàn tất
const guestUserStr = localStorage.getItem('guest_user');
const guestUser = guestUserStr ? JSON.parse(guestUserStr) : null;
const guestId = guestUser ? guestUser.id : undefined;
const response = await fetch(`/api/v1/auth/signup/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.email,
otp: otp
otp: otp,
guestId: guestId
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Mã OTP không chính xác');
// Dọn dẹp session lữ khách sau khi đăng ký thành công
localStorage.removeItem('guest_token');
localStorage.removeItem('guest_user');
onSuccess();
}
} catch (err: any) {
+374 -20
View File
@@ -12,6 +12,7 @@ import { AddPhotoModal } from '@/components/AddPhotoModal';
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline, useMap, Tooltip } from 'react-leaflet';
import _MarkerClusterGroup from 'react-leaflet-cluster';
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
import { CoordinateSelectModal } from '../components/CoordinateSelectModal';
import {
Map as MapIcon,
Wallet,
@@ -42,7 +43,10 @@ import {
Share2,
Tag as TagIcon,
Trash2,
FileText
FileText,
Edit,
Download,
Heart
} from 'lucide-react';
import L from 'leaflet';
@@ -325,6 +329,15 @@ const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.Lat
);
};
const getMostLikedPhoto = (photos: any[]) => {
if (!photos || photos.length === 0) return null;
return [...photos].sort((a, b) => {
const likesA = a.metadata?.likedUserIds?.length || 0;
const likesB = b.metadata?.likedUserIds?.length || 0;
return likesB - likesA;
})[0];
};
export const TourDetailPage = ({
onBack,
tourId,
@@ -500,7 +513,7 @@ export const TourDetailPage = ({
const [isMemberDetailOpen, setIsMemberDetailOpen] = useState(false);
const [selectedPhotoForDisplay, setSelectedPhotoForDisplay] = useState<any | null>(null); // New state for large photo display
const currentUserId = useMemo(() => {
const token = localStorage.getItem('token');
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
if (!token) return null;
try {
return JSON.parse(atob(token.split('.')[1])).sub;
@@ -508,6 +521,143 @@ export const TourDetailPage = ({
return null;
}
}, []);
const currentUser = useMemo(() => {
const userStr = localStorage.getItem('user') || localStorage.getItem('guest_user');
if (!userStr) return null;
try {
return JSON.parse(userStr);
} catch (e) {
return null;
}
}, []);
const [isEditingPhoto, setIsEditingPhoto] = useState(false);
const [editPhotoTitle, setEditPhotoTitle] = useState('');
const [editPhotoDescription, setEditPhotoDescription] = useState('');
const [editPhotoLat, setEditPhotoLat] = useState<number | ''>('');
const [editPhotoLng, setEditPhotoLng] = useState<number | ''>('');
const [isSavingPhotoEdit, setIsSavingPhotoEdit] = useState(false);
const [isMapOpen, setIsMapOpen] = useState(false);
const [resolvedAddress, setResolvedAddress] = useState<string>('');
const [isFullscreen, setIsFullscreen] = useState(false);
const likedUserIds = selectedPhotoForDisplay && selectedPhotoForDisplay.metadata && Array.isArray(selectedPhotoForDisplay.metadata.likedUserIds)
? selectedPhotoForDisplay.metadata.likedUserIds
: [];
const isPhotoLiked = currentUser && likedUserIds.includes(currentUser.id);
const photoLikeCount = likedUserIds.length;
const handleToggleLikePhoto = async () => {
if (!selectedPhotoForDisplay) return;
try {
const response = await fetch(`/api/v1/photos/${selectedPhotoForDisplay.id}/toggle-like`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
}
});
if (response.ok) {
const data = await response.json();
setSelectedPhotoForDisplay((prev: any) => {
if (!prev) return null;
return {
...prev,
metadata: {
...prev.metadata,
likedUserIds: data.likedUserIds
}
};
});
if (currentTour) {
fetchTour(currentTour.id);
}
}
} catch (error) {
console.error('Error toggling like:', error);
}
};
useEffect(() => {
const lat = selectedPhotoForDisplay?.metadata?.lat;
const lng = selectedPhotoForDisplay?.metadata?.lng;
if (typeof lat === 'number' && typeof lng === 'number') {
setResolvedAddress('Đang xác định địa điểm...');
fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&accept-language=vi`)
.then(res => {
if (!res.ok) throw new Error();
return res.json();
})
.then(data => {
if (data && data.display_name) {
const shortAddress = data.display_name.split(',').slice(0, 3).join(',').trim();
setResolvedAddress(shortAddress || data.display_name);
} else {
setResolvedAddress(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
}
})
.catch(() => {
setResolvedAddress(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
});
} else {
setResolvedAddress('Chưa xác định tọa độ');
}
}, [selectedPhotoForDisplay?.id, selectedPhotoForDisplay?.metadata?.lat, selectedPhotoForDisplay?.metadata?.lng]);
useEffect(() => {
if (selectedPhotoForDisplay) {
setEditPhotoTitle(selectedPhotoForDisplay.metadata?.title || '');
setEditPhotoDescription(selectedPhotoForDisplay.metadata?.description || '');
setEditPhotoLat(selectedPhotoForDisplay.metadata?.lat ?? '');
setEditPhotoLng(selectedPhotoForDisplay.metadata?.lng ?? '');
setIsEditingPhoto(false);
}
}, [selectedPhotoForDisplay]);
const handleSavePhotoEdit = async () => {
if (!selectedPhotoForDisplay) return;
if (editPhotoLat !== '' && (isNaN(editPhotoLat) || editPhotoLat < -90 || editPhotoLat > 90)) {
notify({ title: 'Lỗi', message: 'Vĩ độ không hợp lệ (-90 đến 90).', type: 'error' });
return;
}
if (editPhotoLng !== '' && (isNaN(editPhotoLng) || editPhotoLng < -180 || editPhotoLng > 180)) {
notify({ title: 'Lỗi', message: 'Kinh độ không hợp lệ (-180 đến 180).', type: 'error' });
return;
}
setIsSavingPhotoEdit(true);
try {
const response = await fetch(`/api/v1/photos/${selectedPhotoForDisplay.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
},
body: JSON.stringify({
title: editPhotoTitle,
description: editPhotoDescription,
latitude: editPhotoLat === '' ? undefined : editPhotoLat,
longitude: editPhotoLng === '' ? undefined : editPhotoLng
})
});
if (!response.ok) throw new Error('Failed to update photo info');
const updatedPhoto = await response.json();
notify({ title: 'Thành công', message: 'Thông tin ảnh đã được cập nhật.', type: 'success' });
setSelectedPhotoForDisplay((prev: any) => prev ? ({ ...prev, metadata: updatedPhoto.metadata }) : null);
if (currentTour) {
fetchTour(currentTour.id);
}
setIsEditingPhoto(false);
} catch (error) {
notify({ title: 'Lỗi', message: 'Không thể cập nhật thông tin ảnh. Vui lòng thử lại.', type: 'error' });
} finally {
setIsSavingPhotoEdit(false);
}
};
const [joinRequests, setJoinRequests] = useState<any[]>([]);
const [titleInput, setTitleInput] = useState(currentTour?.title ?? '');
const [descriptionInput, setDescriptionInput] = useState(currentTour?.description ?? '');
@@ -541,9 +691,9 @@ export const TourDetailPage = ({
// Effect to set initial selected photo for display or reset if current one is no longer in filtered list
useEffect(() => {
if (filteredPhotos.length > 0 && !selectedPhotoForDisplay) {
setSelectedPhotoForDisplay(filteredPhotos[0]);
setSelectedPhotoForDisplay(getMostLikedPhoto(filteredPhotos));
} else if (selectedPhotoForDisplay && !filteredPhotos.some(p => p.id === selectedPhotoForDisplay.id)) {
setSelectedPhotoForDisplay(filteredPhotos.length > 0 ? filteredPhotos[0] : null);
setSelectedPhotoForDisplay(filteredPhotos.length > 0 ? getMostLikedPhoto(filteredPhotos) : null);
}
}, [filteredPhotos, selectedPhotoForDisplay]);
const [isCommentModalOpen, setIsCommentModalOpen] = useState(false);
@@ -595,7 +745,7 @@ export const TourDetailPage = ({
const response = await fetch(`/api/v1/photos/${photoId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
}
});
@@ -694,7 +844,7 @@ export const TourDetailPage = ({
if (currentTour && currentTour.photos && currentTour.photos.length > 0) {
// If there are photos, and no leg is selected, default to 'all' and first photo
if (selectedLegIdForPhoto === 'all' && !selectedPhotoForDisplay) {
setSelectedPhotoForDisplay(currentTour.photos[0]);
setSelectedPhotoForDisplay(getMostLikedPhoto(currentTour.photos));
} else if (selectedLegIdForPhoto !== 'all') {
// If a specific leg is selected, try to find a photo for that leg
const targetLeg = legs.find(l => l.id === selectedLegIdForPhoto);
@@ -702,9 +852,9 @@ export const TourDetailPage = ({
const locationIdsInLeg = targetLeg.locations.map((loc: any) => loc.id);
const photosInLeg = currentTour.photos.filter((p: any) => p.locationId && locationIdsInLeg.includes(p.locationId));
if (photosInLeg.length > 0 && !selectedPhotoForDisplay) {
setSelectedPhotoForDisplay(photosInLeg[0]);
setSelectedPhotoForDisplay(getMostLikedPhoto(photosInLeg));
} else if (selectedPhotoForDisplay && !photosInLeg.some(p => p.id === selectedPhotoForDisplay.id)) {
setSelectedPhotoForDisplay(photosInLeg.length > 0 ? photosInLeg[0] : null);
setSelectedPhotoForDisplay(photosInLeg.length > 0 ? getMostLikedPhoto(photosInLeg) : null);
}
}
}
@@ -1204,7 +1354,7 @@ export const TourDetailPage = ({
date: tourDateDisplay,
membersCount: currentTour?.participants?.length || 0,
budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND",
coverImage: currentTour?.photos?.[0]?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
coverImage: getMostLikedPhoto(currentTour?.photos || [])?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
};
return (
@@ -1587,8 +1737,9 @@ export const TourDetailPage = ({
zoom={mapZoom}
className="h-full w-full"
preferCanvas={true}
attributionControl={false}
>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="" />
{canEdit && !isPublicView && (
<>
<MapHoverTip canEdit={canEdit} />
@@ -1911,25 +2062,198 @@ export const TourDetailPage = ({
{/* Right Column: Large Photo Display */}
<div className="md:flex-1 bg-white rounded-2xl shadow-lg border border-gray-100 p-4 flex flex-col items-center justify-center min-h-[300px]">
{selectedPhotoForDisplay ? (
<div className="relative w-full h-full flex items-center justify-center">
<div className="relative w-full h-full flex flex-col items-center justify-center">
<div className="relative overflow-hidden rounded-xl shadow-md max-w-full max-h-[calc(100vh-350px)] group">
<img
src={selectedPhotoForDisplay.imageUrl}
alt="Selected Tour Photo"
className="max-w-full max-h-[calc(100vh-200px)] object-contain rounded-xl shadow-md"
className="max-w-full max-h-[calc(100vh-350px)] object-contain cursor-zoom-in"
onClick={() => setIsFullscreen(true)}
/>
{/* Optional: Add delete button for the large photo */}
{currentUserId === selectedPhotoForDisplay.uploaderId && !isPublicView && (
{/* Overlays (Only show when not editing) */}
{!isEditingPhoto && (
<>
{/* Like (Heart) button overlay */}
<button
onClick={handleToggleLikePhoto}
className="absolute top-4 left-4 z-10 flex items-center gap-1.5 bg-black/60 hover:bg-black/75 border border-white/10 text-white font-bold py-1.5 px-3 rounded-full transition-all active:scale-95 text-[11px] backdrop-blur-md"
title={isPhotoLiked ? "Bỏ thích" : "Thích"}
>
<Heart className={`w-4 h-4 transition-colors ${
isPhotoLiked ? 'text-rose-500 fill-rose-500 animate-in zoom-in-75' : 'text-gray-300 hover:text-rose-450'
}`} />
<span>{photoLikeCount}</span>
</button>
{/* Delete button overlay (if owner) */}
{currentUserId === selectedPhotoForDisplay.uploaderId && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeletePhoto(selectedPhotoForDisplay.id);
}}
className="absolute top-4 right-4 p-2 bg-red-500/80 backdrop-blur-sm text-white rounded-full shadow-lg hover:bg-red-600 transition-colors"
className="absolute top-4 right-4 z-10 p-2 bg-black/60 hover:bg-red-650 border border-white/10 text-white rounded-full transition-all active:scale-95 backdrop-blur-md"
title="Xóa ảnh này"
>
<Trash2 className="w-5 h-5" />
<Trash2 className="w-4 h-4" />
</button>
)}
{/* Bottom metadata details gradient panel overlay */}
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent p-6 text-white flex flex-col gap-2 text-left">
<div className="flex justify-between items-start gap-4">
<div className="flex-1 min-w-0">
{selectedPhotoForDisplay.metadata?.title ? (
<h3 className="text-base font-extrabold text-white break-words drop-shadow-md">
{selectedPhotoForDisplay.metadata.title}
</h3>
) : (
<span className="text-xs text-gray-350 italic block mb-1 drop-shadow-md">Chưa tiêu đ</span>
)}
{selectedPhotoForDisplay.metadata?.description ? (
<p className="text-xs text-gray-250 leading-relaxed mt-1 break-words drop-shadow-sm max-h-16 overflow-y-auto no-scrollbar">
{selectedPhotoForDisplay.metadata.description}
</p>
) : (
<span className="text-[11px] text-gray-350 italic block mt-1 drop-shadow-sm">Chưa tả</span>
)}
</div>
{/* Edit Button Overlay */}
{((!isPublicView && currentUser?.isAdmin) || (currentUserId && currentUserId === selectedPhotoForDisplay.uploaderId)) && (
<button
onClick={() => setIsEditingPhoto(true)}
className="p-2 bg-white/10 hover:bg-white/20 border border-white/15 rounded-xl text-white hover:text-gray-200 transition-all shrink-0 backdrop-blur-sm"
title="Chỉnh sửa thông tin"
>
<Edit className="w-4 h-4" />
</button>
)}
</div>
{/* Additional metadata info inside bottom overlay */}
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-white/10 pt-3 text-xs text-gray-200">
<div className="space-y-1">
<div className="flex items-center gap-2 text-gray-300 text-[10px] font-bold uppercase tracking-wider">
<Calendar className="w-3.5 h-3.5" />
Ngày chụp: {new Date(selectedPhotoForDisplay.capturedAt).toLocaleDateString('vi-VN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
</div>
<div className="flex items-center gap-2 font-bold" title={selectedPhotoForDisplay.metadata?.lat && selectedPhotoForDisplay.metadata?.lng ? `${selectedPhotoForDisplay.metadata.lat.toFixed(6)}, ${selectedPhotoForDisplay.metadata.lng.toFixed(6)}` : ''}>
<MapPin className="w-3.5 h-3.5 text-rose-500" />
Đa điểm: {resolvedAddress}
</div>
<div className="text-[10px] text-gray-400">
Tải lên bởi: {selectedPhotoForDisplay.uploader?.name || 'Thành viên'}
</div>
</div>
{(currentUser?.isAdmin || currentUserId === selectedPhotoForDisplay.uploaderId) && selectedPhotoForDisplay.originalUrl && (
<a
href={selectedPhotoForDisplay.originalUrl}
download
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold uppercase tracking-widest text-[9px] transition-all shadow-md active:scale-95 shrink-0"
>
<Download className="w-3.5 h-3.5 animate-pulse" /> Tải nh gốc
</a>
)}
</div>
</div>
</>
)}
</div>
{isEditingPhoto && (
<div className="mt-6 w-full flex flex-col gap-4 px-2">
<div className="space-y-3 bg-gray-50 border border-gray-150 p-4 rounded-2xl w-full text-left">
<h4 className="text-xs font-black uppercase tracking-wider text-blue-600">Chỉnh sửa thông tin nh</h4>
<div className="space-y-2">
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1">Tiêu đ</label>
<input
type="text"
value={editPhotoTitle}
onChange={(e) => setEditPhotoTitle(e.target.value)}
placeholder="Nhập tiêu đề..."
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1"> tả</label>
<textarea
value={editPhotoDescription}
onChange={(e) => setEditPhotoDescription(e.target.value)}
placeholder="Nhập mô tả..."
rows={2}
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1"> đ</label>
<input
type="number"
step="any"
value={editPhotoLat}
onChange={(e) => setEditPhotoLat(e.target.value === '' ? '' : parseFloat(e.target.value))}
placeholder="Vĩ độ..."
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1">Kinh đ</label>
<input
type="number"
step="any"
value={editPhotoLng}
onChange={(e) => setEditPhotoLng(e.target.value === '' ? '' : parseFloat(e.target.value))}
placeholder="Kinh độ..."
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
</div>
<div className="flex justify-start">
<button
type="button"
onClick={() => setIsMapOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 border border-gray-200 text-gray-700 hover:text-gray-900 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={() => setIsEditingPhoto(false)}
disabled={isSavingPhotoEdit}
className="px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg text-xs font-bold transition-all"
>
Hủy
</button>
<button
onClick={handleSavePhotoEdit}
disabled={isSavingPhotoEdit}
className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition-all"
>
{isSavingPhotoEdit ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Đang lưu...
</>
) : (
'Lưu lại'
)}
</button>
</div>
</div>
</div>
)}
</div>
) : (
<div className="text-center text-gray-400">
@@ -2220,7 +2544,7 @@ export const TourDetailPage = ({
{/* Nút X để quay lại */}
<button
onClick={() => setIsMapFullscreen(false)}
className="absolute top-4 left-4 z-[1002] w-11 h-11 bg-white/90 backdrop-blur-md rounded-full shadow-2xl flex items-center justify-center border border-white/20 hover:bg-white transition-all active:scale-95 group"
className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] left-4 z-[1002] w-11 h-11 bg-white/90 backdrop-blur-md rounded-full shadow-2xl flex items-center justify-center border border-white/20 hover:bg-white transition-all active:scale-95 group"
title="Đóng bản đồ"
>
<X className="w-6 h-6 text-gray-800 group-hover:rotate-90 transition-transform duration-300" />
@@ -2228,7 +2552,7 @@ export const TourDetailPage = ({
{/* Transparent Top Bar Label - Glassmorphism style */}
{selectedRouteInfo && drivingRoute.length > 0 && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[1001] bg-white/20 backdrop-blur-lg px-6 py-2.5 rounded-full border border-white/30 flex items-center gap-4 animate-in fade-in slide-in-from-top-2 duration-500 shadow-xl">
<div className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] left-1/2 -translate-x-1/2 z-[1001] bg-white/20 backdrop-blur-lg px-6 py-2.5 rounded-full border border-white/30 flex items-center gap-4 animate-in fade-in slide-in-from-top-2 duration-500 shadow-xl">
<div className="flex items-center gap-2">
<Navigation className="w-4 h-4 text-blue-600 rotate-45 fill-blue-600" />
<span className="text-[11px] font-black text-gray-700 uppercase tracking-widest">{selectedRouteInfo.label}</span>
@@ -2246,8 +2570,9 @@ export const TourDetailPage = ({
zoom={mapZoom}
className="h-full w-full"
preferCanvas={true}
attributionControl={false}
>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="" />
<MapTourBounds locations={allLocations} />
<MapRotationHandler rotation={mapRotation} />
<RecenterUser position={userLocation} trigger={locateTrigger} />
@@ -2284,7 +2609,7 @@ export const TourDetailPage = ({
</MapContainer>
{/* Overlay điều khiển trên bản đồ toàn màn hình */}
<div className="absolute top-4 right-4 z-[1001] flex flex-col gap-2">
<div className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] right-4 z-[1001] flex flex-col gap-2">
<button
onClick={() => setIsMapControlsOpen(!isMapControlsOpen)}
className="w-11 h-11 bg-white/90 backdrop-blur-md rounded-2xl shadow-xl border border-white text-blue-600 flex items-center justify-center transition-all active:scale-95"
@@ -2420,6 +2745,35 @@ export const TourDetailPage = ({
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
onCommentDeleted={() => handleCommentDecrement(commentLocationId)}
/>
<CoordinateSelectModal
isOpen={isMapOpen}
onClose={() => setIsMapOpen(false)}
initialLat={typeof editPhotoLat === 'number' ? editPhotoLat : undefined}
initialLng={typeof editPhotoLng === 'number' ? editPhotoLng : undefined}
onSelect={(lat, lng) => {
setEditPhotoLat(lat);
setEditPhotoLng(lng);
}}
/>
{isFullscreen && selectedPhotoForDisplay && (
<div
className="fixed inset-0 z-[9999] bg-black/95 flex items-center justify-center cursor-zoom-out animate-in fade-in duration-200"
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={selectedPhotoForDisplay.imageUrl}
alt="Fullscreen photo"
className="max-w-full max-h-full object-contain select-none animate-in zoom-in-95 duration-200"
/>
</div>
)}
</div>
);
};
+58
View File
@@ -38,6 +38,8 @@
"cache-manager": "^7.2.8",
"cache-manager-redis-yet": "^5.1.5",
"dotenv": "^17.4.2",
"exifr": "^7.1.3",
"heic-convert": "^2.1.0",
"nodemailer": "^9.0.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
@@ -4877,6 +4879,12 @@
"node": ">=0.8.x"
}
},
"node_modules/exifr": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
"integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
"license": "MIT"
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
@@ -5412,6 +5420,32 @@
"node": ">= 0.4"
}
},
"node_modules/heic-convert": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/heic-convert/-/heic-convert-2.1.0.tgz",
"integrity": "sha512-1qDuRvEHifTVAj3pFIgkqGgJIr0M3X7cxEPjEp0oG4mo8GFjq99DpCo8Eg3kg17Cy0MTjxpFdoBHOatj7ZVKtg==",
"license": "ISC",
"dependencies": {
"heic-decode": "^2.0.0",
"jpeg-js": "^0.4.4",
"pngjs": "^6.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/heic-decode": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz",
"integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==",
"license": "ISC",
"dependencies": {
"libheif-js": "^1.19.8"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/hookified": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
@@ -5663,6 +5697,12 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jpeg-js": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
"license": "BSD-3-Clause"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5832,6 +5872,15 @@
"leaflet": "^1.3.1"
}
},
"node_modules/libheif-js": {
"version": "1.19.8",
"resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.19.8.tgz",
"integrity": "sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ==",
"license": "LGPL-3.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -6928,6 +6977,15 @@
"node": ">=4"
}
},
"node_modules/pngjs": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
"license": "MIT",
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",