feat: tải ảnh lên bằng tài khoản public và cho phép bình luận

This commit is contained in:
2026-06-19 22:38:16 +07:00
parent 4640526e4b
commit 6c1b77d7d2
29 changed files with 1752 additions and 165 deletions
+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;
}
+282 -17
View File
@@ -56,6 +56,8 @@ require("reflect-metadata");
const zlib = __importStar(require("zlib"));
const util_1 = require("util");
const sharp_1 = __importDefault(require("sharp"));
const exifr_1 = __importDefault(require("exifr"));
const heic_convert_1 = __importDefault(require("heic-convert"));
const core_1 = require("@nestjs/core");
const common_1 = require("@nestjs/common");
const platform_express_1 = require("@nestjs/platform-express");
@@ -147,8 +149,11 @@ let TourRoleGuard = class TourRoleGuard {
}
else {
const photo = await this.prisma.photo.findUnique({ where: { id: resourceId }, select: { tourId: true } });
if (photo)
if (photo) {
if (!photo.tourId)
return true;
tourId = photo.tourId;
}
}
}
}
@@ -320,6 +325,23 @@ let AuthController = class AuthController {
},
};
}
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,
},
};
}
async getStatus() {
const userCount = await this.prisma.user.count();
console.log(`[Status Check] Users found: ${userCount}`);
@@ -363,7 +385,7 @@ let AuthController = class AuthController {
}
}
async signupVerify(body) {
const { email, otp } = body;
const { email, otp, guestId } = body;
const storedOtp = await this.cacheManager.get(`signup_otp:${email}`);
if (!storedOtp || storedOtp !== otp) {
throw new common_1.BadRequestException('Mã OTP không chính xác hoặc đã hết hạn.');
@@ -373,13 +395,41 @@ let AuthController = class AuthController {
throw new common_1.BadRequestException('Thông tin đăng ký đã hết hạn. Vui lòng thực hiện lại từ đầu.');
}
const { password, name, phone, address } = JSON.parse(cachedDataStr);
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({
data: { email, passwordHash, name, phone, address, isAdmin: shouldBeAdmin },
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
});
let user;
if (guestId) {
const guestUser = await this.prisma.user.findUnique({
where: { id: guestId }
});
if (guestUser && guestUser.isAnonymous) {
const existingUser = await this.prisma.user.findFirst({
where: { email, isAnonymous: false }
});
if (existingUser) {
throw new common_1.BadRequestException('Email này đã được đăng ký bởi tài khoản khác.');
}
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;
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 }
});
}
await Promise.all([
this.cacheManager.del(`signup_otp:${email}`),
this.cacheManager.del(`signup_data:${email}`)
@@ -394,6 +444,12 @@ __decorate([
__metadata("design:paramtypes", [Object]),
__metadata("design:returntype", Promise)
], AuthController.prototype, "convertGuestToOfficial", null);
__decorate([
(0, common_1.Post)('create-guest'),
__metadata("design:type", Function),
__metadata("design:paramtypes", []),
__metadata("design:returntype", Promise)
], AuthController.prototype, "createGuestUser", null);
__decorate([
(0, common_1.Get)('status'),
__metadata("design:type", Function),
@@ -998,12 +1054,28 @@ let TourController = class TourController {
fs.mkdirSync(tourDisplayPath, { recursive: true });
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 originalFilePath = path.join(memberOriginalDir, filename);
const displayFilePath = path.join(tourDisplayPath, filename);
const originalExtension = path.extname(file.originalname).toLowerCase() || '.jpg';
const originalFilename = `${uniqueSuffix}${originalExtension}`;
const displayFilename = `${uniqueSuffix}.jpg`;
const originalFilePath = path.join(memberOriginalDir, originalFilename);
const displayFilePath = path.join(tourDisplayPath, displayFilename);
await fs.promises.writeFile(originalFilePath, file.buffer);
await (0, sharp_1.default)(file.buffer)
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 (0, heic_convert_1.default)({
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);
}
}
await (0, sharp_1.default)(processBuffer)
.resize(2560, 2560, {
fit: 'inside',
withoutEnlargement: true
@@ -1014,8 +1086,8 @@ let TourController = class TourController {
data: {
tourId: tourId,
uploaderId: uploaderId,
imageUrl: `/uploads/tours/${filename}`,
originalUrl: `/uploads/members/${uploaderId}/originals/${filename}`,
imageUrl: `/uploads/tours/${displayFilename}`,
originalUrl: `/uploads/members/${uploaderId}/originals/${originalFilename}`,
privacy: 'TOUR_ONLY',
}
});
@@ -1488,6 +1560,86 @@ let PhotoController = class PhotoController {
constructor(prisma) {
this.prisma = prisma;
}
async uploadAnonymousPhoto(files, req) {
if (!files || files.length === 0) {
throw new common_1.BadRequestException('Vui lòng chọn một ảnh.');
}
const uploaderId = req.user.id;
const isAnonymous = req.user.isAnonymous;
if (!isAnonymous) {
throw new common_1.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);
if (!fs.existsSync(memberOriginalDir)) {
fs.mkdirSync(memberOriginalDir, { recursive: true });
}
await fs.promises.writeFile(originalFilePath, file.buffer);
let lat;
let lng;
try {
const gps = await exifr_1.default.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);
}
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}`);
}
}
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}`);
}
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 (0, heic_convert_1.default)({
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);
}
}
await (0, sharp_1.default)(processBuffer)
.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
}
},
});
}
async deletePhoto(id, req) {
const photo = await this.prisma.photo.findUnique({
where: { id },
@@ -1495,7 +1647,7 @@ let PhotoController = class PhotoController {
if (!photo) {
throw new common_1.NotFoundException('Không tìm thấy ảnh.');
}
if (photo.uploaderId !== req.user.id) {
if (photo.uploaderId !== req.user.id && !req.user.isAdmin) {
throw new common_1.ForbiddenException('Bạn không có quyền xóa ảnh này.');
}
if (photo.imageUrl) {
@@ -1514,6 +1666,16 @@ let PhotoController = class PhotoController {
return { message: 'Ảnh đã được xóa thành công.' };
}
};
__decorate([
(0, common_1.Post)('upload-anonymous'),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
(0, common_1.UseInterceptors)((0, platform_express_1.FilesInterceptor)('images', 1)),
__param(0, (0, common_1.UploadedFiles)()),
__param(1, (0, common_1.Req)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [Array, Object]),
__metadata("design:returntype", Promise)
], PhotoController.prototype, "uploadAnonymousPhoto", null);
__decorate([
(0, common_1.Delete)(':id'),
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
@@ -1661,9 +1823,16 @@ let CommentGateway = class CommentGateway {
client.join(`tour_${tourId}`);
console.log(`[WS] Client ${client.id} joined room: tour_${tourId}`);
}
handleJoinPhoto(client, photoId) {
client.join(`photo_${photoId}`);
console.log(`[WS] Client ${client.id} joined room: photo_${photoId}`);
}
notifyNewComment(tourId, data) {
this.server.to(`tour_${tourId}`).emit('commentAdded', data);
}
notifyNewPhotoComment(photoId, data) {
this.server.to(`photo_${photoId}`).emit('photoCommentAdded', data);
}
};
exports.CommentGateway = CommentGateway;
__decorate([
@@ -1676,6 +1845,12 @@ __decorate([
__metadata("design:paramtypes", [socket_io_1.Socket, String]),
__metadata("design:returntype", void 0)
], CommentGateway.prototype, "handleJoinTour", null);
__decorate([
(0, websockets_1.SubscribeMessage)('joinPhoto'),
__metadata("design:type", Function),
__metadata("design:paramtypes", [socket_io_1.Socket, String]),
__metadata("design:returntype", void 0)
], CommentGateway.prototype, "handleJoinPhoto", null);
exports.CommentGateway = CommentGateway = __decorate([
(0, websockets_1.WebSocketGateway)({ cors: { origin: '*' } })
], CommentGateway);
@@ -1842,6 +2017,96 @@ AdminOtpController = __decorate([
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
EmailService, Object])
], AdminOtpController);
let PublicPhotoController = class PublicPhotoController {
constructor(prisma, commentGateway) {
this.prisma = prisma;
this.commentGateway = commentGateway;
}
async getPublicPhotos() {
return this.prisma.photo.findMany({
where: { privacy: 'PUBLIC' },
select: {
id: true,
imageUrl: true,
originalUrl: true,
capturedAt: true,
metadata: true,
uploader: {
select: {
id: true,
name: true,
}
}
},
orderBy: { capturedAt: 'desc' }
});
}
async getPhotoComments(photoId) {
return this.prisma.comment.findMany({
where: { photoId },
include: {
user: {
select: { id: true, name: true }
}
},
orderBy: { createdAt: 'asc' }
});
}
async addPhotoComment(photoId, body, req) {
const { content } = body;
if (!content || content.trim() === '') {
throw new common_1.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 common_1.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 }
}
}
});
this.commentGateway.notifyNewPhotoComment(photoId, comment);
return comment;
}
};
__decorate([
(0, common_1.Get)(),
__metadata("design:type", Function),
__metadata("design:paramtypes", []),
__metadata("design:returntype", Promise)
], PublicPhotoController.prototype, "getPublicPhotos", null);
__decorate([
(0, common_1.Get)(':photoId/comments'),
__param(0, (0, common_1.Param)('photoId', common_1.ParseUUIDPipe)),
__metadata("design:type", Function),
__metadata("design:paramtypes", [String]),
__metadata("design:returntype", Promise)
], PublicPhotoController.prototype, "getPhotoComments", null);
__decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
(0, common_1.Post)(':photoId/comments'),
__param(0, (0, common_1.Param)('photoId', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Body)()),
__param(2, (0, common_1.Req)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [String, Object, Object]),
__metadata("design:returntype", Promise)
], PublicPhotoController.prototype, "addPhotoComment", null);
PublicPhotoController = __decorate([
(0, common_1.Controller)('public-photos'),
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
CommentGateway])
], PublicPhotoController);
let AppModule = class AppModule {
};
AppModule = __decorate([
@@ -1866,7 +2131,7 @@ AppModule = __decorate([
signOptions: { expiresIn: '1d' },
}),
],
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],
providers: [prisma_service_1.PrismaService, jwt_strategy_1.JwtStrategy, TourRoleGuard, jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard, CommentGateway, core_2.Reflector, EmailService, config_1.ConfigService],
exports: [prisma_service_1.PrismaService]
})
+1 -1
View File
File diff suppressed because one or more lines are too long
+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,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;
+5 -2
View File
@@ -193,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)
}
+289 -20
View File
@@ -6,6 +6,8 @@ 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 { NestExpressApplication } from '@nestjs/platform-express';
@@ -128,7 +130,10 @@ export class TourRoleGuard implements CanActivate {
} 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;
if (photo) {
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;
}
}
}
}
@@ -317,6 +322,26 @@ class AuthController {
};
}
@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();
@@ -374,8 +399,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) {
@@ -388,15 +413,48 @@ class AuthController {
}
const { password, name, phone, address } = JSON.parse(cachedDataStr);
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({
data: { email, passwordHash, name, phone, address, isAdmin: shouldBeAdmin },
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
});
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;
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([
@@ -1112,18 +1170,34 @@ class TourController {
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)
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)
.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
@@ -1136,8 +1210,8 @@ class TourController {
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',
}
});
@@ -1426,6 +1500,110 @@ 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)
.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({
@@ -1436,8 +1614,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.');
}
@@ -1577,10 +1755,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')
@@ -1748,6 +1937,86 @@ class AdminOtpController {
}
}
@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,
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;
}
}
@Module({
imports: [
ConfigModule.forRoot({
@@ -1771,7 +2040,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],
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector, EmailService, ConfigService],
exports: [PrismaService]
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 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" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>Travel Planner</title>
<script type="module" crossorigin src="/assets/index-D9CLCU9L.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-S9boMaIB.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+1
View File
@@ -112,6 +112,7 @@ function App() {
user={user}
onViewTour={handleViewTour}
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
onLoginSuccess={handleLoginSuccess}
/>
);
}
@@ -0,0 +1,348 @@
import React, { useState, useEffect, useRef } from 'react';
import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download } from 'lucide-react';
import { io } from 'socket.io-client';
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;
};
uploader?: {
id: string;
name: string;
};
};
photoGroup?: any[];
onSelectPhoto?: (photo: any) => void;
onLoginSuccess?: (user: any) => void;
}
export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
isOpen,
onClose,
photo,
photoGroup = [],
onSelectPhoto,
onLoginSuccess
}) => {
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 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);
}
}
const 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);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[5000] flex items-center justify-center 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="relative w-full max-w-5xl h-[85vh] bg-slate-900 border border-slate-800 rounded-[32px] shadow-2xl overflow-hidden flex flex-col md:flex-row animate-in zoom-in-95 duration-300 text-slate-100">
{/* Close Button Mobile/Desktop */}
<button
onClick={onClose}
className="absolute top-4 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 h-2/5 md:h-full bg-slate-950 flex items-center justify-center overflow-hidden group">
<img
src={photo.imageUrl}
alt="Public Map Upload"
className="w-full h-full object-contain"
/>
{/* Info & Timeline overlay inside photo panel */}
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-slate-950 via-slate-950/90 to-transparent 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>
)}
{/* Photo Metadata */}
<div className="flex flex-wrap items-center gap-4 text-xs text-slate-300">
<span className="flex items-center gap-1.5 font-semibold text-emerald-400">
<User className="w-4 h-4" />
{photo.uploader?.name || 'Ẩn danh'}
</span>
<span className="flex items-center gap-1.5 text-slate-400">
<Calendar className="w-4 h-4" />
{new Date(photo.capturedAt).toLocaleDateString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</span>
{photo.metadata?.lat && photo.metadata?.lng && (
<span className="flex items-center gap-1.5 text-slate-400">
<MapPin className="w-4 h-4 text-rose-500" />
{photo.metadata.lat.toFixed(4)}, {photo.metadata.lng.toFixed(4)}
</span>
)}
{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 h-3/5 md:h-full flex flex-col bg-slate-900 border-t md:border-t-0 md:border-l border-slate-800">
{/* Comments Header */}
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
<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="flex-1 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="p-4 bg-slate-950/40 border-t border-slate-800/80">
<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-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>
</div>
);
};
+173 -68
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,11 @@ interface UserManagementModalProps {
}
export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen, onClose }) => {
const [activeTab, setActiveTab] = useState<'users' | 'photos'>('users');
const [users, setUsers] = useState<any[]>([]);
const [photos, setPhotos] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [photosLoading, setPhotosLoading] = useState(false);
const [error, setError] = useState('');
const fetchUsers = async () => {
@@ -27,9 +30,29 @@ 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);
}
};
useEffect(() => {
if (isOpen) fetchUsers();
}, [isOpen]);
if (isOpen) {
if (activeTab === 'users') {
fetchUsers();
} else {
fetchPhotos();
}
}
}, [isOpen, activeTab]);
const handleToggleBlock = async (id: string) => {
try {
@@ -60,89 +83,171 @@ 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>
</div>
{/* Content Body */}
<div className="flex-1 overflow-y-auto p-6">
{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>
<tr className="text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100">
<th className="pb-4 font-bold px-2">Người dùng</th>
<th className="pb-4 font-bold">Vai trò</th>
<th className="pb-4 font-bold">Trạng thái</th>
<th className="pb-4 font-bold text-right">Thao tác</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{users.map(u => (
<tr key={u.id} className="group hover:bg-gray-50/50 transition-colors">
<td className="py-4 px-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
{u.name?.charAt(0) || <User className="w-5 h-5" />}
</div>
<div>
<div className="font-bold text-gray-900">{u.name || 'N/A'}</div>
<div className="text-xs text-gray-400">{u.email}</div>
</div>
</div>
</td>
<td className="py-4">
{u.isAdmin ? (
<span className="px-2 py-1 bg-purple-50 text-purple-600 text-[10px] font-black rounded-md border border-purple-100">ADMIN</span>
) : (
<span className="px-2 py-1 bg-gray-50 text-gray-500 text-[10px] font-black rounded-md border border-gray-100">USER</span>
)}
</td>
<td className="py-4">
{u.isBlocked ? (
<span className="flex items-center gap-1 text-red-500 text-xs font-bold"><ShieldAlert className="w-3 h-3" /> Đã khóa</span>
) : (
<span className="text-green-500 text-xs font-bold">Đang hoạt đng</span>
)}
</td>
<td className="py-4 text-right">
<div className="flex justify-end gap-2">
<button
onClick={() => handleToggleBlock(u.id)}
className={`p-2 rounded-xl transition-all ${u.isBlocked ? 'bg-green-50 text-green-600 hover:bg-green-100' : 'bg-amber-50 text-amber-600 hover:bg-amber-100'}`}
title={u.isBlocked ? 'Mở khóa' : 'Khóa tài khoản'}
>
{u.isBlocked ? <Unlock className="w-4 h-4" /> : <Lock className="w-4 h-4" />}
</button>
<button
onClick={() => handleDelete(u.id)}
className="p-2 bg-red-50 text-red-600 rounded-xl hover:bg-red-100 transition-all"
title="Xóa người dùng"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
{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>
) : (
<table className="w-full text-left border-collapse">
<thead>
<tr className="text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100">
<th className="pb-4 font-bold px-2">Người dùng</th>
<th className="pb-4 font-bold">Vai trò</th>
<th className="pb-4 font-bold">Trạng thái</th>
<th className="pb-4 font-bold text-right">Thao tác</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{users.map(u => (
<tr key={u.id} className="group hover:bg-gray-50/50 transition-colors">
<td className="py-4 px-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
{u.name?.charAt(0) || <User className="w-5 h-5" />}
</div>
<div>
<div className="font-bold text-gray-900">{u.name || 'N/A'}</div>
<div className="text-xs text-gray-400">{u.email}</div>
</div>
</div>
</td>
<td className="py-4">
{u.isAdmin ? (
<span className="px-2 py-1 bg-purple-50 text-purple-600 text-[10px] font-black rounded-md border border-purple-100">ADMIN</span>
) : (
<span className="px-2 py-1 bg-gray-50 text-gray-500 text-[10px] font-black rounded-md border border-gray-100">USER</span>
)}
</td>
<td className="py-4">
{u.isBlocked ? (
<span className="flex items-center gap-1 text-red-500 text-xs font-bold"><ShieldAlert className="w-3 h-3" /> Đã khóa</span>
) : (
<span className="text-green-500 text-xs font-bold">Đang hoạt đng</span>
)}
</td>
<td className="py-4 text-right">
<div className="flex justify-end gap-2">
<button
onClick={() => handleToggleBlock(u.id)}
className={`p-2 rounded-xl transition-all ${u.isBlocked ? 'bg-green-50 text-green-600 hover:bg-green-100' : 'bg-amber-50 text-amber-600 hover:bg-amber-100'}`}
title={u.isBlocked ? 'Mở khóa' : 'Khóa tài khoản'}
>
{u.isBlocked ? <Unlock className="w-4 h-4" /> : <Lock className="w-4 h-4" />}
</button>
<button
onClick={() => handleDelete(u.id)}
className="p-2 bg-red-50 text-red-600 rounded-xl hover:bg-red-100 transition-all"
title="Xóa người dùng"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)
) : (
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>
))}
</tbody>
</table>
</div>
)
)}
</div>
</div>
+93 -4
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,37 @@ 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);
}
});
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 +226,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();
}
@@ -449,6 +484,46 @@ 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);
}
}}
icon={L.divIcon({
className: 'custom-photo-bubble',
html: `
<div class="relative group">
<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>
` : ''}
</div>
`,
iconSize: [48, 48],
iconAnchor: [24, 24]
})}
/>
);
})}
</MapContainer>
{/* Context Menu Chia sẻ */}
@@ -498,6 +573,20 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
onViewTour(tour.id);
}}
/>
{selectedPhoto && (
<PublicPhotoModal
isOpen={!!selectedPhoto}
onClose={() => {
setSelectedPhoto(null);
setSelectedPhotoGroup([]);
}}
photo={selectedPhoto}
photoGroup={selectedPhotoGroup}
onSelectPhoto={(photo) => setSelectedPhoto(photo)}
onLoginSuccess={onLoginSuccess}
/>
)}
</div>
);
};
+241 -51
View File
@@ -1,6 +1,7 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { LogIn, Compass, Map as MapIcon, Camera } from 'lucide-react';
import { LoginModal } from '../components/LoginModal';
import { useNotification } from '@/hooks/useNotification';
interface LandingPageProps {
onContinue?: () => void;
@@ -11,63 +12,209 @@ interface LandingPageProps {
}
// Component Animation cho người lữ hành
const TravelerAnimation = () => (
<div className="relative w-64 h-64">
{/* 1. Animation người lữ hành (SVG Silhouette) */}
<svg
viewBox="0 0 100 100"
className="absolute inset-0 w-full h-full"
style={{ animation: 'travelerFadeIn 4s cubic-bezier(0.4, 0, 0.6, 1) infinite' }}
>
{/* Thân người */}
<path d="M 50,30 C 40,30 35,40 35,50 L 35,90 L 65,90 L 65,50 C 65,40 60,30 50,30 Z" fill="rgba(255,255,255,0.1)" />
{/* Máy ảnh */}
<rect x="42" y="45" width="16" height="10" rx="2" fill="rgba(255,255,255,0.5)" />
</svg>
{/* 2. Animation đèn flash (giữ nguyên) */}
<div
className="absolute top-1/2 left-1/2 w-48 h-48 md:w-64 md:h-64 bg-white rounded-full"
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={{
transform: 'translate(-50%, -50%)',
animation: 'flash 4s cubic-bezier(0.4, 0, 0.6, 1) infinite',
animation: 'gentle-shake 5s ease-in-out infinite',
}}
/>
{/* 3. Icon máy ảnh tĩnh để luôn hiển thị */}
<div className="absolute inset-0 flex items-center justify-center opacity-50">
<Camera className="w-20 h-20 md:w-24 md:h-24 text-white/60 drop-shadow-xl" strokeWidth={1.5} />
</div>
{/* 4. Thêm keyframes cho cả hai animation */}
<style>{`
@keyframes travelerFadeIn {
0%, 40%, 100% { opacity: 0; transform: scale(0.8); }
50%, 90% { opacity: 1; transform: scale(1); }
}
@keyframes flash {
0%, 50%, 100% { opacity: 0; transform: translate(-50%, -50%) scale(0.5); }
55% { opacity: 0.6; transform: translate(-50%, -50%) scale(1.2); }
}
`}</style>
</div>
);
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();
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();
setPublicPhotos(data);
}
} 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 để làm tọa độ dự phòng
const location = await new Promise<GeolocationPosition | null>((resolve) => {
if (!navigator.geolocation) {
resolve(null);
} else {
navigator.geolocation.getCurrentPosition(
(pos) => resolve(pos),
() => resolve(null),
{ timeout: 5000, enableHighAccuracy: true }
);
}
});
// 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());
}
const 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 = '';
}
};
// Sử dụng trực tiếp ảnh nền AVIF từ thư mục `public`
const backgroundImage = '/background.avif'; // Giả sử bạn đặt tên file là background.avif
return (
<div className="h-screen w-full overflow-hidden font-sans bg-gray-900 relative">
{/* Background Image - Hiển thị trên mọi thiết bị */}
<img
src={backgroundImage}
className="absolute inset-0 w-full h-full object-cover opacity-60"
alt="Travel Background"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-black/20" />
{/* 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
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"
/>
)}
{/* 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>
{/* 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 flex justify-between items-center">
@@ -85,10 +232,53 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
</div>
{/* Body: Animation người lữ hành */}
<div className="absolute inset-0 flex items-center justify-center z-10 pointer-events-none">
<TravelerAnimation />
<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-28 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-8 left-1/2 -translate-x-1/2 z-20 w-full px-4">
<button
+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) {
+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",