fix: sửa lỗi các nút bị che dưới thanh địa chỉ của chrome, hỗ trợ iphone upload ảnh heic

This commit is contained in:
2026-06-20 07:05:25 +07:00
parent 36e8658dad
commit def0e0d0f9
19 changed files with 116 additions and 37 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
{ {
"continue.enableConsole": true "continue.enableConsole": true,
"remote.autoForwardPortsFallback": 0
} }
+31 -7
View File
@@ -1695,7 +1695,7 @@ let UserController = class UserController {
this.prisma = prisma; this.prisma = prisma;
} }
async getAllUsers(req, q) { async getAllUsers(req, q) {
const currentUserId = req.user?.sub; const currentUserId = req.user?.id;
const users = await this.prisma.user.findMany({ const users = await this.prisma.user.findMany({
where: q where: q
? { ? {
@@ -1718,18 +1718,32 @@ let UserController = class UserController {
orderBy: { capturedAt: 'desc' } orderBy: { capturedAt: 'desc' }
}); });
} }
async updateUser(id, data) { async updateUser(id, data, req) {
const requestingUser = req.user;
const targetUser = await this.prisma.user.findUnique({ where: { id } });
if (!targetUser) {
throw new common_1.NotFoundException('Không tìm thấy người dùng');
}
if (targetUser.isAdmin && !requestingUser.isAdmin) {
throw new common_1.ForbiddenException('Không có quyền thay đổi thông tin hoặc reset password của Quản trị viên');
}
if (!requestingUser.isAdmin && requestingUser.id !== id) {
throw new common_1.ForbiddenException('Bạn không có quyền thực hiện hành động này');
}
if (data.password) { if (data.password) {
data.passwordHash = await bcrypt.hash(data.password, 10); data.passwordHash = await bcrypt.hash(data.password, 10);
delete data.password; delete data.password;
} }
if (!requestingUser.isAdmin && data.isAdmin !== undefined) {
delete data.isAdmin;
}
return this.prisma.user.update({ return this.prisma.user.update({
where: { id }, where: { id },
data, data,
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true } select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true }
}); });
} }
async deleteUser(id) { async deleteUser(id, req) {
const user = await this.prisma.user.findUnique({ where: { id } }); const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) if (!user)
throw new common_1.NotFoundException('Không tìm thấy người dùng'); throw new common_1.NotFoundException('Không tìm thấy người dùng');
@@ -1757,10 +1771,13 @@ let UserController = class UserController {
} }
return { message: 'Đã xóa người dùng' }; return { message: 'Đã xóa người dùng' };
} }
async toggleBlock(id) { async toggleBlock(id, req) {
const user = await this.prisma.user.findUnique({ where: { id } }); const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) if (!user)
throw new common_1.NotFoundException('Người dùng không tồn tại'); throw new common_1.NotFoundException('Người dùng không tồn tại');
if (user.isAdmin) {
throw new common_1.BadRequestException('Không thể khóa tài khoản Quản trị viên');
}
const updated = await this.prisma.user.update({ const updated = await this.prisma.user.update({
where: { id }, where: { id },
data: { isBlocked: !user.isBlocked }, data: { isBlocked: !user.isBlocked },
@@ -1771,6 +1788,7 @@ let UserController = class UserController {
}; };
__decorate([ __decorate([
(0, common_1.Get)(), (0, common_1.Get)(),
(0, common_1.UseGuards)(admin_guard_1.AdminGuard),
__param(0, (0, common_1.Req)()), __param(0, (0, common_1.Req)()),
__param(1, (0, common_1.Query)('q')), __param(1, (0, common_1.Query)('q')),
__metadata("design:type", Function), __metadata("design:type", Function),
@@ -1790,27 +1808,33 @@ __decorate([
(0, common_1.Patch)(':id'), (0, common_1.Patch)(':id'),
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Body)()), __param(1, (0, common_1.Body)()),
__param(2, (0, common_1.Req)()),
__metadata("design:type", Function), __metadata("design:type", Function),
__metadata("design:paramtypes", [String, Object]), __metadata("design:paramtypes", [String, Object, Object]),
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], UserController.prototype, "updateUser", null); ], UserController.prototype, "updateUser", null);
__decorate([ __decorate([
(0, exports.Roles)(client_1.ParticipantRole.OWNER), (0, exports.Roles)(client_1.ParticipantRole.OWNER),
(0, common_1.Delete)(':id'), (0, common_1.Delete)(':id'),
(0, common_1.UseGuards)(admin_guard_1.AdminGuard),
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Req)()),
__metadata("design:type", Function), __metadata("design:type", Function),
__metadata("design:paramtypes", [String]), __metadata("design:paramtypes", [String, Object]),
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], UserController.prototype, "deleteUser", null); ], UserController.prototype, "deleteUser", null);
__decorate([ __decorate([
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER), (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
(0, common_1.Post)('block/:id'), (0, common_1.Post)('block/:id'),
(0, common_1.UseGuards)(admin_guard_1.AdminGuard),
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Req)()),
__metadata("design:type", Function), __metadata("design:type", Function),
__metadata("design:paramtypes", [String]), __metadata("design:paramtypes", [String, Object]),
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], UserController.prototype, "toggleBlock", null); ], UserController.prototype, "toggleBlock", null);
UserController = __decorate([ UserController = __decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
(0, common_1.Controller)('users'), (0, common_1.Controller)('users'),
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER), (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
__metadata("design:paramtypes", [prisma_service_1.PrismaService]) __metadata("design:paramtypes", [prisma_service_1.PrismaService])
+1 -1
View File
File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
import * as dotenv from 'dotenv';
import * as path from 'path';
const envPath = path.resolve(process.cwd(), '..', '.env');
dotenv.config({ path: envPath });
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
async function main() {
const users = await prisma.user.findMany();
console.log('USERS:', users);
}
main().finally(() => pool.end());
+39 -4
View File
@@ -1640,14 +1640,16 @@ class PhotoController {
} }
} }
@UseGuards(JwtAuthGuard)
@Controller('users') @Controller('users')
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for User management (Admin/Manager) @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for User management (Admin/Manager)
class UserController { class UserController {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
@Get() @Get()
@UseGuards(AdminGuard)
async getAllUsers(@Req() req: any, @Query('q') q?: string) { async getAllUsers(@Req() req: any, @Query('q') q?: string) {
const currentUserId = req.user?.sub; const currentUserId = req.user?.id;
const users = await this.prisma.user.findMany({ const users = await this.prisma.user.findMany({
where: q where: q
? { ? {
@@ -1677,11 +1679,36 @@ class UserController {
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for updating user (Admin/Manager) @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for updating user (Admin/Manager)
@Patch(':id') @Patch(':id')
async updateUser(@Param('id', ParseUUIDPipe) id: string, @Body() data: any) { async updateUser(@Param('id', ParseUUIDPipe) id: string, @Body() data: any, @Req() req: any) {
const requestingUser = req.user;
// Fetch target user from DB
const targetUser = await this.prisma.user.findUnique({ where: { id } });
if (!targetUser) {
throw new NotFoundException('Không tìm thấy người dùng');
}
// 1. If target user is an Admin, only an Admin can update them.
// (A manager/normal user cannot reset/change password of an Admin)
if (targetUser.isAdmin && !requestingUser.isAdmin) {
throw new ForbiddenException('Không có quyền thay đổi thông tin hoặc reset password của Quản trị viên');
}
// 2. A non-admin can only update their own profile.
if (!requestingUser.isAdmin && requestingUser.id !== id) {
throw new ForbiddenException('Bạn không có quyền thực hiện hành động này');
}
if (data.password) { if (data.password) {
data.passwordHash = await bcrypt.hash(data.password, 10); data.passwordHash = await bcrypt.hash(data.password, 10);
delete data.password; delete data.password;
} }
// Safety: prevent non-admins from promoting anyone to admin
if (!requestingUser.isAdmin && data.isAdmin !== undefined) {
delete data.isAdmin;
}
return this.prisma.user.update({ return this.prisma.user.update({
where: { id }, where: { id },
data, data,
@@ -1691,7 +1718,8 @@ class UserController {
@Roles(ParticipantRole.OWNER) // Only owner can delete user @Roles(ParticipantRole.OWNER) // Only owner can delete user
@Delete(':id') @Delete(':id')
async deleteUser(@Param('id', ParseUUIDPipe) id: string) { @UseGuards(AdminGuard)
async deleteUser(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
const user = await this.prisma.user.findUnique({ where: { id } }); const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException('Không tìm thấy người dùng'); if (!user) throw new NotFoundException('Không tìm thấy người dùng');
if (user.isAdmin) { if (user.isAdmin) {
@@ -1729,9 +1757,16 @@ class UserController {
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for blocking user (Admin/Manager) @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for blocking user (Admin/Manager)
@Post('block/:id') @Post('block/:id')
async toggleBlock(@Param('id', ParseUUIDPipe) id: string) { @UseGuards(AdminGuard)
async toggleBlock(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
const user = await this.prisma.user.findUnique({ where: { id } }); const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException('Người dùng không tồn tại'); if (!user) throw new NotFoundException('Người dùng không tồn tại');
// Safety check: Cannot block an Admin
if (user.isAdmin) {
throw new BadRequestException('Không thể khóa tài khoản Quản trị viên');
}
const updated = await this.prisma.user.update({ const updated = await this.prisma.user.update({
where: { id }, where: { id },
data: { isBlocked: !user.isBlocked }, data: { isBlocked: !user.isBlocked },
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 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
+3 -3
View File
@@ -2,11 +2,11 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>Travel Planner</title> <title>Travel Planner</title>
<script type="module" crossorigin src="/assets/index-D1uuLlpI.js"></script> <script type="module" crossorigin src="/assets/index-Dn1e9vte.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-f6lAQjsT.css"> <link rel="stylesheet" crossorigin href="/assets/index-C4TR57aU.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>Travel Planner</title> <title>Travel Planner</title>
</head> </head>
+2 -2
View File
@@ -251,9 +251,9 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
}, []); }, []);
return ( return (
<div className="h-screen w-full relative"> <div className="h-dvh w-full relative overflow-hidden">
{/* Top Bar Container - Chứa tất cả các nút điều hướng phía trên */} {/* Top Bar Container - Chứa tất cả các nút điều hướng phía trên */}
<div className="absolute top-4 left-4 right-4 z-[1002] flex items-center justify-between pointer-events-none"> <div className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] left-4 right-4 z-[1002] flex items-center justify-between pointer-events-none">
{/* Nhóm bên trái: Quay lại và Thông tin vị trí */} {/* Nhóm bên trái: Quay lại và Thông tin vị trí */}
<div className="flex items-center gap-3 pointer-events-auto"> <div className="flex items-center gap-3 pointer-events-auto">
<button <button
+4 -4
View File
@@ -195,7 +195,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
return ( return (
<div className="h-screen w-full overflow-hidden font-sans bg-gray-900 relative"> <div className="h-dvh w-full overflow-hidden font-sans bg-gray-900 relative">
{/* Background Image - Hiển thị trên mọi thiết bị với hiệu ứng Crossfade & Panning */} {/* 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"> <div className="absolute inset-0 z-0 bg-gray-950 overflow-hidden">
{/* Slot 1 */} {/* Slot 1 */}
@@ -242,7 +242,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
`}</style> `}</style>
{/* Top Bar - Thanh điều hướng trên cùng */} {/* 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"> <div className="absolute top-0 left-0 right-0 z-20 p-4 pt-[calc(1rem+env(safe-area-inset-top,0px))] flex justify-between items-center">
<div className="flex items-center gap-2 text-white drop-shadow-lg"> <div className="flex items-center gap-2 text-white drop-shadow-lg">
<Compass className="w-8 h-8" /> <Compass className="w-8 h-8" />
<span className="text-xl font-black tracking-tighter uppercase hidden sm:block">Travel Planner</span> <span className="text-xl font-black tracking-tighter uppercase hidden sm:block">Travel Planner</span>
@@ -274,7 +274,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
{/* Community Gallery Previews */} {/* Community Gallery Previews */}
{publicPhotos.length > 0 && ( {publicPhotos.length > 0 && (
<div className="absolute bottom-28 left-0 right-0 z-20 px-4 flex flex-col items-center gap-2"> <div className="absolute bottom-[calc(7rem+env(safe-area-inset-bottom,0px))] left-0 right-0 z-20 px-4 flex flex-col items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-white/70 drop-shadow-md"> <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}) Khoảnh khắc từ cộng đng ({publicPhotos.length})
</span> </span>
@@ -305,7 +305,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
)} )}
{/* Bottom Bar - Nút hành động chính */} {/* 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"> <div className="absolute bottom-[calc(2rem+env(safe-area-inset-bottom,0px))] left-1/2 -translate-x-1/2 z-20 w-full px-4">
<button <button
onClick={onGoToMap} onClick={onGoToMap}
className="w-full max-w-md mx-auto flex items-center justify-center gap-3 bg-white/20 backdrop-blur-lg text-white font-bold py-4 px-8 rounded-2xl transition-all shadow-2xl border border-white/30 hover:bg-white/30 active:scale-95" className="w-full max-w-md mx-auto flex items-center justify-center gap-3 bg-white/20 backdrop-blur-lg text-white font-bold py-4 px-8 rounded-2xl transition-all shadow-2xl border border-white/30 hover:bg-white/30 active:scale-95"
+3 -3
View File
@@ -2221,7 +2221,7 @@ export const TourDetailPage = ({
{/* Nút X để quay lại */} {/* Nút X để quay lại */}
<button <button
onClick={() => setIsMapFullscreen(false)} onClick={() => setIsMapFullscreen(false)}
className="absolute top-4 left-4 z-[1002] w-11 h-11 bg-white/90 backdrop-blur-md rounded-full shadow-2xl flex items-center justify-center border border-white/20 hover:bg-white transition-all active:scale-95 group" className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] left-4 z-[1002] w-11 h-11 bg-white/90 backdrop-blur-md rounded-full shadow-2xl flex items-center justify-center border border-white/20 hover:bg-white transition-all active:scale-95 group"
title="Đóng bản đồ" title="Đóng bản đồ"
> >
<X className="w-6 h-6 text-gray-800 group-hover:rotate-90 transition-transform duration-300" /> <X className="w-6 h-6 text-gray-800 group-hover:rotate-90 transition-transform duration-300" />
@@ -2229,7 +2229,7 @@ export const TourDetailPage = ({
{/* Transparent Top Bar Label - Glassmorphism style */} {/* Transparent Top Bar Label - Glassmorphism style */}
{selectedRouteInfo && drivingRoute.length > 0 && ( {selectedRouteInfo && drivingRoute.length > 0 && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[1001] bg-white/20 backdrop-blur-lg px-6 py-2.5 rounded-full border border-white/30 flex items-center gap-4 animate-in fade-in slide-in-from-top-2 duration-500 shadow-xl"> <div className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] left-1/2 -translate-x-1/2 z-[1001] bg-white/20 backdrop-blur-lg px-6 py-2.5 rounded-full border border-white/30 flex items-center gap-4 animate-in fade-in slide-in-from-top-2 duration-500 shadow-xl">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Navigation className="w-4 h-4 text-blue-600 rotate-45 fill-blue-600" /> <Navigation className="w-4 h-4 text-blue-600 rotate-45 fill-blue-600" />
<span className="text-[11px] font-black text-gray-700 uppercase tracking-widest">{selectedRouteInfo.label}</span> <span className="text-[11px] font-black text-gray-700 uppercase tracking-widest">{selectedRouteInfo.label}</span>
@@ -2286,7 +2286,7 @@ export const TourDetailPage = ({
</MapContainer> </MapContainer>
{/* Overlay điều khiển trên bản đồ toàn màn hình */} {/* Overlay điều khiển trên bản đồ toàn màn hình */}
<div className="absolute top-4 right-4 z-[1001] flex flex-col gap-2"> <div className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] right-4 z-[1001] flex flex-col gap-2">
<button <button
onClick={() => setIsMapControlsOpen(!isMapControlsOpen)} onClick={() => setIsMapControlsOpen(!isMapControlsOpen)}
className="w-11 h-11 bg-white/90 backdrop-blur-md rounded-2xl shadow-xl border border-white text-blue-600 flex items-center justify-center transition-all active:scale-95" className="w-11 h-11 bg-white/90 backdrop-blur-md rounded-2xl shadow-xl border border-white text-blue-600 flex items-center justify-center transition-all active:scale-95"