feat: tải ảnh lên bằng tài khoản public và cho phép bình luận
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 888 KiB |
|
After Width: | Height: | Size: 611 KiB |
|
After Width: | Height: | Size: 756 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 225 KiB |
@@ -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>
|
||||
@@ -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 có bình luận nào. Hãy bắt đầu cuộc trò chuyện!</span>
|
||||
</div>
|
||||
) : (
|
||||
comments.map((c) => {
|
||||
return (
|
||||
<div key={c.id} className="flex gap-3 items-start animate-in fade-in slide-in-from-bottom-2 duration-200">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center flex-shrink-0 border border-slate-700/60">
|
||||
<User className="w-4.5 h-4.5 text-slate-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="bg-slate-800/55 p-3.5 rounded-2xl rounded-tl-none border border-slate-800 shadow-lg">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-xs font-black text-slate-200 truncate">{c.userName}</span>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -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 lý 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 có 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 lý thành viên và 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 có ả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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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,16 +54,25 @@ 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();
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||