46 Commits

Author SHA1 Message Date
3dtours e34d197dd0 fix: sửa lỗi hiển thị thumbnail khi chia sẻ trên facebook 2026-06-20 14:44:31 +07:00
3dtours fdf41e05fc fix: sửa lỗi hiển thị trên browser của điện thoại 2026-06-20 12:19:30 +07:00
3dtours ae74035ad5 fix: cho phép người đã tải ảnh lên được quyền chỉnh sửa 2026-06-20 08:27:58 +07:00
3dtours ac11bb56db fix: bố cục lại cách hiển thị thông tin ảnh 2026-06-20 08:04:12 +07:00
3dtours 5081546cdf feat: cho phép người dùng chọn tọa độ trên bản đồ khi sửa ảnh 2026-06-20 07:40:50 +07:00
3dtours 9da8a9494d feat: cho phép người dùng chỉnh sửa thông tin ảnh đã upload 2026-06-20 07:37:01 +07:00
3dtours 4373ed684a fix: ảnh phải giữ nguyên hướng chụp 2026-06-20 07:28:28 +07:00
3dtours def0e0d0f9 fix: sửa lỗi các nút bị che dưới thanh địa chỉ của chrome, hỗ trợ iphone upload ảnh heic 2026-06-20 07:05:25 +07:00
3dtours 36e8658dad fix: sửa lỗi iphone ko thể upload 2026-06-19 22:49:51 +07:00
3dtours 6c1b77d7d2 feat: tải ảnh lên bằng tài khoản public và cho phép bình luận 2026-06-19 22:38:16 +07:00
3dtours 4640526e4b fix: thay đổi ảnh nền trang chủ 2026-06-19 12:38:17 +07:00
3dtours c7692ab9b6 fix: thay đổi giao diện trang chủ 2026-06-19 12:00:19 +07:00
3dtours 36fbbb9386 fix: remove leaflet liên kết trên bản đồ 2026-06-19 10:34:03 +07:00
3dtours ef11309399 feat: chuẩn bị backend cho tính năng anonymous user upload ảnh không cần đăng nhập 2026-06-19 08:46:59 +07:00
3dtours 6f2dd0fc01 fix: lỗi đọc nhầm .env ở thư mục backend 2026-06-19 08:13:35 +07:00
3dtours 623f0164c8 feat: cài đặt tính năng gửi mã OTP qua mail xác thực 2026-06-18 23:00:12 +07:00
3dtours 88b47099b1 feat: cài đặt tính năng gửi mã OTP qua mail xác thực 2026-06-18 23:00:03 +07:00
3dtours 659d2a0840 fix: hiển thị market chỉ hướng người dùng di chuyển trên iOS 2026-06-18 21:24:08 +07:00
3dtours 68950dba10 fix: hiển thị market chỉ hướng người dùng di chuyển 2026-06-18 10:51:47 +07:00
3dtours 3e215ae8e8 fix: xoay màn hình theo hướng người nhìn trong màn hình bản đồ đã mượt 2026-06-17 22:41:45 +07:00
3dtours e7b8c672e0 fix: xoay màn hình theo hướng người nhìn trong màn hình bản đồ đã mượt nhưng đến góc là xoay tròn 2026-06-17 22:36:05 +07:00
3dtours 2462850e2e fix: xoay màn hình theo hướng người nhìn trong màn hình bản đồ vẫn còn xoay tròn 2026-06-17 22:23:01 +07:00
3dtours abb61f65cd feat: xoay màn hình theo hướng người nhìn trong màn hình bản đồ vẫn còn lag 2026-06-17 22:20:57 +07:00
3dtours da32d59161 feat: nhấn nút toggle status full bản đồ, nhấn nút bản đồ thì giao diện cũ 2026-06-17 21:53:46 +07:00
3dtours 40b1c4f2ab feat: nhấn nút toggle status bản đồ sẽ hiển thị toàn màn hình 2026-06-17 21:41:43 +07:00
3dtours 55de887f87 feat: nhấn nút toggle status để chuyển đến bản đồ di chuyển 2026-06-17 21:30:41 +07:00
3dtours ffe3cb6ecd fix: tạm dừng thuật toán tìm đường đi 2026-06-17 21:16:31 +07:00
3dtours 69c67a1636 fix: sửa lại các tuyến đường đề xuất trên bản đồ 2026-06-17 20:49:30 +07:00
3dtours f181009fa5 fix: sửa lại các nút hiển thị trên màn hình gom lại thành 1 nút 2026-06-17 20:08:28 +07:00
3dtours b6551da147 fix: sửa lại cho phép chỉnh sửa thời gian bắt đầu và kết thúc của điểm xuất phát và điểm kết thúc 2026-06-17 19:46:55 +07:00
3dtours 2afb2971da fix: sửa lại hiển thị của các nút trên bản đồ 2026-06-17 19:37:29 +07:00
3dtours 14ffbb8657 fix: xóa bảng hiển thị mẹo trên bản đồ 2026-06-17 19:34:13 +07:00
3dtours fbe5ef873b feat: thêm tính năng hiển thị tooltip mẹo nhấn chuột phải 2026-06-17 19:30:58 +07:00
3dtours d6ee1dde74 feat: thêm tính năng nút la bàn để xoay map theo hướng Bắc hoặc theo hướng người dùng di chuyển 2026-06-17 19:19:29 +07:00
3dtours 288b40ad12 feat: thêm tính năng tìm tôi để tự động di chuyển bản đồ về vị trí của người dùng 2026-06-17 19:14:18 +07:00
3dtours 3a3296c340 feat: thêm tính năng hiển thị vị trí người dùng trên bản đồ 2026-06-17 18:27:52 +07:00
3dtours e664a3797e feat: thêm tính năng đường dành cho xe máy và đi bộ 2026-06-17 18:00:45 +07:00
3dtours 36418673db feat: thêm tính năng vẽ đường di chuyển trên bản đồ 2026-06-17 17:44:51 +07:00
3dtours 98e4a4a340 fix: lỗi không xuất hiện nút sửa cho điểm bắt đầu và điểm kết thúc 2026-06-17 17:39:53 +07:00
3dtours bfbbb66747 fix: lỗi bong bóng Tour vẫn xuất hiện và điểm kết thúc 2026-06-17 17:33:47 +07:00
3dtours 1933b58f84 fix: lỗi bong bóng Tour vẫn xuất hiện và điểm bắt đầu là điểm xuất phát 2026-06-17 17:29:40 +07:00
3dtours 05dc80bb6d fix: lỗi bong bóng Tour vẫn hiển thị sau khi xóa 2026-06-17 17:15:11 +07:00
3dtours 4d63bff214 feat: thêm nút xóa tour trong phần cài đặt của Tour 2026-06-17 15:48:00 +07:00
3dtours c5474f1ff6 fix: sửa tính năng hiển thị quãng đường tại điểm đến kế tiếp 2026-06-17 15:37:18 +07:00
3dtours 7ad785fed9 feat: thêm tính năng ghi note cho Tour 2026-06-17 12:28:24 +07:00
3dtours e66f242c2c fix: click vào nút bình luận không load dược page 2026-06-16 21:44:49 +07:00
47 changed files with 7337 additions and 695 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
{ {
"continue.enableConsole": true "continue.enableConsole": true,
"remote.autoForwardPortsFallback": 0
} }
+3 -2
View File
@@ -5,8 +5,8 @@ export declare class JwtStrategy extends JwtStrategy_base {
constructor(prisma: PrismaService); constructor(prisma: PrismaService);
validate(payload: any): Promise<{ validate(payload: any): Promise<{
id: string; id: string;
email: string; email: string | null;
passwordHash: string; passwordHash: string | null;
name: string | null; name: string | null;
phone: string | null; phone: string | null;
address: string | null; address: string | null;
@@ -14,6 +14,7 @@ export declare class JwtStrategy extends JwtStrategy_base {
createdAt: Date; createdAt: Date;
isAdmin: boolean; isAdmin: boolean;
isBlocked: boolean; isBlocked: boolean;
isAnonymous: boolean;
}>; }>;
} }
export {}; export {};
+7
View File
@@ -15,9 +15,16 @@ export declare class TourRoleGuard implements CanActivate {
constructor(reflector: Reflector, prisma: PrismaService, cacheManager: Cache); constructor(reflector: Reflector, prisma: PrismaService, cacheManager: Cache);
canActivate(context: ExecutionContext): Promise<boolean>; canActivate(context: ExecutionContext): Promise<boolean>;
} }
export declare class EmailService {
private transporter;
constructor();
sendOTP(email: string, otp: string): Promise<any>;
}
export declare class CommentGateway implements OnGatewayConnection { export declare class CommentGateway implements OnGatewayConnection {
server: Server; server: Server;
handleConnection(client: Socket): void; handleConnection(client: Socket): void;
handleJoinTour(client: Socket, tourId: string): void; handleJoinTour(client: Socket, tourId: string): void;
handleJoinPhoto(client: Socket, photoId: string): void;
notifyNewComment(tourId: string, data: any): void; notifyNewComment(tourId: string, data: any): void;
notifyNewPhotoComment(photoId: string, data: any): void;
} }
+1140 -57
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long
+80
View File
@@ -0,0 +1,80 @@
> backend@0.0.1 start:dev
> nest start --watch
[10:46:05 PM] Starting compilation in watch mode...
[10:46:08 PM] Found 0 errors. Watching for file changes.
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [NestFactory] Starting Nest application...
--- [PRISMA CHECK] ---
DATABASE_URL nhận được: ĐÃ ĐỌC THÀNH CÔNG ✔️
----------------------
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] ConfigHostModule dependencies initialized +40ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] JwtModule dependencies initialized +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] ConfigModule dependencies initialized +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] CacheModule dependencies initialized +15ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] AppModule dependencies initialized +2ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [WebSocketsController] CommentGateway subscribed to the "joinTour" message +11ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [WebSocketsController] CommentGateway subscribed to the "joinPhoto" message +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] AppController {/api/v1}: +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1, GET} route +2ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] AuthController {/api/v1/auth}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/convert-guest, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/create-guest, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/status, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/login, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/signup/request, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/signup/verify, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] PublicTourController {/api/v1/tours}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id/public, GET} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] TourController {/api/v1/tours}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/locations, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/start-point, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/end-point, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/legs/batch, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/legs, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id, PATCH} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/explore, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/members, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests, GET} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests/:requestId/accept, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests/:requestId/reject, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/members/:userId, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/photos, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] UserController {/api/v1/users}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/me/photos, GET} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/:id, PATCH} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/block/:id, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] RoutingController {/api/v1/routing}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/routing/optimize/:legId, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] LegController {/api/v1/legs}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/legs/:id, PATCH} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/legs/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] LocationController {/api/v1/locations}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:id, PATCH} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:id, DELETE} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] CommentController {/api/v1/locations}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:locationId/comments, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:locationId/comments, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] PhotoController {/api/v1/photos}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/photos/upload-anonymous, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/photos/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] PublicPhotoController {/api/v1/public-photos}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/public-photos, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/public-photos/:photoId/comments, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/public-photos/:photoId/comments, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] AdminOtpController {/api/v1/admin/otp}: +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/admin/otp/send, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/admin/otp/verify, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [NestApplication] Nest application successfully started +16ms
🚀 Server is running on: http://localhost:3001
[EmailService] ✅ Kết nối SMTP thành công. Sẵn sàng gửi mail.
[WS] Client connected: sIV1IoQvXb-xaa9iAAAC
[WS] Client sIV1IoQvXb-xaa9iAAAC joined room: photo_557913e6-fcb4-4dae-8cff-a6ff52da3386
+4
View File
@@ -21,6 +21,7 @@
"dependencies": { "dependencies": {
"@nestjs/cache-manager": "^3.1.3", "@nestjs/cache-manager": "^3.1.3",
"@nestjs/common": "^11.1.27", "@nestjs/common": "^11.1.27",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.27", "@nestjs/core": "^11.1.27",
"@nestjs/jwt": "^11.0.2", "@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
@@ -33,6 +34,9 @@
"cache-manager": "^7.2.8", "cache-manager": "^7.2.8",
"cache-manager-redis-yet": "^5.1.5", "cache-manager-redis-yet": "^5.1.5",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"exifr": "^7.1.3",
"heic-convert": "^2.1.0",
"nodemailer": "^9.0.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.12.0", "pg": "^8.12.0",
@@ -0,0 +1,15 @@
-- DropForeignKey
ALTER TABLE "Photo" DROP CONSTRAINT "Photo_tourId_fkey";
-- AlterTable
ALTER TABLE "Photo" ADD COLUMN "originalUrl" TEXT,
ALTER COLUMN "tourId" DROP NOT NULL,
ALTER COLUMN "imageUrl" DROP NOT NULL;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isAnonymous" BOOLEAN NOT NULL DEFAULT false,
ALTER COLUMN "email" DROP NOT NULL,
ALTER COLUMN "passwordHash" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Comment" ADD COLUMN "photoId" TEXT,
ALTER COLUMN "locationId" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_photoId_fkey" FOREIGN KEY ("photoId") REFERENCES "Photo"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+8 -4
View File
@@ -57,8 +57,8 @@ enum PrivacyLevel {
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
email String @unique email String? @unique
passwordHash String passwordHash String?
name String? name String?
phone String? phone String?
address String? address String?
@@ -66,6 +66,7 @@ model User {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
isAdmin Boolean @default(false) isAdmin Boolean @default(false)
isBlocked Boolean @default(false) isBlocked Boolean @default(false)
isAnonymous Boolean @default(false)
createdTours Tour[] @relation("TourCreator") createdTours Tour[] @relation("TourCreator")
tourParticipations TourParticipant[] tourParticipations TourParticipant[]
@@ -192,14 +193,17 @@ model Photo {
tour Tour? @relation(fields: [tourId], references: [id], onDelete: SetNull) tour Tour? @relation(fields: [tourId], references: [id], onDelete: SetNull)
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull) location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
uploader User @relation(fields: [uploaderId], references: [id]) uploader User @relation(fields: [uploaderId], references: [id])
comments Comment[]
} }
model Comment { model Comment {
id String @id @default(uuid()) id String @id @default(uuid())
content String content String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
locationId String locationId String?
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade) location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
photoId String?
photo Photo? @relation(fields: [photoId], references: [id], onDelete: Cascade)
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
+19
View File
@@ -0,0 +1,19 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
import * as dotenv from 'dotenv';
import * as path from 'path';
const envPath = path.resolve(process.cwd(), '..', '.env');
dotenv.config({ path: envPath });
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
async function main() {
const users = await prisma.user.findMany();
console.log('USERS:', users);
}
main().finally(() => pool.end());
+1179 -55
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

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

After

Width:  |  Height:  |  Size: 254 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>Travel Planner</title>
<script type="module" crossorigin src="/assets/index-CtrQmjY1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-i0kJVU1C.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <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
View File
@@ -16,6 +16,8 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-leaflet-cluster": "^2.1.0", "react-leaflet-cluster": "^2.1.0",
"react-quill": "^2.0.0",
"react-quill-new": "^3.8.3",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"zustand": "^5.0.1" "zustand": "^5.0.1"
}, },
Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

+9 -2
View File
@@ -2,8 +2,9 @@ import React, { useState, useEffect } from 'react';
import { LandingPage } from './pages/LandingPage'; import { LandingPage } from './pages/LandingPage';
import { ExploreMap } from './pages/ExploreMap'; import { ExploreMap } from './pages/ExploreMap';
import { TourDetailPage } from './pages/TourDetailPage'; import { TourDetailPage } from './pages/TourDetailPage';
import { SignupPage } from './pages/SignupPage'; import SignupPage from './pages/SignupPage';
import { MyPhotosPage } from './pages/MyPhotosPage'; import { MyPhotosPage } from './pages/MyPhotosPage';
import { MyNotePage } from './pages/MyNotePage';
import { useTourStore } from './store/useTourStore'; import { useTourStore } from './store/useTourStore';
import { ConfirmProvider } from './hooks/useConfirm'; import { ConfirmProvider } from './hooks/useConfirm';
import { NotificationProvider } from './hooks/useNotification'; import { NotificationProvider } from './hooks/useNotification';
@@ -13,7 +14,7 @@ function App() {
const viewTourId = params.get('viewTour'); const viewTourId = params.get('viewTour');
const [user, setUser] = useState<any>(null); const [user, setUser] = useState<any>(null);
const [currentPage, setCurrentPage] = useState<'landing' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos'>(viewTourId ? 'tourDetail' : 'landing'); const [currentPage, setCurrentPage] = useState<'landing' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes'>(viewTourId ? 'tourDetail' : 'landing');
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId); const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId); const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
@@ -94,10 +95,15 @@ function App() {
tourId={currentTourId!} tourId={currentTourId!}
onBack={handleBackFromTourDetail} onBack={handleBackFromTourDetail}
isPublicView={isPublicTourView} isPublicView={isPublicTourView}
onOpenNotes={() => setCurrentPage('notes')}
/> />
); );
} }
if (currentPage === 'notes') {
return <MyNotePage onBack={() => setCurrentPage('tourDetail')} />;
}
if (currentPage === 'explore') { if (currentPage === 'explore') {
return ( return (
<ExploreMap <ExploreMap
@@ -106,6 +112,7 @@ function App() {
user={user} user={user}
onViewTour={handleViewTour} onViewTour={handleViewTour}
onOpenMyPhotos={() => setCurrentPage('myPhotos')} onOpenMyPhotos={() => setCurrentPage('myPhotos')}
onLoginSuccess={handleLoginSuccess}
/> />
); );
} }
+36 -7
View File
@@ -61,7 +61,7 @@ const MapPicker = ({ onPick, center }: { onPick: (latlng: L.LatLng) => void, cen
); );
}; };
export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation, isPublicView = false }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any, isPublicView?: boolean }) => { export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation, isStartPoint = false, isEndPoint = false, isPublicView = false, onSuccess }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any, isStartPoint?: boolean, isEndPoint?: boolean, isPublicView?: boolean, onSuccess?: () => void }) => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
address: '', address: '',
@@ -85,7 +85,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
const searchTimeout = useRef<any>(null); const searchTimeout = useRef<any>(null);
// Gom các selector lại để giảm số lượng Hook gọi nội bộ và tăng hiệu năng // Gom các selector lại để giảm số lượng Hook gọi nội bộ và tăng hiệu năng
const { legs, addLocation, updateLocation, mapCenter, currentTour, userRole } = useTourStore(); const { legs, addLocation, updateLocation, updateTourStartPoint, updateTourEndPoint, mapCenter, currentTour, userRole } = useTourStore();
const notify = useNotification(); const notify = useNotification();
// 1. Luôn khai báo Hook ở cấp cao nhất, không đặt code logic/return giữa các Hook // 1. Luôn khai báo Hook ở cấp cao nhất, không đặt code logic/return giữa các Hook
@@ -108,7 +108,9 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
expenseDescription: expense?.description || '', expenseDescription: expense?.description || '',
expenseNote: expense?.note || '', expenseNote: expense?.note || '',
paidById: expense?.paidById || '', paidById: expense?.paidById || '',
plannedStart: editingLocation.plannedStart ? editingLocation.plannedStart.slice(0, 16) : '', plannedStart: isStartPoint
? (editingLocation.plannedEnd ? editingLocation.plannedEnd.slice(0, 16) : '')
: (editingLocation.plannedStart ? editingLocation.plannedStart.slice(0, 16) : ''),
plannedEnd: editingLocation.plannedEnd ? editingLocation.plannedEnd.slice(0, 16) : '' plannedEnd: editingLocation.plannedEnd ? editingLocation.plannedEnd.slice(0, 16) : ''
}); });
} else { } else {
@@ -153,8 +155,13 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
// 2. Thực hiện các tính toán và hàm xử lý // 2. Thực hiện các tính toán và hàm xử lý
const currentLegId = formData.legId || (legs.length > 0 ? legs[0].id : ''); const currentLegId = formData.legId || (legs.length > 0 ? legs[0].id : '');
const targetLeg = legs.find(l => l.id === currentLegId); const targetLeg = legs.find(l => l.id === currentLegId);
const titleText = editingLocation ? `Sửa địa điểm: ${editingLocation.name}` : (initialLegId ? `Thêm địa điểm cho ${targetLeg?.note || `Chặng ${targetLeg?.sequence}`}` : 'Thêm địa điểm mới'); const titleText = isStartPoint ? 'Thiết lập Điểm xuất phát' :
const buttonText = editingLocation ? 'Cập nhật thay đổi' : (initialLegId ? 'Xác nhận thêm vào chặng' : 'Thêm địa điểm'); isEndPoint ? 'Thiết lập Điểm kết thúc' :
editingLocation ? `Sửa địa điểm: ${editingLocation.name}` :
(initialLegId ? `Thêm địa điểm cho ${targetLeg?.note || `Chặng ${targetLeg?.sequence}`}` : 'Thêm địa điểm mới');
const buttonText = isStartPoint ? 'Xác nhận Điểm xuất phát' :
isEndPoint ? 'Xác nhận Điểm kết thúc' :
editingLocation ? 'Cập nhật thay đổi' : (initialLegId ? 'Xác nhận thêm vào chặng' : 'Thêm địa điểm');
const handleSearchLocation = (query: string) => { const handleSearchLocation = (query: string) => {
setFormData(prev => ({ ...prev, name: query })); setFormData(prev => ({ ...prev, name: query }));
@@ -281,6 +288,21 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
e.preventDefault(); e.preventDefault();
setIsLoading(true); setIsLoading(true);
try { try {
if (isStartPoint) {
await updateTourStartPoint(tourId, {
name: formData.name || "Điểm xuất phát",
latitude: parseFloat(formData.latitude as any),
longitude: parseFloat(formData.longitude as any),
plannedEnd: formData.plannedStart,
});
} else if (isEndPoint) {
await updateTourEndPoint(tourId, {
name: formData.name || "Điểm kết thúc",
latitude: parseFloat(formData.latitude as any),
longitude: parseFloat(formData.longitude as any),
plannedStart: formData.plannedStart,
});
} else {
const payload: any = { const payload: any = {
...formData, ...formData,
expenseAmount: formData.expenseAmount.replace(/\./g, ''), // Loại bỏ dấu chấm trước khi gửi expenseAmount: formData.expenseAmount.replace(/\./g, ''), // Loại bỏ dấu chấm trước khi gửi
@@ -294,6 +316,13 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
} else { } else {
await addLocation(tourId, payload); await addLocation(tourId, payload);
} }
}
notify({
title: 'Thành công',
message: isStartPoint ? 'Đã thiết lập điểm xuất phát.' : isEndPoint ? 'Đã thiết lập điểm kết thúc.' : editingLocation ? 'Đã cập nhật địa điểm.' : 'Đã thêm địa điểm mới.',
type: 'success'
});
onSuccess?.(); // Gọi callback onSuccess sau khi thành công
onClose(); onClose();
} catch (error) { } catch (error) {
notify({ title: 'Lỗi', message: 'Lỗi khi lưu địa điểm', type: 'error' }); notify({ title: 'Lỗi', message: 'Lỗi khi lưu địa điểm', type: 'error' });
@@ -370,8 +399,8 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
</div> </div>
</div> </div>
<MapContainer center={currentCoords} zoom={13} className="h-full w-full" zoomControl={false}> <MapContainer center={currentCoords} zoom={13} className="h-full w-full" zoomControl={false} attributionControl={false}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" /> <TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="" />
<Marker position={currentCoords} /> <Marker position={currentCoords} />
<MapPicker center={currentCoords} onPick={handlePickLocation} /> <MapPicker center={currentCoords} onPick={handlePickLocation} />
</MapContainer> </MapContainer>
+21
View File
@@ -70,11 +70,32 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
setIsUploading(true); setIsUploading(true);
try { try {
// Lấy tọa độ hiện tại của người dùng làm dự phòng nếu ảnh EXIF không có GPS
const location = await Promise.race([
new Promise<GeolocationPosition | null>((resolve) => {
if (!navigator.geolocation) {
resolve(null);
} else {
navigator.geolocation.getCurrentPosition(
(pos) => resolve(pos),
() => resolve(null),
{ timeout: 4000, enableHighAccuracy: true }
);
}
}),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 4500))
]);
const formData = new FormData(); const formData = new FormData();
selectedFiles.forEach(file => { selectedFiles.forEach(file => {
formData.append('images', file); formData.append('images', file);
}); });
if (location) {
formData.append('latitude', location.coords.latitude.toString());
formData.append('longitude', location.coords.longitude.toString());
}
const response = await fetch(`/api/v1/tours/${tourId}/photos`, { const response = await fetch(`/api/v1/tours/${tourId}/photos`, {
method: 'POST', method: 'POST',
headers: { headers: {
+2 -2
View File
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { X, Send, MessageSquare, User, Loader2, Trash2 } from 'lucide-react'; import { X, Send, MessageSquare, User, Loader2, Trash2 } from 'lucide-react';
import { io } from 'socket.io-client'; import { io } from 'socket.io-client';
import { useTourStore } from '@/store/useTourStore'; import { useTourStore } from '@/store/useTourStore';
import { useConfirm } from '@/hooks/useConfirm'; import { ConfirmModal } from './ConfirmModal';
interface Comment { interface Comment {
id: string; id: string;
@@ -26,7 +26,7 @@ export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, loc
const [comments, setComments] = useState<Comment[]>([]); const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState(''); const [newComment, setNewComment] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const confirm = useConfirm(); const [confirmState, setConfirmState] = useState({ open: false, commentId: '' });
const userRole = useTourStore(state => state.userRole); const userRole = useTourStore(state => state.userRole);
const currentUserId = React.useMemo(() => { const currentUserId = React.useMemo(() => {
@@ -0,0 +1,163 @@
import React, { useState, useEffect } from 'react';
import { X, MapPin } from 'lucide-react';
import { MapContainer, TileLayer, Marker, useMap, useMapEvents } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
// Fix Leaflet default marker icon bug
const DefaultIcon = L.icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
});
interface CoordinateSelectModalProps {
isOpen: boolean;
onClose: () => void;
initialLat?: number;
initialLng?: number;
onSelect: (lat: number, lng: number) => void;
}
function MapClickEvents({ onClick }: { onClick: (lat: number, lng: number) => void }) {
useMapEvents({
click: (e) => {
onClick(e.latlng.lat, e.latlng.lng);
}
});
return null;
}
function MapInvalidator() {
const map = useMap();
useEffect(() => {
const timer = setTimeout(() => {
map.invalidateSize();
}, 250);
return () => clearTimeout(timer);
}, [map]);
return null;
}
function RecenterMap({ position }: { position: [number, number] }) {
const map = useMap();
useEffect(() => {
map.setView(position, map.getZoom());
}, [position, map]);
return null;
}
export const CoordinateSelectModal: React.FC<CoordinateSelectModalProps> = ({
isOpen,
onClose,
initialLat,
initialLng,
onSelect
}) => {
const defaultCenter: [number, number] = [10.7769, 106.7009]; // TP.HCM default
const [position, setPosition] = useState<[number, number]>(defaultCenter);
const [hasSelected, setHasSelected] = useState(false);
useEffect(() => {
if (isOpen) {
if (typeof initialLat === 'number' && typeof initialLng === 'number' && !isNaN(initialLat) && !isNaN(initialLng)) {
setPosition([initialLat, initialLng]);
setHasSelected(true);
} else {
setPosition(defaultCenter);
setHasSelected(false);
}
}
}, [isOpen, initialLat, initialLng]);
if (!isOpen) return null;
const handleMapClick = (lat: number, lng: number) => {
setPosition([lat, lng]);
setHasSelected(true);
};
const handleConfirm = () => {
onSelect(position[0], position[1]);
onClose();
};
return (
<div className="fixed inset-0 z-[6000] flex items-center justify-center p-4 animate-in fade-in duration-200">
{/* Backdrop */}
<div
className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Content */}
<div className="relative w-full max-w-2xl h-[550px] bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col border border-gray-100 animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="p-4 border-b border-gray-100 flex items-center justify-between bg-white shrink-0">
<div className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-blue-500" />
<div className="text-left">
<h3 className="font-extrabold text-sm text-gray-900">Chọn vị trí trên bản đ</h3>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Click lên bản đ đ chọn tọa đ</p>
</div>
</div>
<button
onClick={onClose}
className="p-1.5 hover:bg-gray-100 rounded-full transition-all text-gray-400 hover:text-gray-600"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Map Body */}
<div className="flex-1 bg-gray-50 relative min-h-[300px]" style={{ zIndex: 10 }}>
<MapContainer
center={position}
zoom={13}
style={{ width: '100%', height: '100%', zIndex: 1 }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapClickEvents onClick={handleMapClick} />
<MapInvalidator />
<RecenterMap position={position} />
{hasSelected && (
<Marker position={position} icon={DefaultIcon} />
)}
</MapContainer>
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-100 flex items-center justify-between bg-white shrink-0">
<div className="text-xs text-gray-500">
{hasSelected ? (
<span className="font-semibold text-gray-700">
Tọa đ: {position[0].toFixed(6)}, {position[1].toFixed(6)}
</span>
) : (
<span className="italic text-gray-400">Chưa chọn vị trí</span>
)}
</div>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 border border-gray-200 hover:bg-gray-50 text-gray-700 rounded-xl text-xs font-bold transition-all"
>
Hủy
</button>
<button
onClick={handleConfirm}
disabled={!hasSelected}
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed text-white rounded-xl text-xs font-bold transition-all shadow-md shadow-blue-500/10"
>
Xác nhận
</button>
</div>
</div>
</div>
</div>
);
};
+107 -36
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { format, differenceInMinutes, parseISO } from 'date-fns'; import { format, differenceInMinutes, parseISO } from 'date-fns';
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft, MessageSquare } from 'lucide-react'; import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft, MessageSquare, FileText, Flag } from 'lucide-react';
import { useTourStore } from '@/store/useTourStore'; import { useTourStore } from '@/store/useTourStore';
import { useConfirm } from '@/hooks/useConfirm'; import { useConfirm } from '@/hooks/useConfirm';
import { useNotification } from '@/hooks/useNotification'; import { useNotification } from '@/hooks/useNotification';
@@ -41,8 +41,18 @@ const formatTravelTime = (minutes: number) => {
export const ItineraryTimeline = ({ export const ItineraryTimeline = ({
onAddLocation, onAddLocation,
onEditLocation, onEditLocation,
onQuickNote,
onNavigate,
onSuccess,
isPublicView = false isPublicView = false
}: { onAddLocation?: (legId: string) => void, onEditLocation?: (location: any) => void, isPublicView?: boolean }) => { }: {
onAddLocation?: (legId: string, isStart?: boolean, isEnd?: boolean) => void,
onEditLocation?: (location: any) => void,
onQuickNote?: (name: string) => void,
onNavigate?: (location: any) => void,
onSuccess?: () => void,
isPublicView?: boolean
}) => {
// Tối ưu hóa selectors để chỉ lắng nghe những thay đổi cần thiết // Tối ưu hóa selectors để chỉ lắng nghe những thay đổi cần thiết
const currentTour = useTourStore(state => state.currentTour); const currentTour = useTourStore(state => state.currentTour);
const legs = useTourStore(state => state.legs); const legs = useTourStore(state => state.legs);
@@ -105,9 +115,10 @@ export const ItineraryTimeline = ({
endDate: '' endDate: ''
}); });
const toggleComplete = async (locationId: string) => { const handleStatusClick = (location: any) => {
// Gọi API PATCH /api/v1/locations/:id để cập nhật trạng thái if (onNavigate) {
console.log("Toggle status for location:", locationId); onNavigate(location);
}
}; };
const handleAddLeg = async () => { const handleAddLeg = async () => {
@@ -160,6 +171,7 @@ export const ItineraryTimeline = ({
if (isConfirmed) { if (isConfirmed) {
try { try {
await deleteLeg(legId); await deleteLeg(legId);
onSuccess?.();
} catch (err: any) { } catch (err: any) {
notify({ title: 'Lỗi', message: err.message, type: 'error' }); notify({ title: 'Lỗi', message: err.message, type: 'error' });
} }
@@ -174,12 +186,20 @@ export const ItineraryTimeline = ({
if (isConfirmed) { if (isConfirmed) {
try { try {
await deleteLocation(id); await deleteLocation(id);
onSuccess?.();
} catch (err: any) { } catch (err: any) {
notify({ title: 'Lỗi', message: err.message, type: 'error' }); notify({ title: 'Lỗi', message: err.message, type: 'error' });
} }
} }
}; };
// Tạo mảng phẳng tất cả địa điểm để tính toán quãng đường liên tục giữa các chặng
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
useEffect(() => {
console.log("ItineraryTimeline: Legs updated", legs);
}, [legs]);
return ( return (
<div className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen"> <div className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen">
<div className="px-2 pt-4"> <div className="px-2 pt-4">
@@ -284,24 +304,69 @@ export const ItineraryTimeline = ({
<div className={`absolute left-6 top-16 ${legIdx === legs.length - 1 ? 'bottom-0' : 'bottom-[-3.5rem]'} w-0.5 bg-blue-100 -z-0`} /> <div className={`absolute left-6 top-16 ${legIdx === legs.length - 1 ? 'bottom-0' : 'bottom-[-3.5rem]'} w-0.5 bg-blue-100 -z-0`} />
<div className="ml-2"> <div className="ml-2">
{/* Nút thêm nhanh "Điểm xuất phát" cho Chặng 1 nếu chưa có */}
{legIdx === 0 && !leg.locations.some(loc => loc.plannedStart && new Date(loc.plannedStart).getTime() === 0) && (
<div className="relative flex group mb-6 opacity-80 hover:opacity-100 transition-opacity">
<div className="z-10 mt-1.5 mr-4">
<div className="w-8 h-8 bg-white rounded-full border-2 border-dashed border-blue-200 flex items-center justify-center text-blue-400">
<MapPin className="w-4 h-4" />
</div>
</div>
<button
onClick={() => onAddLocation?.(leg.id, true)}
className="flex-1 bg-blue-50/20 p-4 rounded-xl border border-dashed border-blue-100 hover:border-blue-400 hover:bg-blue-50 transition-all flex items-center justify-between group"
>
<div className="text-left">
<span className="inline-block px-2 py-0.5 bg-blue-50 text-blue-600 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm xuất phát</span>
<h3 className="font-bold text-gray-400 text-sm italic">Nhấn đ ghim điểm bắt đu cho Tour...</h3>
</div>
<Plus className="w-5 h-5 text-blue-500 group-hover:scale-110 transition-transform" />
</button>
</div>
)}
{/* Nút thêm nhanh "Điểm kết thúc" cho Chặng cuối nếu chưa có */}
{legIdx === legs.length - 1 && !legs.some(l => l.locations.some(loc => loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0)) && (
<div className="relative flex group mb-6 opacity-80 hover:opacity-100 transition-opacity">
<div className="z-10 mt-1.5 mr-4">
<div className="w-8 h-8 bg-white rounded-full border-2 border-dashed border-red-200 flex items-center justify-center text-red-400">
<Flag className="w-4 h-4" />
</div>
</div>
<button
onClick={() => onAddLocation?.(leg.id, false, true)}
className="flex-1 bg-red-50/20 p-4 rounded-xl border border-dashed border-red-100 hover:border-red-400 hover:bg-red-50 transition-all flex items-center justify-between group"
>
<div className="text-left">
<span className="inline-block px-2 py-0.5 bg-red-50 text-red-600 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span>
<h3 className="font-bold text-gray-400 text-sm italic">Nhấn đ ghim điểm kết thúc cho Tour...</h3>
</div>
<Plus className="w-5 h-5 text-red-500 group-hover:scale-110 transition-transform" />
</button>
</div>
)}
{leg.locations.map((location, idx) => { {leg.locations.map((location, idx) => {
// Logic quan trọng: Gán điểm cuối chặng này nối với điểm đầu chặng sau // Tìm vị trí của điểm này trong toàn bộ hành trình
const nextLocation = leg.locations[idx + 1] || legs[legIdx + 1]?.locations[0]; const globalIdx = allLocations.findIndex(loc => loc.id === location.id);
const distanceToNext = nextLocation const prevLocation = globalIdx > 0 ? allLocations[globalIdx - 1] : null;
? calculateDistance(location.latitude, location.longitude, nextLocation.latitude, nextLocation.longitude)
: null;
const averageSpeed = 35; // km/h const distanceFromPrev = prevLocation
const travelTimeMinutes = distanceToNext ? Math.round((distanceToNext / averageSpeed) * 60) : null; ? calculateDistance(prevLocation.latitude, prevLocation.longitude, location.latitude, location.longitude)
const dwellMinutes = (location.plannedStart && location.plannedEnd)
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
: null; : null;
const locationExpense = leg.expenses?.find((e: any) => e.locationId === location.id); const locationExpense = leg.expenses?.find((e: any) => e.locationId === location.id);
const isStartPoint = legs[0]?.id === leg.id && idx === 0; // Nhận diện điểm mốc dựa trên timestamp đặc biệt (0) thay vì chỉ số mảng
const isEndPoint = legs[legs.length - 1]?.id === leg.id && idx === leg.locations.length - 1; const isStartPoint = location.plannedStart && new Date(location.plannedStart).getTime() === 0;
const isEndPoint = location.plannedEnd && new Date(location.plannedEnd).getTime() === 0;
const plannedTimeStr = isStartPoint ? location.plannedEnd : location.plannedStart;
const hasValidPlannedTime = plannedTimeStr && new Date(plannedTimeStr).getTime() !== 0;
const dwellMinutes = (location.plannedStart && location.plannedEnd && !isStartPoint && !isEndPoint)
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
: null;
return ( return (
<div key={location.id}> <div key={location.id}>
@@ -309,7 +374,7 @@ export const ItineraryTimeline = ({
{/* Timeline Node */} {/* Timeline Node */}
<div className="z-10 mt-1.5 mr-4"> <div className="z-10 mt-1.5 mr-4">
<button <button
onClick={() => toggleComplete(location.id)} onClick={() => handleStatusClick(location)}
className={`transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`} className={`transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`}
> >
{location.status === 'COMPLETED' ? ( {location.status === 'COMPLETED' ? (
@@ -370,27 +435,38 @@ export const ItineraryTimeline = ({
</div> </div>
<div className="text-right flex flex-col items-end"> <div className="text-right flex flex-col items-end">
<div className="flex gap-1 mb-2">
{onQuickNote && !isPublicView && (
<button
onClick={() => onQuickNote(location.name)}
className="flex items-center gap-1 px-2 py-1 bg-amber-50 hover:bg-amber-100 text-amber-600 rounded-lg text-[10px] font-bold transition-all border border-amber-100"
title="Ghi chú nhanh"
>
<FileText className="w-3 h-3" />
</button>
)}
<button <button
onClick={() => { onClick={() => {
setCommentLocationId(location.id); setCommentLocationId(location.id);
setCommentLocationName(location.name); setCommentLocationName(location.name);
setIsCommentModalOpen(true); setIsCommentModalOpen(true);
}} }}
className="flex items-center gap-1 px-2 py-1 bg-gray-50 hover:bg-blue-50 text-gray-400 hover:text-blue-600 rounded-lg text-[10px] font-bold transition-all border border-gray-100 hover:border-blue-100 mb-2" className="flex items-center gap-1 px-2 py-1 bg-gray-50 hover:bg-blue-50 text-gray-400 hover:text-blue-600 rounded-lg text-[10px] font-bold transition-all border border-gray-100 hover:border-blue-100"
> >
<MessageSquare className="w-3 h-3" /> <MessageSquare className="w-3 h-3" />
BÌNH LUẬN {location._count?.comments > 0 && `(${location._count.comments})`} {location._count?.comments > 0 && `(${location._count.comments})`}
</button> </button>
</div>
<div className="flex items-center text-sm font-black text-blue-600"> <div className="flex items-center text-sm font-black text-blue-600">
<Clock className="w-3 h-3 mr-1" /> <Clock className="w-3 h-3 mr-1" />
{location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'} {hasValidPlannedTime ? format(parseISO(plannedTimeStr), 'HH:mm') : '--:--'}
</div> </div>
{location.status === 'COMPLETED' && location.actualStart && ( {location.status === 'COMPLETED' && location.actualStart && (
<div className="text-[10px] text-gray-400 mt-1 italic"> <div className="text-[10px] text-gray-400 mt-1 italic">
Thực tế: {format(parseISO(location.actualStart), 'HH:mm')} Thực tế: {format(parseISO(location.actualStart), 'HH:mm')}
</div> </div>
)} )}
{canEdit && !isStartPoint && !isEndPoint && ( // Only show edit/delete if canEdit {canEdit && ( // Allow editing and deleting of all locations if user has edit permissions
<div className="flex gap-1 mt-2"> <div className="flex gap-1 mt-2">
<button onClick={() => onEditLocation?.(location)} className="p-1 text-gray-400 hover:text-blue-600 transition-colors"> <button onClick={() => onEditLocation?.(location)} className="p-1 text-gray-400 hover:text-blue-600 transition-colors">
<Edit2 className="w-3.5 h-3.5" /> <Edit2 className="w-3.5 h-3.5" />
@@ -404,22 +480,17 @@ export const ItineraryTimeline = ({
</div> </div>
{/* Logic tính toán độ lệch thời gian */} {/* Logic tính toán độ lệch thời gian */}
<TimeVariance planned={location.plannedStart || ''} actual={location.actualStart || null} /> <TimeVariance planned={hasValidPlannedTime ? plannedTimeStr : ''} actual={location.actualStart || null} />
</div> </div>
</div> </div>
{distanceToNext !== null && travelTimeMinutes !== null && ( {/* Hiển thị quãng đường di chuyển từ điểm trước ĐẾN điểm hiện tại */}
<div className="ml-4 -mt-4 mb-2 flex items-center gap-3"> {distanceFromPrev !== null && distanceFromPrev > 0 && (
<div className="w-8 flex justify-center"> <div className="ml-14 -mt-4 mb-6 flex items-center gap-2 animate-in fade-in slide-in-from-left-2 duration-500">
<Navigation className="w-3 h-3 text-blue-400 rotate-180" /> <div className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-600 rounded-xl border border-blue-100 shadow-sm">
</div> <Navigation className="w-3 h-3 rotate-45" />
<div className="flex items-center gap-2"> <span className="text-[10px] font-black uppercase tracking-tighter">
<span className="text-[10px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100"> +{distanceFromPrev.toFixed(1)} km từ {prevLocation?.name.split(',')[0]}
{distanceToNext.toFixed(2)} km
</span>
<span className="text-[10px] font-bold text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full border border-gray-100 flex items-center gap-1">
<Clock className="w-2.5 h-2.5" />
~ {formatTravelTime(travelTimeMinutes)}
</span> </span>
</div> </div>
</div> </div>
@@ -0,0 +1,734 @@
import React, { useState, useEffect, useRef } from 'react';
import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit, Heart } from 'lucide-react';
import { io } from 'socket.io-client';
import { CoordinateSelectModal } from './CoordinateSelectModal';
interface Comment {
id: string;
userName: string;
content: string;
createdAt: string;
userId: string;
}
interface PublicPhotoModalProps {
isOpen: boolean;
onClose: () => void;
photo: {
id: string;
imageUrl: string;
originalUrl?: string;
capturedAt: string;
metadata?: {
lat?: number;
lng?: number;
title?: string;
description?: string;
};
uploader?: {
id: string;
name: string;
};
uploaderId?: string;
};
photoGroup?: any[];
onSelectPhoto?: (photo: any) => void;
onLoginSuccess?: (user: any) => void;
onUpdatePhoto?: (updatedPhoto: any) => void;
}
export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
isOpen,
onClose,
photo,
photoGroup = [],
onSelectPhoto,
onLoginSuccess,
onUpdatePhoto
}) => {
const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isSending, setIsSending] = useState(false);
const commentsEndRef = useRef<HTMLDivElement>(null);
const [currentUser, setCurrentUser] = useState<any>(null);
const [isEditing, setIsEditing] = useState(false);
const [editTitle, setEditTitle] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editLat, setEditLat] = useState<number | ''>('');
const [editLng, setEditLng] = useState<number | ''>('');
const [isSavingEdit, setIsSavingEdit] = useState(false);
const [isMapOpen, setIsMapOpen] = useState(false);
const [resolvedAddress, setResolvedAddress] = useState<string>('');
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const lat = photo?.metadata?.lat;
const lng = photo?.metadata?.lng;
if (typeof lat === 'number' && typeof lng === 'number') {
setResolvedAddress('Đang xác định địa điểm...');
fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&accept-language=vi`)
.then(res => {
if (!res.ok) throw new Error();
return res.json();
})
.then(data => {
if (data && data.display_name) {
const shortAddress = data.display_name.split(',').slice(0, 3).join(',').trim();
setResolvedAddress(shortAddress || data.display_name);
} else {
setResolvedAddress(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
}
})
.catch(() => {
setResolvedAddress(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
});
} else {
setResolvedAddress('Chưa xác định tọa độ');
}
}, [photo?.id, photo?.metadata?.lat, photo?.metadata?.lng]);
const checkCurrentUser = () => {
const userStr = localStorage.getItem('user') || localStorage.getItem('guest_user');
if (userStr) {
try {
setCurrentUser(JSON.parse(userStr));
} catch (e) {}
} else {
setCurrentUser(null);
}
};
useEffect(() => {
checkCurrentUser();
}, [isOpen]);
useEffect(() => {
if (photo) {
setEditTitle(photo.metadata?.title || '');
setEditDescription(photo.metadata?.description || '');
setEditLat(photo.metadata?.lat ?? '');
setEditLng(photo.metadata?.lng ?? '');
setIsEditing(false); // Reset editing mode when selected photo changes
}
}, [photo]);
const handleSaveEdit = async () => {
if (editLat !== '' && (isNaN(editLat) || editLat < -90 || editLat > 90)) {
alert('Vĩ độ không hợp lệ (-90 đến 90)');
return;
}
if (editLng !== '' && (isNaN(editLng) || editLng < -180 || editLng > 180)) {
alert('Kinh độ không hợp lệ (-180 đến 180)');
return;
}
setIsSavingEdit(true);
try {
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
const res = await fetch(`/api/v1/photos/${photo.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
title: editTitle,
description: editDescription,
latitude: editLat === '' ? undefined : editLat,
longitude: editLng === '' ? undefined : editLng
})
});
if (res.ok) {
const updatedPhoto = await res.json();
setIsEditing(false);
if (onUpdatePhoto) {
onUpdatePhoto(updatedPhoto);
}
} else {
const err = await res.json();
alert(err.message || 'Lỗi khi cập nhật thông tin ảnh.');
}
} catch (error) {
console.error('Lỗi khi cập nhật thông tin ảnh:', error);
alert('Không thể kết nối đến máy chủ.');
} finally {
setIsSavingEdit(false);
}
};
const likedUserIds = photo.metadata && Array.isArray((photo.metadata as any).likedUserIds)
? (photo.metadata as any).likedUserIds
: [];
const isLiked = currentUser && likedUserIds.includes(currentUser.id);
const likeCount = likedUserIds.length;
const handleToggleLike = async () => {
let token = localStorage.getItem('token') || localStorage.getItem('guest_token');
let userObj = currentUser;
if (!token) {
try {
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
if (guestRes.ok) {
const guestData = await guestRes.json();
token = guestData.access_token;
userObj = guestData.user;
localStorage.setItem('token', token!);
localStorage.setItem('user', JSON.stringify(userObj));
if (onLoginSuccess) {
onLoginSuccess(userObj);
}
setCurrentUser(userObj);
} else {
return;
}
} catch (e) {
console.error('Không thể tạo phiên khách:', e);
return;
}
}
try {
const res = await fetch(`/api/v1/photos/${photo.id}/toggle-like`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (res.ok) {
const updatedPhoto = await res.json();
if (onUpdatePhoto) {
onUpdatePhoto(updatedPhoto);
}
}
} catch (error) {
console.error('Lỗi khi thích ảnh:', error);
}
};
const fetchComments = async () => {
setIsLoading(true);
try {
const res = await fetch(`/api/v1/public-photos/${photo.id}/comments`);
if (res.ok) {
const data = await res.json();
setComments(
data.map((c: any) => ({
id: c.id,
userName: c.user?.name || 'Ẩn danh',
content: c.content,
createdAt: c.createdAt,
userId: c.userId
}))
);
}
} catch (error) {
console.error('Lỗi khi tải bình luận:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (!isOpen || !photo.id) return;
fetchComments();
const socket = io();
socket.emit('joinPhoto', photo.id);
socket.on('photoCommentAdded', (newCommentData: any) => {
if (newCommentData.photoId === photo.id) {
setComments(prev => {
if (prev.find(c => c.id === newCommentData.id)) return prev;
return [
...prev,
{
id: newCommentData.id,
userName: newCommentData.user?.name || 'Ẩn danh',
content: newCommentData.content,
createdAt: newCommentData.createdAt,
userId: newCommentData.userId
}
];
});
}
});
return () => {
socket.disconnect();
};
}, [isOpen, photo.id]);
useEffect(() => {
commentsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [comments]);
const handleSend = async () => {
if (!newComment.trim()) return;
setIsSending(true);
try {
let token = localStorage.getItem('token');
let currentUser = JSON.parse(localStorage.getItem('user') || 'null');
// Nếu chưa có token, tự động tạo tài khoản khách
if (!token) {
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
if (!guestRes.ok) throw new Error('Không thể tạo tài khoản khách tự động.');
const guestData = await guestRes.json();
token = guestData.access_token;
currentUser = guestData.user;
localStorage.setItem('guest_token', token!);
localStorage.setItem('guest_user', JSON.stringify(currentUser));
localStorage.setItem('token', token!);
localStorage.setItem('user', JSON.stringify(currentUser));
if (onLoginSuccess) {
onLoginSuccess(currentUser);
}
}
let res = await fetch(`/api/v1/public-photos/${photo.id}/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ content: newComment })
});
if (res.status === 401) {
console.warn('Token invalid or expired. Creating a new guest user and retrying comment...');
localStorage.removeItem('guest_token');
localStorage.removeItem('guest_user');
localStorage.removeItem('token');
localStorage.removeItem('user');
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
if (!guestRes.ok) throw new Error('Không thể tạo lại phiên khách.');
const guestData = await guestRes.json();
token = guestData.access_token;
currentUser = guestData.user;
localStorage.setItem('guest_token', token!);
localStorage.setItem('guest_user', JSON.stringify(currentUser));
localStorage.setItem('token', token!);
localStorage.setItem('user', JSON.stringify(currentUser));
if (onLoginSuccess) {
onLoginSuccess(currentUser);
}
res = await fetch(`/api/v1/public-photos/${photo.id}/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ content: newComment })
});
}
if (res.ok) {
setNewComment('');
fetchComments();
} else {
const err = await res.json();
console.error('Lỗi khi gửi bình luận:', err.message);
}
} catch (error) {
console.error('Lỗi khi gửi bình luận:', error);
} finally {
setIsSending(false);
}
};
const isAuthorized = currentUser?.isAdmin ||
(currentUser && photo.uploader && currentUser.id === photo.uploader.id) ||
(currentUser && photo.uploaderId && currentUser.id === photo.uploaderId);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[5000] md:flex md:items-center md:justify-center md:p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-300"
onClick={onClose}
/>
{/* Container */}
<div className="fixed inset-0 overflow-y-auto flex flex-col bg-slate-900 text-slate-100 md:relative md:inset-auto md:overflow-hidden md:rounded-[32px] md:shadow-2xl md:border md:border-slate-800 md:w-full md:max-w-5xl md:h-[85vh] md:flex-row animate-in zoom-in-95 duration-300">
{/* Close Button Mobile/Desktop */}
<button
onClick={onClose}
className="fixed md:absolute top-[calc(1rem+env(safe-area-inset-top,0px))] right-4 md:top-4 md:right-4 z-50 p-2 bg-slate-950/60 hover:bg-slate-800/80 border border-slate-700/50 rounded-full text-slate-300 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
{/* Left Side: Photo Detail */}
<div className="relative w-full md:w-3/5 md:h-full bg-slate-950 flex flex-col overflow-hidden group shrink-0">
{/* Photo wrapper for mobile view (handles top overlay name and bottom-right like) */}
<div className="relative w-full flex items-center justify-center md:absolute md:inset-0 md:flex md:items-center md:justify-center bg-slate-950">
{/* Mobile Only: Uploader details overlay */}
<div className="absolute top-[calc(0.75rem+env(safe-area-inset-top,0px))] left-4 z-40 md:hidden flex items-center gap-2 bg-slate-950/70 backdrop-blur-md px-2.5 py-1.5 rounded-full border border-slate-700/50">
<div className="w-5 h-5 rounded-full bg-emerald-500/20 flex items-center justify-center">
<User className="w-3 h-3 text-emerald-400" />
</div>
<span className="text-xs text-slate-200 max-w-[120px] truncate">
{photo.uploader?.name || 'Ẩn danh'}
</span>
</div>
{/* Like Button Overlay */}
<button
onClick={handleToggleLike}
className="absolute bottom-4 right-4 z-40 flex items-center gap-1.5 bg-slate-950/70 hover:bg-slate-900/80 border border-slate-700/50 text-slate-200 hover:text-white font-bold py-1.5 px-3 rounded-full transition-all active:scale-95 text-[11px] backdrop-blur-md md:absolute md:top-[calc(1rem+env(safe-area-inset-top,0px))] md:left-4 md:bottom-auto md:right-auto"
title={isLiked ? "Bỏ thích" : "Thích"}
>
<Heart className={`w-4 h-4 transition-colors ${
isLiked ? 'text-rose-500 fill-rose-500 animate-in zoom-in-75' : 'text-slate-350 hover:text-rose-450'
}`} />
<span>{likeCount}</span>
</button>
<a
href={`${window.location.origin}/api/v1/public-photos/${photo.id}/share`}
className="w-full h-auto max-h-[85vh] object-contain cursor-zoom-in md:h-full md:max-h-none flex items-center justify-center"
onClick={(e) => {
e.preventDefault();
setIsFullscreen(true);
}}
>
<img
src={photo.imageUrl}
alt="Public Map Upload"
className="w-full h-auto max-h-[85vh] object-contain md:h-full md:max-h-none"
/>
</a>
</div>
{/* Info & Timeline overlay inside photo panel */}
<div className="relative p-6 bg-slate-900 border-b border-slate-800/60 md:absolute md:bottom-0 md:left-0 md:right-0 md:bg-gradient-to-t md:from-black/60 md:via-black/20 md:to-transparent md:border-b-0 flex flex-col gap-4">
{/* Timeline scroll */}
{photoGroup && photoGroup.length > 1 && (
<div className="flex flex-col gap-2 border-b border-slate-800/80 pb-3">
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-400 flex items-center gap-1.5">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500 animate-ping"></span>
Lịch sử nh tại vị trí này ({photoGroup.length})
</span>
<div className="flex gap-3 overflow-x-auto no-scrollbar py-1">
{photoGroup.map((p) => {
const isActive = p.id === photo.id;
return (
<button
key={p.id}
onClick={() => onSelectPhoto?.(p)}
className={`relative w-12 h-12 rounded-xl overflow-hidden border-2 transition-all active:scale-95 shrink-0 ${
isActive ? 'border-emerald-500 scale-110 shadow-lg' : 'border-slate-700 hover:border-slate-500'
}`}
>
<img src={p.imageUrl} alt="Timeline thumbnail" className="w-full h-full object-cover" />
<div className="absolute bottom-0 inset-x-0 bg-slate-950/70 text-[8px] text-center font-bold text-slate-300 py-0.5">
{new Date(p.capturedAt).toLocaleDateString('vi-VN', { month: '2-digit', day: '2-digit' })}
</div>
</button>
);
})}
</div>
</div>
)}
{isEditing ? (
<div className="flex flex-col gap-3 bg-slate-900/95 border border-slate-800 p-4 rounded-2xl animate-in slide-in-from-bottom-2">
<h4 className="text-xs font-black uppercase tracking-wider text-emerald-400">Chỉnh sửa thông tin nh</h4>
<div className="space-y-2">
<div>
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Tiêu đ</label>
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
placeholder="Nhập tiêu đề cho ảnh..."
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
</div>
<div>
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1"> tả</label>
<textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Mô tả bức ảnh này..."
rows={2}
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500 resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1"> đ</label>
<input
type="number"
step="any"
value={editLat}
onChange={(e) => setEditLat(e.target.value === '' ? '' : parseFloat(e.target.value))}
placeholder="Vĩ độ..."
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
</div>
<div>
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Kinh đ</label>
<input
type="number"
step="any"
value={editLng}
onChange={(e) => setEditLng(e.target.value === '' ? '' : parseFloat(e.target.value))}
placeholder="Kinh độ..."
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
</div>
</div>
<div className="flex justify-start">
<button
type="button"
onClick={() => setIsMapOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-750 border border-slate-700/50 text-slate-300 hover:text-white rounded-xl text-[10px] font-bold transition-all"
>
<MapPin className="w-3.5 h-3.5 text-rose-500" />
Chọn trên bản đ
</button>
</div>
</div>
<div className="flex justify-end gap-2 mt-2">
<button
onClick={() => setIsEditing(false)}
disabled={isSavingEdit}
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-bold transition-all"
>
Hủy
</button>
<button
onClick={handleSaveEdit}
disabled={isSavingEdit}
className="flex items-center gap-1 px-4 py-1.5 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition-all"
>
{isSavingEdit ? (
<>
<Loader2 className="w-3 h-3 animate-spin" />
Đang lưu...
</>
) : (
'Lưu lại'
)}
</button>
</div>
</div>
) : (
<>
{/* Title & Description display */}
<div className="flex justify-between items-start gap-4">
<div className="flex-1 min-w-0">
{photo.metadata?.title ? (
<h4 className="text-sm font-black text-white tracking-tight leading-snug break-words">
{photo.metadata.title}
</h4>
) : (
<span className="text-[10px] text-slate-500 italic block mb-1">Chưa tiêu đ</span>
)}
{photo.metadata?.description ? (
<p className="text-xs text-slate-300 leading-relaxed mt-1 max-h-20 overflow-y-auto no-scrollbar break-words">
{photo.metadata.description}
</p>
) : (
<span className="text-[10px] text-slate-500 italic block mt-1">Chưa tả</span>
)}
</div>
{isAuthorized && (
<button
onClick={() => setIsEditing(true)}
className="p-1.5 bg-slate-800 hover:bg-slate-700 border border-slate-700/50 rounded-xl text-slate-300 hover:text-white transition-all shrink-0"
title="Chỉnh sửa thông tin"
>
<Edit className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Photo Metadata */}
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-slate-800/80 pt-3 text-xs text-slate-350">
<div className="space-y-1 text-left">
<div className="flex items-center gap-1.5 text-slate-400">
<Calendar className="w-3.5 h-3.5" />
Ngày chụp: {new Date(photo.capturedAt).toLocaleDateString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</div>
<div className="flex items-center gap-1.5 text-slate-400" title={photo.metadata?.lat && photo.metadata?.lng ? `${photo.metadata.lat.toFixed(6)}, ${photo.metadata.lng.toFixed(6)}` : ''}>
<MapPin className="w-3.5 h-3.5 text-rose-500" />
Đa điểm: {resolvedAddress}
</div>
<div className="hidden md:flex items-center gap-1.5 text-xs text-emerald-400">
<User className="w-3.5 h-3.5" />
Người đăng: {photo.uploader?.name || 'Ẩn danh'}
</div>
</div>
{isAuthorized && photo.originalUrl && (
<a
href={photo.originalUrl}
download
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 bg-slate-800/80 hover:bg-slate-700/80 border border-slate-700/50 text-slate-200 hover:text-white font-bold py-1 px-3 rounded-full transition-all active:scale-95 text-[10px]"
>
<Download className="w-3.5 h-3.5 text-emerald-500" />
Tải nh gốc
</a>
)}
</div>
</>
)}
<style>{`
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
`}</style>
</div>
</div>
{/* Right Side: Comments */}
<div className="w-full md:w-2/5 md:flex-1 md:min-h-0 flex flex-col bg-slate-900 border-t md:border-t-0 md:border-l border-slate-800">
{/* Comments Header */}
<div className="hidden md:block p-6 border-b border-slate-800">
<div>
<h3 className="text-lg font-black tracking-tight text-white flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-emerald-500" />
Bình luận cộng đng
</h3>
<p className="text-xs text-slate-400 mt-1">nh chia sẻ công khai trên bản đ</p>
</div>
</div>
{/* Comments list scroll area */}
<div className="md:flex-1 md:overflow-y-auto p-6 space-y-4 bg-slate-900/50">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-12 text-slate-500 gap-2">
<Loader2 className="w-8 h-8 animate-spin text-emerald-500" />
<span className="text-xs font-semibold">Đang tải bình luận...</span>
</div>
) : comments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-500 gap-3">
<div className="p-4 bg-slate-800/40 rounded-full text-slate-600">
<MessageSquare className="w-8 h-8" />
</div>
<span className="text-sm font-semibold italic">Chưa bình luận nào. Hãy bắt đu cuộc trò chuyện!</span>
</div>
) : (
comments.map((c) => {
return (
<div key={c.id} className="flex gap-3 items-start animate-in fade-in slide-in-from-bottom-2 duration-200">
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center flex-shrink-0 border border-slate-700/60">
<User className="w-4.5 h-4.5 text-slate-400" />
</div>
<div className="flex-1 min-w-0">
<div className="bg-slate-800/55 p-3.5 rounded-2xl rounded-tl-none border border-slate-800 shadow-lg">
<div className="flex justify-between items-center mb-1">
<span className="text-xs font-black text-slate-200 truncate">{c.userName}</span>
<span className="text-[9px] font-medium text-slate-500">
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<p className="text-sm text-slate-300 leading-relaxed break-words">{c.content}</p>
</div>
</div>
</div>
);
})
)}
<div ref={commentsEndRef} />
</div>
{/* Comment Input Area */}
<div className="sticky bottom-0 md:static p-4 bg-slate-950 md:bg-slate-950/40 border-t border-slate-800/80 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] md:pb-4 z-40">
<div className="relative flex items-center gap-2">
<input
type="text"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !isSending && handleSend()}
placeholder="Viết bình luận công khai..."
className="flex-1 bg-slate-800/65 border border-slate-700/70 text-slate-100 placeholder-slate-500 rounded-2xl px-4 py-3 text-base md:text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
disabled={isSending}
/>
<button
onClick={handleSend}
disabled={!newComment.trim() || isSending}
className="p-3 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-40 text-white rounded-2xl transition-all active:scale-95 shadow-lg shadow-emerald-950/30 flex items-center justify-center"
>
{isSending ? (
<Loader2 className="w-4.5 h-4.5 animate-spin" />
) : (
<Send className="w-4.5 h-4.5" />
)}
</button>
</div>
</div>
</div>
</div>
<CoordinateSelectModal
isOpen={isMapOpen}
onClose={() => setIsMapOpen(false)}
initialLat={typeof editLat === 'number' ? editLat : undefined}
initialLng={typeof editLng === 'number' ? editLng : undefined}
onSelect={(lat, lng) => {
setEditLat(lat);
setEditLng(lng);
}}
/>
{isFullscreen && (
<div
className="fixed inset-0 z-[9999] bg-black/95 flex items-center justify-center cursor-zoom-out animate-in fade-in duration-200"
onClick={() => setIsFullscreen(false)}
>
<button
onClick={() => setIsFullscreen(false)}
className="fixed top-6 right-6 p-3 bg-black/50 hover:bg-black/75 border border-white/10 rounded-full text-white transition-colors z-[10000]"
>
<X className="w-6 h-6" />
</button>
<img
src={photo.imageUrl}
alt="Fullscreen photo"
className="max-w-full max-h-full object-contain select-none animate-in zoom-in-95 duration-200"
/>
</div>
)}
</div>
);
};
+274 -9
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import 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 { interface UserManagementModalProps {
isOpen: boolean; isOpen: boolean;
@@ -7,8 +7,14 @@ interface UserManagementModalProps {
} }
export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen, onClose }) => { export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen, onClose }) => {
const [activeTab, setActiveTab] = useState<'users' | 'photos' | 'trash'>('users');
const [users, setUsers] = useState<any[]>([]); const [users, setUsers] = useState<any[]>([]);
const [photos, setPhotos] = useState<any[]>([]);
const [trashPhotos, setTrashPhotos] = useState<any[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [photosLoading, setPhotosLoading] = useState(false);
const [trashLoading, setTrashLoading] = useState(false);
const [cleaning, setCleaning] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const fetchUsers = async () => { const fetchUsers = async () => {
@@ -27,9 +33,89 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
} }
}; };
const fetchPhotos = async () => {
setPhotosLoading(true);
try {
const response = await fetch(`/api/v1/public-photos`);
if (!response.ok) throw new Error('Không thể tải danh sách ảnh công cộng');
const data = await response.json();
setPhotos(data);
} catch (err: any) {
setError(err.message);
} finally {
setPhotosLoading(false);
}
};
const fetchTrashPhotos = async () => {
setTrashLoading(true);
try {
const response = await fetch(`/api/v1/admin/trash-photos`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!response.ok) throw new Error('Không thể tải danh sách ảnh rác');
const data = await response.json();
setTrashPhotos(data);
} catch (err: any) {
setError(err.message);
} finally {
setTrashLoading(false);
}
};
const handleCleanTrash = async () => {
if (!confirm('Bạn có chắc muốn dọn sạch tất cả ảnh rác? Thao tác này sẽ xóa vĩnh viễn các tệp vật lý và dọn dẹp các bản ghi mồ côi.')) return;
setCleaning(true);
try {
const response = await fetch(`/api/v1/admin/trash-photos/clean`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Dọn dẹp thất bại');
alert(data.message);
fetchTrashPhotos();
} catch (err: any) {
alert(err.message);
} finally {
setCleaning(false);
}
};
const handleDeleteSingleTrash = async (item: any) => {
if (!confirm('Xóa mục này vĩnh viễn?')) return;
try {
const response = await fetch(`/api/v1/admin/trash-photos/delete-single`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: item.type,
filePath: item.filePath,
id: item.id
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Xóa mục thất bại');
fetchTrashPhotos();
} catch (err: any) {
alert(err.message);
}
};
useEffect(() => { useEffect(() => {
if (isOpen) fetchUsers(); if (isOpen) {
}, [isOpen]); if (activeTab === 'users') {
fetchUsers();
} else if (activeTab === 'photos') {
fetchPhotos();
} else if (activeTab === 'trash') {
fetchTrashPhotos();
}
}
}, [isOpen, activeTab]);
const handleToggleBlock = async (id: string) => { const handleToggleBlock = async (id: string) => {
try { try {
@@ -60,29 +146,83 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
} }
}; };
const handleDeletePhoto = async (id: string) => {
if (!confirm('Bạn có chắc chắn muốn xóa bức ảnh công khai này?')) return;
try {
const res = await fetch(`/api/v1/photos/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message);
}
fetchPhotos();
} catch (err: any) {
alert(err.message);
}
};
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4"> <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="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 className="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<div> <div>
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2"> <h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Shield className="w-6 h-6 text-blue-600" /> Quản người dùng <Shield className="w-6 h-6 text-blue-600" /> Hệ thống quản trị
</h2> </h2>
<p className="text-sm text-gray-500">Quản trị viên quyền thêm, sửa, xóa hoặc khóa tài khoản.</p> <p className="text-sm text-gray-500">Quản thành viên các tài nguyên công cộng của ng dụng.</p>
</div> </div>
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors"> <button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
<X className="w-6 h-6 text-gray-400" /> <X className="w-6 h-6 text-gray-400" />
</button> </button>
</div> </div>
{/* Tab Selection */}
<div className="flex border-b border-gray-100 bg-gray-50/20 px-6">
<button
onClick={() => setActiveTab('users')}
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 ${
activeTab === 'users' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
}`}
>
<User className="w-4 h-4" />
Thành viên
</button>
<button
onClick={() => setActiveTab('photos')}
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 ${
activeTab === 'photos' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
}`}
>
<ImageIcon className="w-4 h-4" />
nh công cộng
</button>
<button
onClick={() => setActiveTab('trash')}
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 ${
activeTab === 'trash' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
}`}
>
<Trash2 className="w-4 h-4" />
nh rác
</button>
</div>
{/* Content Body */}
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
{loading ? ( {error && (
<div className="p-4 bg-red-50 text-red-600 rounded-xl font-bold mb-4">{error}</div>
)}
{activeTab === 'users' ? (
loading ? (
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div> <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"> <table className="w-full text-left border-collapse">
<thead> <thead>
@@ -143,6 +283,131 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
))} ))}
</tbody> </tbody>
</table> </table>
)
) : activeTab === 'photos' ? (
photosLoading ? (
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
) : photos.length === 0 ? (
<div className="text-center py-20 text-gray-400 italic">Chưa nh công cộng nào đưc tải lên.</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6">
{photos.map(p => (
<div key={p.id} className="relative group bg-gray-50 border border-gray-100 rounded-2xl overflow-hidden shadow-sm hover:shadow-md transition-all flex flex-col justify-between">
<div className="aspect-square bg-slate-900 overflow-hidden flex items-center justify-center relative">
<img src={p.imageUrl} alt="Public content" className="w-full h-full object-cover transition-transform group-hover:scale-105" />
{/* Delete button shown on hover/focus */}
<button
onClick={() => handleDeletePhoto(p.id)}
className="absolute top-2 right-2 p-2 bg-red-600 hover:bg-red-500 text-white rounded-xl shadow-lg transition-all active:scale-95 opacity-0 group-hover:opacity-100 focus:opacity-100 z-10"
title="Xóa ảnh công cộng"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="p-3 bg-white">
<div className="font-bold text-xs text-gray-800 truncate" title={p.uploader?.name || 'Ẩn danh'}>
Đăng bởi: {p.uploader?.name || 'Ẩn danh'}
</div>
<div className="text-[10px] text-gray-400 mt-1">
{new Date(p.capturedAt).toLocaleDateString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})}
</div>
</div>
</div>
))}
</div>
)
) : (
trashLoading ? (
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
) : (
<div className="space-y-4">
<div className="flex justify-between items-center bg-gray-50 p-4 rounded-2xl border border-gray-100">
<div>
<span className="text-sm font-bold text-gray-800">
Tổng số lượng: <span className="text-red-500 font-extrabold text-base">{trashPhotos.length}</span> mục nh rác
</span>
<span className="mx-2 text-gray-300">|</span>
<span className="text-sm font-bold text-gray-800">
Dung lượng ưc tính: <span className="text-blue-600 font-extrabold text-base">
{(trashPhotos.reduce((acc, curr) => acc + (curr.size || 0), 0) / (1024 * 1024)).toFixed(2)} MB
</span>
</span>
</div>
{trashPhotos.length > 0 && (
<button
onClick={handleCleanTrash}
disabled={cleaning}
className="px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white rounded-xl font-bold text-xs uppercase tracking-wider flex items-center gap-2 shadow-md transition-all active:scale-95 disabled:opacity-50"
>
{cleaning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Dọn sạch nh rác
</button>
)}
</div>
{trashPhotos.length === 0 ? (
<div className="text-center py-20 text-gray-400 italic">Không nh rác nào. Hệ thống của bạn sạch sẽ!</div>
) : (
<div className="border border-gray-100 rounded-2xl overflow-hidden shadow-sm">
<table className="w-full text-left border-collapse bg-white">
<thead>
<tr className="text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100 bg-gray-50/50">
<th className="py-3 px-4 font-bold">Hình nh</th>
<th className="py-3 px-4 font-bold">Thông tin</th>
<th className="py-3 px-4 font-bold"> do</th>
<th className="py-3 px-4 font-bold">Kích thước</th>
<th className="py-3 px-4 font-bold text-right">Thao tác</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{trashPhotos.map((item, index) => (
<tr key={index} className="group hover:bg-gray-50/50 transition-colors">
<td className="py-3 px-4">
{item.url ? (
<div className="w-12 h-12 rounded-lg bg-gray-900 overflow-hidden shadow-sm border border-gray-100 flex items-center justify-center">
<img src={item.url} alt="Trash preview" className="w-full h-full object-cover" onError={(e) => { (e.target as HTMLElement).style.display = 'none'; }} />
</div>
) : (
<div className="w-12 h-12 rounded-lg bg-gray-100 border border-gray-100 flex items-center justify-center text-gray-400 text-[10px] font-bold">
N/A
</div>
)}
</td>
<td className="py-3 px-4 max-w-[200px] truncate">
<span className="text-xs font-mono text-gray-500 block truncate" title={item.url || item.filePath}>
{item.url || item.filePath}
</span>
<span className="text-[10px] font-black uppercase text-gray-400 tracking-wider">
{item.type === 'file_only' ? 'Tệp tin mồ côi' : 'Bản ghi mồ côi'}
</span>
</td>
<td className="py-3 px-4">
<span className="text-xs text-red-500 font-semibold">{item.reason}</span>
</td>
<td className="py-3 px-4 text-xs font-semibold text-gray-700">
{((item.size || 0) / 1024).toFixed(1)} KB
</td>
<td className="py-3 px-4 text-right">
<button
onClick={() => handleDeleteSingleTrash(item)}
className="p-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-all shadow-sm active:scale-95"
title="Xóa vĩnh viễn"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
)} )}
</div> </div>
</div> </div>
+181 -15
View File
@@ -1,14 +1,15 @@
import React, { useEffect, useState } from 'react'; 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'; import _MarkerClusterGroup from 'react-leaflet-cluster';
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup; const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import { useTourStore } from '@/store/useTourStore'; 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 { UserManagementModal } from '@/components/UserManagementModal';
import { useNotification } from '@/hooks/useNotification'; import { useNotification } from '@/hooks/useNotification';
import { CreateTourModal } from '../components/CreateTourModal'; import { CreateTourModal } from '../components/CreateTourModal';
import { PublicPhotoModal } from '../components/PublicPhotoModal';
// Fix lỗi icon mặc định của Leaflet // Fix lỗi icon mặc định của Leaflet
const DefaultIcon = L.icon({ const DefaultIcon = L.icon({
@@ -56,7 +57,7 @@ function MapTracker() {
return null; 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 // 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 publicTours = useTourStore(state => state.publicTours);
const fetchPublicTours = useTourStore(state => state.fetchPublicTours); const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
@@ -76,6 +77,45 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
const [userPos, setUserPos] = useState<[number, number]>(initialViewState?.center || [10.7769, 106.7009]); const [userPos, setUserPos] = useState<[number, number]>(initialViewState?.center || [10.7769, 106.7009]);
const [mapZoom] = useState(initialViewState?.zoom || 13); const [mapZoom] = useState(initialViewState?.zoom || 13);
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null);
const [selectedPhotoGroup, setSelectedPhotoGroup] = useState<any[]>([]);
const groupedPhotos = React.useMemo(() => {
const groups: { [key: string]: any[] } = {};
publicPhotos.forEach((photo) => {
const lat = photo.metadata?.lat;
const lng = photo.metadata?.lng;
if (typeof lat === 'number' && typeof lng === 'number') {
const key = `${lat.toFixed(5)},${lng.toFixed(5)}`;
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(photo);
}
});
// Sort each group by likes descending
Object.values(groups).forEach((group) => {
group.sort((a, b) => {
const likesA = a.metadata?.likedUserIds?.length || 0;
const likesB = b.metadata?.likedUserIds?.length || 0;
return likesB - likesA;
});
});
return Object.values(groups);
}, [publicPhotos]);
const fetchPublicPhotos = async () => {
try {
const response = await fetch('/api/v1/public-photos');
if (response.ok) {
const data = await response.json();
setPublicPhotos(data);
}
} catch (error) {
console.error('Error fetching public photos:', error);
}
};
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false); const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); // State để điều khiển hiển thị dropdown lọc const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); // State để điều khiển hiển thị dropdown lọc
@@ -194,7 +234,10 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
}, [publicTours, selectedFilterTag]); }, [publicTours, selectedFilterTag]);
useEffect(() => { 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')) { if (user || localStorage.getItem('token')) {
fetchPublicTours(); fetchPublicTours();
} }
@@ -210,20 +253,50 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
() => console.log("Không thể lấy vị trí người dùng") () => console.log("Không thể lấy vị trí người dùng")
); );
} else { } else {
// Cập nhật store để đồng bộ với vị trí khởi tạo từ cache
setMapCenter(initialViewState.center); setMapCenter(initialViewState.center);
} }
}, []); }, []);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const photoId = params.get('photoId');
if (photoId && publicPhotos.length > 0) {
const foundPhoto = publicPhotos.find((p) => p.id === photoId);
if (foundPhoto) {
const lat = foundPhoto.metadata?.lat;
const lng = foundPhoto.metadata?.lng;
if (typeof lat === 'number' && typeof lng === 'number') {
const group = publicPhotos.filter((p) => {
const pLat = p.metadata?.lat;
const pLng = p.metadata?.lng;
return typeof pLat === 'number' && typeof pLng === 'number' &&
Math.abs(pLat - lat) < 0.00001 &&
Math.abs(pLng - lng) < 0.00001;
});
group.sort((a, b) => {
const likesA = a.metadata?.likedUserIds?.length || 0;
const likesB = b.metadata?.likedUserIds?.length || 0;
return likesB - likesA;
});
setSelectedPhoto(foundPhoto);
setSelectedPhotoGroup(group);
} else {
setSelectedPhoto(foundPhoto);
setSelectedPhotoGroup([foundPhoto]);
}
}
}
}, [publicPhotos]);
return ( 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
onClick={onBack} onClick={onBack}
className="bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100" className="w-11 h-11 flex items-center justify-center bg-white rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100 shrink-0"
title="Quay lại" title="Quay lại"
> >
<X className="w-6 h-6 text-gray-800" /> <X className="w-6 h-6 text-gray-800" />
@@ -233,7 +306,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
<div className="relative"> <div className="relative">
<button <button
onClick={() => setIsFilterDropdownOpen(prev => !prev)} onClick={() => setIsFilterDropdownOpen(prev => !prev)}
className="bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100 flex items-center justify-center" className="w-11 h-11 bg-white rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100 flex items-center justify-center shrink-0"
title="Lọc theo loại" title="Lọc theo loại"
> >
<Filter className="w-6 h-6 text-gray-800" /> <Filter className="w-6 h-6 text-gray-800" />
@@ -318,7 +391,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
console.log("Đang mở Ảnh của tôi..."); console.log("Đang mở Ảnh của tôi...");
onOpenMyPhotos(); onOpenMyPhotos();
}} }}
className="bg-white p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-50 text-blue-600 transition-all flex items-center gap-2 font-bold border border-blue-100" className="w-11 h-11 md:w-auto bg-white p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-50 text-blue-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold border border-blue-100 shrink-0"
title="Ảnh của tôi" title="Ảnh của tôi"
> >
<ImageIcon className="w-5 h-5" /> <ImageIcon className="w-5 h-5" />
@@ -330,7 +403,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
{user && ( {user && (
<button <button
onClick={() => setIsCreateModalOpen(true)} onClick={() => setIsCreateModalOpen(true)}
className="bg-green-600 p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center gap-2 font-bold" className="w-11 h-11 md:w-auto bg-green-600 p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0"
title="Tạo Tour mới" title="Tạo Tour mới"
> >
<Navigation className="w-5 h-5" /> <Navigation className="w-5 h-5" />
@@ -342,7 +415,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
{user?.isAdmin && ( {user?.isAdmin && (
<button <button
onClick={() => setIsAdminModalOpen(true)} onClick={() => setIsAdminModalOpen(true)}
className="bg-blue-600 p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center gap-2 font-bold" className="w-11 h-11 md:w-auto bg-blue-600 p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0"
title="Quản lý hệ thống" title="Quản lý hệ thống"
> >
<Settings className="w-5 h-5" /> <Settings className="w-5 h-5" />
@@ -354,7 +427,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
{onLogout && ( {onLogout && (
<button <button
onClick={onLogout} onClick={onLogout}
className="bg-white p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center gap-2 font-bold text-gray-700 border border-gray-100" className="w-11 h-11 md:w-auto bg-white p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold text-gray-700 border border-gray-100 shrink-0"
title="Đăng xuất" title="Đăng xuất"
> >
<LogOut className="w-5 h-5" /> <LogOut className="w-5 h-5" />
@@ -369,10 +442,10 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
zoom={mapZoom} zoom={mapZoom}
className="h-full w-full" className="h-full w-full"
preferCanvas={true} preferCanvas={true}
attributionControl={false}
> >
<TileLayer <TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; OpenStreetMap contributors'
/> />
{/* Theo dõi di chuyển bản đồ */} {/* Theo dõi di chuyển bản đồ */}
@@ -383,7 +456,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */} {/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
<RecenterMap position={userPos} /> <RecenterMap position={userPos} />
<MarkerClusterGroup chunkedLoading> <MarkerClusterGroup key={`cluster-${filteredTours.length}`} chunkedLoading>
{filteredTours.map((tour) => { {filteredTours.map((tour) => {
const startLoc = tour.legs?.[0]?.locations?.[0]; const startLoc = tour.legs?.[0]?.locations?.[0];
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`; const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
@@ -449,6 +522,49 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
); );
})} })}
</MarkerClusterGroup> </MarkerClusterGroup>
{groupedPhotos.map((photoGroup) => {
const latestPhoto = photoGroup[0];
const lat = latestPhoto.metadata?.lat;
const lng = latestPhoto.metadata?.lng;
if (typeof lat !== 'number' || typeof lng !== 'number') return null;
return (
<Marker
key={latestPhoto.id}
position={[lat, lng]}
eventHandlers={{
click: () => {
setSelectedPhoto(latestPhoto);
setSelectedPhotoGroup(photoGroup);
const params = new URLSearchParams(window.location.search);
params.set('photoId', latestPhoto.id);
window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`);
}
}}
icon={L.divIcon({
className: 'custom-photo-bubble',
html: `
<a href="${window.location.origin}/api/v1/public-photos/${latestPhoto.id}/share" onclick="event.preventDefault();" class="relative group block">
<div class="w-12 h-12 rounded-full border-4 border-emerald-500 shadow-lg overflow-hidden transition-transform group-hover:scale-110">
<img src="${latestPhoto.imageUrl}" class="w-full h-full object-cover" />
</div>
<div class="absolute -bottom-1 -right-1 bg-emerald-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
📸
</div>
${photoGroup.length > 1 ? `
<div class="absolute -top-1 -left-1 bg-rose-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[9px] font-black text-white shadow-md animate-bounce">
${photoGroup.length}
</div>
` : ''}
</a>
`,
iconSize: [48, 48],
iconAnchor: [24, 24]
})}
/>
);
})}
</MapContainer> </MapContainer>
{/* Context Menu Chia sẻ */} {/* Context Menu Chia sẻ */}
@@ -479,10 +595,60 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
isOpen={isCreateModalOpen} isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)} onClose={() => setIsCreateModalOpen(false)}
onSuccess={(tour) => { onSuccess={(tour) => {
// Tự động tạo ghi chú mới cho hành trình vừa tạo
const savedNotes = localStorage.getItem('my_journey_notes');
let notes = [];
try {
notes = savedNotes ? JSON.parse(savedNotes) : [];
} catch (e) { notes = []; }
const newTourNote = {
id: Date.now().toString(),
title: `Ghi chú của hành trình: ${tour.title}`,
content: `<p>Bắt đầu lập kế hoạch cho chuyến đi <strong>${tour.title}</strong> của bạn tại đây...</p>`,
createdAt: new Date().toISOString()
};
localStorage.setItem('my_journey_notes', JSON.stringify([newTourNote, ...notes]));
fetchTour(tour.id); fetchTour(tour.id);
onViewTour(tour.id); onViewTour(tour.id);
}} }}
/> />
{selectedPhoto && (
<PublicPhotoModal
isOpen={!!selectedPhoto}
onClose={() => {
setSelectedPhoto(null);
setSelectedPhotoGroup([]);
const params = new URLSearchParams(window.location.search);
if (params.has('photoId')) {
params.delete('photoId');
const newSearch = params.toString();
const newUrl = `${window.location.pathname}${newSearch ? `?${newSearch}` : ''}`;
window.history.replaceState({}, '', newUrl);
}
}}
photo={selectedPhoto}
photoGroup={selectedPhotoGroup}
onSelectPhoto={(photo) => {
setSelectedPhoto(photo);
const params = new URLSearchParams(window.location.search);
params.set('photoId', photo.id);
window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`);
}}
onLoginSuccess={onLoginSuccess}
onUpdatePhoto={(updatedPhoto) => {
setPublicPhotos((prev) =>
prev.map((p) => (p.id === updatedPhoto.id ? updatedPhoto : p))
);
setSelectedPhoto(updatedPhoto);
setSelectedPhotoGroup((prev) =>
prev.map((p) => (p.id === updatedPhoto.id ? updatedPhoto : p))
);
}}
/>
)}
</div> </div>
); );
}; };
+287 -117
View File
@@ -1,15 +1,7 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { LogIn, Compass, ArrowRight, Map as MapIcon, UserPlus, ShieldCheck } from 'lucide-react'; import { LogIn, Compass, Map as MapIcon, Camera } from 'lucide-react';
import { LoginModal } from '../components/LoginModal'; import { LoginModal } from '../components/LoginModal';
import { useNotification } from '@/hooks/useNotification';
const TRAVEL_IMAGES = [
"https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1503220317375-aaad61436b1b?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1513581166391-887a96df91e7?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1530789253388-582c481c54b0?auto=format&fit=crop&q=80"
];
interface LandingPageProps { interface LandingPageProps {
onContinue?: () => void; onContinue?: () => void;
@@ -19,135 +11,313 @@ interface LandingPageProps {
isInitialSetup?: boolean; isInitialSetup?: boolean;
} }
// Component Animation cho người lữ hành
const TravelerAnimation = ({ onClick }: { onClick?: () => void }) => {
return <button
onClick={onClick}
className="relative w-48 h-48 flex items-center justify-center transition-transform active:scale-95 focus:outline-none"
style={{
animation: 'gentle-shake 5s ease-in-out infinite',
}}
aria-label="Khám phá bản đồ"
>
{/* Giảm kích thước icon xuống 75% (w-24 -> w-18, md:w-28 -> md:w-21) */}
<Camera className="w-18 h-18 md:w-21 md:h-21 text-white/80 drop-shadow-2xl" strokeWidth={1.5} />
{/* Keyframes cho animation rung lắc */}
<style>{`
@keyframes gentle-shake {
0%, 100% {
transform: rotate(0deg) scale(1);
}
50% {
transform: rotate(2deg) scale(1.05);
}
}
`}</style>
</button>;
};
export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSignup, onGoToMap, onLoginSuccess, isInitialSetup }) => { export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSignup, onGoToMap, onLoginSuccess, isInitialSetup }) => {
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false); const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const notify = useNotification();
// Chọn ngẫu nhiên một hình ảnh khi người dùng truy cập trang const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
const backgroundImage = useMemo(() => { const [currentBgIndex, setCurrentBgIndex] = useState(0);
return TRAVEL_IMAGES[Math.floor(Math.random() * TRAVEL_IMAGES.length)]; const [bg1, setBg1] = useState('/background.avif');
const [bg2, setBg2] = useState('');
const [fade1, setFade1] = useState(true);
const [fade2, setFade2] = useState(false);
const [activeSlot, setActiveSlot] = useState<1 | 2>(1);
useEffect(() => {
const nextUrl = publicPhotos.length > 0 ? publicPhotos[currentBgIndex]?.imageUrl : '/background.avif';
if (!nextUrl) return;
const currentUrl = activeSlot === 1 ? bg1 : bg2;
if (nextUrl === currentUrl) return;
if (activeSlot === 1) {
setBg2(nextUrl);
setFade2(true);
setFade1(false);
setActiveSlot(2);
} else {
setBg1(nextUrl);
setFade1(true);
setFade2(false);
setActiveSlot(1);
}
}, [currentBgIndex, publicPhotos]);
const fetchPublicPhotos = async () => {
try {
const res = await fetch('/api/v1/public-photos');
if (res.ok) {
const data = await res.json();
const sortedData = data.sort((a: any, b: any) => {
const likesA = a.metadata?.likedUserIds?.length || 0;
const likesB = b.metadata?.likedUserIds?.length || 0;
return likesB - likesA;
});
setPublicPhotos(sortedData);
}
} catch (e) {
console.error('Lỗi khi tải ảnh công khai:', e);
}
};
useEffect(() => {
fetchPublicPhotos();
}, []); }, []);
useEffect(() => {
if (publicPhotos.length <= 1) return;
const interval = setInterval(() => {
setCurrentBgIndex((prev) => (prev + 1) % publicPhotos.length);
}, 12000);
return () => clearInterval(interval);
}, [publicPhotos]);
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
notify({ title: 'Đang xử lý...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
try {
// Lấy tọa độ hiện tại của người dùng với cơ chế chống treo (Promise.race)
const location = await Promise.race([
new Promise<GeolocationPosition | null>((resolve) => {
if (!navigator.geolocation) {
resolve(null);
} else {
navigator.geolocation.getCurrentPosition(
(pos) => resolve(pos),
() => resolve(null),
{ timeout: 4000, enableHighAccuracy: true }
);
}
}),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 4500))
]);
// 1. Tạo tài khoản khách và lấy token
let guestToken = localStorage.getItem('guest_token');
let guestUser = JSON.parse(localStorage.getItem('guest_user') || 'null');
if (!guestToken) {
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
if (!guestRes.ok) throw new Error('Không thể tạo phiên khách.');
const guestData = await guestRes.json();
guestToken = guestData.access_token;
guestUser = guestData.user;
localStorage.setItem('guest_token', guestToken!);
localStorage.setItem('guest_user', JSON.stringify(guestUser));
}
// 2. Tải ảnh lên
const formData = new FormData();
formData.append('images', file);
if (location) {
formData.append('latitude', location.coords.latitude.toString());
formData.append('longitude', location.coords.longitude.toString());
}
let uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
method: 'POST',
headers: { 'Authorization': `Bearer ${guestToken!}` },
body: formData,
});
if (uploadRes.status === 401) {
console.warn('Guest token invalid or expired. Creating a new guest user and retrying...');
localStorage.removeItem('guest_token');
localStorage.removeItem('guest_user');
localStorage.removeItem('token');
localStorage.removeItem('user');
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
if (!guestRes.ok) throw new Error('Không thể tạo lại phiên khách.');
const guestData = await guestRes.json();
guestToken = guestData.access_token;
guestUser = guestData.user;
localStorage.setItem('guest_token', guestToken!);
localStorage.setItem('guest_user', JSON.stringify(guestUser));
uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
method: 'POST',
headers: { 'Authorization': `Bearer ${guestToken!}` },
body: formData,
});
}
if (!uploadRes.ok) {
const errorData = await uploadRes.json();
throw new Error(errorData.message || 'Tải ảnh thất bại.');
}
notify({ title: 'Thành công!', message: 'Ảnh của bạn đã được tải lên.', type: 'success' });
// Cập nhật lại danh sách ảnh lập tức
await fetchPublicPhotos();
setCurrentBgIndex(0);
// Đăng nhập luôn bằng tài khoản khách này để người dùng xem được ảnh của mình trên bản đồ
localStorage.setItem('token', guestToken!);
localStorage.setItem('user', JSON.stringify(guestUser));
if (onLoginSuccess) {
onLoginSuccess(guestUser);
}
} catch (error: any) {
notify({ title: 'Lỗi', message: error.message, type: 'error' });
} finally {
// Reset input để có thể chọn lại cùng 1 file
if (event.target) event.target.value = '';
}
};
return ( return (
<div className="flex h-screen w-full flex-col md:flex-row overflow-hidden font-sans bg-gray-50 relative"> <div className="h-dvh w-full overflow-hidden font-sans bg-gray-900 relative">
{/* Logo/Brand Header ở góc trên bên trái */} {/* Background Image - Hiển thị trên mọi thiết bị với hiệu ứng Crossfade & Panning */}
<div className="absolute top-8 left-8 z-20 flex items-center gap-3 text-white md:text-white drop-shadow-lg"> <div className="absolute inset-0 z-0 bg-gray-950 overflow-hidden">
<Compass className="w-10 h-10" /> {/* Slot 1 */}
<span className="text-2xl font-black tracking-tighter uppercase">Travel Planner</span> {bg1 && (
</div>
{/* Nửa bên trái: Hiển thị một hình ảnh duy nhất */}
<div className="hidden md:block md:w-1/2 relative h-full bg-blue-900">
<img <img
src={backgroundImage} key={bg1}
className="w-full h-full object-cover opacity-70" src={bg1}
alt="Travel Background" className="absolute inset-y-0 left-0 h-full w-[120%] max-w-none object-cover transition-opacity duration-[1200ms] ease-in-out animate-panning"
style={{
opacity: fade1 ? 0.8 : 0,
}}
alt="Travel Background 1"
/> />
<div className="absolute inset-0 bg-gradient-to-br from-black/40 to-transparent pointer-events-none" /> )}
{/* Thumbnail Grid ở góc dưới bên trái */} {/* Slot 2 */}
<div className="absolute bottom-12 left-8 z-20 flex flex-col gap-2 items-start drop-shadow-2xl"> {bg2 && (
{/* Hàng trên cùng: 1 thumbnail */} <img
<div className="flex gap-2"> key={bg2}
<img src={TRAVEL_IMAGES[0]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 1" /> src={bg2}
</div> 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={{
{/* Hàng thứ 2: 2 thumbnail */} opacity: fade2 ? 0.8 : 0,
<div className="flex gap-2"> }}
<img src={TRAVEL_IMAGES[1]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 2" /> alt="Travel Background 2"
<img src={TRAVEL_IMAGES[2]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 3" /> />
</div>
{/* Hàng dưới cùng: 3 thumbnail */}
<div className="flex gap-2">
<img src={TRAVEL_IMAGES[3]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 4" />
<img src={TRAVEL_IMAGES[4]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 5" />
<img src={TRAVEL_IMAGES[5]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 6" />
</div>
</div>
</div>
{/* Nửa bên phải: Nội dung chào mừng và Hành động */}
<div className="flex-1 md:w-1/2 flex flex-col justify-center items-center bg-white p-8 lg:p-16 h-full overflow-y-auto">
<div className="max-w-md w-full space-y-12">
<div className="space-y-4">
{isInitialSetup ? (
<>
<div className="inline-flex items-center px-3 py-1 rounded-full bg-amber-50 text-amber-700 text-xs font-bold border border-amber-100 mb-2">
<ShieldCheck className="w-3 h-3 mr-1" /> CẤU HÌNH HỆ THỐNG LẦN ĐU
</div>
<h1 className="text-4xl font-extrabold text-gray-900 leading-tight">
Thiết lập Quản trị viên
</h1>
<p className="text-lg text-gray-500 leading-relaxed">
Chào mừng! Hệ thống vừa đưc cài đt. Vui lòng tạo tài khoản đu tiên đ quản vận hành trang web.
</p>
</>
) : (
<>
<h1 className="text-4xl font-extrabold text-gray-900 leading-tight">
Lên kế hoạch cho hành trình tiếp theo
</h1>
<p className="text-lg text-gray-500 leading-relaxed">
Khám phá các đa điểm nổi bật qua bản đ cộng đng hoặc đăng nhập đ bắt đu tự tạo chuyến đi cho riêng mình.
</p>
</>
)} )}
</div> </div>
<div className="grid grid-cols-1 gap-4"> {/* Keyframes cho hiệu ứng panning từ trái sang phải */}
{isInitialSetup && ( <style>{`
@keyframes pan-left-to-right {
0% {
transform: translateX(0) translateZ(0);
}
100% {
transform: translateX(-16.667%) translateZ(0);
}
}
.animate-panning {
animation: pan-left-to-right 13500ms linear forwards;
will-change: transform;
}
`}</style>
{/* Top Bar - Thanh điều hướng trên cùng */}
<div className="absolute top-0 left-0 right-0 z-20 p-4 pt-[calc(1rem+env(safe-area-inset-top,0px))] flex justify-between items-center">
<div className="flex items-center gap-2 text-white drop-shadow-lg">
<Compass className="w-8 h-8" />
<span className="text-xl font-black tracking-tighter uppercase hidden sm:block">Travel Planner</span>
</div>
<button <button
onClick={onGoToSignup} onClick={() => setIsLoginModalOpen(true)}
className="flex items-center justify-center gap-3 bg-blue-600 hover:bg-blue-700 text-white font-bold py-5 px-8 rounded-2xl transition-all shadow-xl shadow-blue-100 animate-bounce-subtle mb-2" className="flex items-center justify-center gap-2 bg-white/10 backdrop-blur-md text-white font-bold py-2 px-4 rounded-full border border-white/20 hover:bg-white/20 transition-all"
> >
<UserPlus className="w-5 h-5" /> <LogIn className="w-4 h-4" />
Bắt đu thiết lập ngay <span className="text-sm">Đăng nhập</span>
</button> </button>
</div>
{/* Body: Animation người lữ hành */}
<div className="absolute inset-0 flex items-center justify-center z-10" >
{/* Kích hoạt click vào file input để chọn hoặc chụp ảnh */}
<TravelerAnimation onClick={() => fileInputRef.current?.click()} />
</div>
{/* Input chọn file ẩn để chụp/chọn ảnh */}
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
capture="environment"
className="hidden"
/>
{/* Community Gallery Previews */}
{publicPhotos.length > 0 && (
<div className="absolute bottom-[calc(7rem+env(safe-area-inset-bottom,0px))] left-0 right-0 z-20 px-4 flex flex-col items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-white/70 drop-shadow-md">
Khoảnh khắc từ cộng đng ({publicPhotos.length})
</span>
<div className="flex gap-3 overflow-x-auto max-w-full no-scrollbar pb-2 px-4 justify-center">
{publicPhotos.slice(0, 8).map((photo, index) => (
<button
key={photo.id}
onClick={() => setCurrentBgIndex(index)}
className={`relative w-14 h-14 rounded-xl overflow-hidden border-2 transition-all active:scale-95 shrink-0 ${
currentBgIndex === index ? 'border-emerald-500 scale-110 shadow-lg' : 'border-white/20 hover:border-white/50'
}`}
>
<img src={photo.imageUrl} alt="Community thumbnail" className="w-full h-full object-cover" />
</button>
))}
</div>
<style>{`
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
`}</style>
</div>
)} )}
{/* Bottom Bar - Nút hành động chính */}
<div className="absolute bottom-[calc(2rem+env(safe-area-inset-bottom,0px))] left-1/2 -translate-x-1/2 z-20 w-full px-4">
<button <button
onClick={onGoToMap} onClick={onGoToMap}
className="flex items-center justify-center gap-3 bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-4 px-8 rounded-2xl transition-all shadow-lg shadow-indigo-100" className="w-full max-w-md mx-auto flex items-center justify-center gap-3 bg-white/20 backdrop-blur-lg text-white font-bold py-4 px-8 rounded-2xl transition-all shadow-2xl border border-white/30 hover:bg-white/30 active:scale-95"
> >
<MapIcon className="w-5 h-5" /> <MapIcon className="w-5 h-5" />
Khám phá các hành trình du lịch Khám phá các hành trình du lịch
</button> </button>
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => setIsLoginModalOpen(true)}
className="flex items-center justify-center gap-2 bg-blue-50 hover:bg-blue-100 text-blue-700 font-bold py-4 rounded-2xl transition-all"
>
<LogIn className="w-5 h-5" />
Đăng nhập
</button>
<button
onClick={onGoToSignup}
className="flex items-center justify-center gap-2 bg-gray-50 hover:bg-gray-100 text-gray-700 font-bold py-4 rounded-2xl border border-gray-100 transition-all"
>
<UserPlus className="w-5 h-5" />
Đăng
</button>
</div>
<button
onClick={onContinue}
className="flex items-center justify-center gap-3 text-gray-400 hover:text-gray-600 py-2 transition-all text-sm font-medium"
>
Tiếp tục tham quan nh
<ArrowRight className="w-5 h-5" />
</button>
</div>
<div className="pt-12 flex items-center gap-4 text-sm text-gray-400 border-t border-gray-100">
<div className="flex -space-x-3">
{[1, 2, 3, 4].map(i => (
<img key={i} className="w-10 h-10 rounded-full border-4 border-white" src={`https://i.pravatar.cc/100?img=${i+20}`} alt="user" />
))}
</div>
<p className="font-medium">Tham gia cùng +2,400 người du lịch khác</p>
</div>
</div>
</div> </div>
{/* Login Modal Component */} {/* Login Modal Component */}
+242
View File
@@ -0,0 +1,242 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { ChevronLeft, FileText, Plus, Search, Trash2, Loader2, Save, Calendar } from 'lucide-react';
import ReactQuill, { Quill } from 'react-quill-new'; // Nếu react-quill gặp lỗi với React 18, hãy dùng react-quill-new
import 'react-quill-new/dist/quill.snow.css';
// Cấu hình Lucide Icons cho Quill
const Icons = Quill.import('ui/icons');
Icons['bold'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/></svg>';
Icons['italic'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>';
Icons['underline'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>';
Icons['strike'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4H9a3 3 0 0 0-2.83 4"/><path d="M14 12a4 4 0 0 1 0 8H6"/><line x1="4" y1="12" x2="20" y2="12"/></svg>';
Icons['link'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
Icons['image'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>';
Icons['clean'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.9-9.9c1-1 2.5-1 3.4 0l4.4 4.4c1 1 1 2.5 0 3.4L11 21Z"/><path d="m22 21-5.9-5.9"/><path d="M16 11l-5 5"/></svg>';
Icons['table'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h18v18H3z"/><path d="M3 9h18"/><path d="M3 15h18"/><path d="M9 3v18"/><path d="M15 3v18"/></svg>';
Icons['header']['1'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="m17 12 3-2v8"/></svg>';
Icons['header']['2'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1"/></svg>';
Icons['list']['bullet'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>';
Icons['list']['check'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="m9 12 2 2 4-4"/></svg>';
Icons['align'][''] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="17" y1="18" x2="3" y2="18"/></svg>';
Icons['align']['center'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="10" x2="6" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="18" y1="18" x2="6" y2="18"/></svg>';
Icons['align']['right'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="21" y1="10" x2="7" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="7" y2="18"/></svg>';
Icons['align']['justify'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="21" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/></svg>';
Icons['indent']['+1'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="11 17 16 12 11 7"/><line x1="18" y1="12" x2="3" y2="12"/></svg>';
Icons['indent']['-1'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="8 17 3 12 8 7"/><line x1="21" y1="12" x2="6" y2="12"/></svg>';
interface Note {
id: string;
title: string;
content: string;
createdAt: string;
}
export const MyNotePage = ({ onBack }: { onBack: () => void }) => {
const [isLoading, setIsLoading] = useState(false);
const [notes, setNotes] = useState<Note[]>([]);
const [isCreating, setIsCreating] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const quillRef = useRef<ReactQuill>(null);
// State cho form ghi chú
const [noteForm, setNoteForm] = useState({ title: '', content: '' });
// Khôi phục ghi chú từ localStorage khi load trang
useEffect(() => {
const saved = localStorage.getItem('my_journey_notes');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed)) {
setNotes(parsed);
}
} catch (e) { console.error("Lỗi parse notes:", e); }
}
}, []);
// Tự động lưu ghi chú vào localStorage khi có thay đổi
useEffect(() => {
localStorage.setItem('my_journey_notes', JSON.stringify(notes));
}, [notes]);
// Cấu hình các công cụ định dạng cho ReactQuill
const quillModules = useMemo(() => ({
table: true,
toolbar: {
container: [
[{ 'header': 1 }, { 'header': 2 }],
['bold', 'italic', 'underline', 'strike'],
[{ 'align': [] }],
[{ 'indent': '-1'}, { 'indent': '+1' }],
[{ 'list': 'bullet' }, { 'list': 'check' }],
['link', 'image', 'table', 'clean'],
],
handlers: {
table: function() {
const rows = prompt('Nhập số hàng:', '3');
const cols = prompt('Nhập số cột:', '3');
if (rows && cols) {
const quill = (quillRef.current as any)?.getEditor();
if (quill) {
quill.getModule('table').insertTable(parseInt(rows), parseInt(cols));
}
}
}
}
},
}), []);
const handleSaveNote = () => {
if (!noteForm.title.trim() && !noteForm.content.trim()) return;
const note: Note = {
id: Date.now().toString(),
title: noteForm.title || 'Ghi chú không tiêu đề',
content: noteForm.content,
createdAt: new Date().toISOString(),
};
setNotes([note, ...notes]);
setIsCreating(false);
setNoteForm({ title: '', content: '' });
};
const handleDeleteNote = (id: string) => {
if (window.confirm("Bạn có chắc chắn muốn xóa ghi chú này?")) {
setNotes(prev => prev.filter(n => n.id !== id));
}
};
const filteredNotes = (notes || []).filter(n => {
const title = (n.title || '').toLowerCase();
const content = (n.content || '').replace(/<[^>]*>/g, '').toLowerCase();
const query = searchQuery.toLowerCase();
return title.includes(query) || content.includes(query);
});
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
{/* Header */}
<div className="sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-gray-100 px-4 py-4 flex items-center gap-4">
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<ChevronLeft className="w-6 h-6 text-gray-600" />
</button>
<div>
<h1 className="text-xl font-black text-gray-900">Ghi chú của tôi</h1>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Sổ tay hành trình nhân</p>
</div>
</div>
<div className="flex-1 p-6 max-w-2xl mx-auto w-full">
{!isCreating && (
<div className="flex items-center gap-4 mb-8">
<div className="relative flex-1">
<input
type="text"
placeholder="Tìm kiếm nội dung ghi chú..."
className="w-full pl-10 pr-4 py-3 bg-white rounded-2xl border border-gray-100 shadow-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all text-sm"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
</div>
<button
onClick={() => setIsCreating(true)}
className="p-3 bg-amber-500 text-white rounded-2xl shadow-lg shadow-amber-200 hover:bg-amber-600 transition-all active:scale-95"
>
<Plus className="w-6 h-6" />
</button>
</div>
)}
{isLoading ? (
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<Loader2 className="w-10 h-10 animate-spin mb-4" />
<p className="font-bold">Đang tải ghi chú...</p>
</div>
) : isCreating ? (
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-4">
<input
type="text"
placeholder="Tiêu đề ghi chú..."
className="w-full px-4 py-3 bg-white rounded-2xl border border-gray-100 shadow-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all text-lg font-bold"
value={noteForm.title}
onChange={(e) => setNoteForm({ ...noteForm, title: e.target.value })}
/>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden min-h-[500px] flex flex-col">
<ReactQuill
ref={quillRef}
theme="snow"
value={noteForm.content}
onChange={(content) => setNoteForm({ ...noteForm, content })}
modules={quillModules}
placeholder="Bắt đầu viết cảm nhận của bạn tại đây..."
className="flex-1 flex flex-col [&_.ql-container]:flex-1 [&_.ql-container]:flex [&_.ql-container]:flex-col [&_.ql-editor]:flex-1 [&_.ql-editor]:text-base [&_.ql-toolbar]:flex [&_.ql-toolbar]:flex-wrap sm:[&_.ql-toolbar]:flex-nowrap [&_.ql-toolbar]:border-0 [&_.ql-toolbar]:border-b [&_.ql-toolbar]:border-gray-50 [&_.ql-editor_table]:border-collapse [&_.ql-editor_table]:w-full [&_.ql-editor_td]:border [&_.ql-editor_td]:border-gray-200 [&_.ql-editor_td]:p-2 [&_.ql-editor_.ql-align-center]:text-center [&_.ql-editor_.ql-align-right]:text-right [&_.ql-editor_.ql-align-justify]:text-justify [&_.ql-picker-options]:!z-[50] [&_.ql-picker-options]:!shadow-xl [&_.ql-picker-options]:!rounded-xl [&_.ql-picker-options]:!border-gray-100 [&_.ql-stroke]:!stroke-gray-500 [&_.ql-fill]:!fill-gray-500 [&_button:hover_.ql-stroke]:!stroke-amber-500 [&_button:hover_.ql-fill]:!stroke-amber-500 [&_button.ql-active_.ql-stroke]:!stroke-amber-500"
/>
</div>
<div className="flex gap-3">
<button
onClick={handleSaveNote}
className="flex-1 flex items-center justify-center gap-2 bg-amber-500 hover:bg-amber-600 text-white py-4 rounded-2xl font-black uppercase tracking-widest text-xs transition-all shadow-lg shadow-amber-200"
>
<Save className="w-5 h-5" /> Lưu ghi chú
</button>
<button
onClick={() => { setIsCreating(false); setNoteForm({ title: '', content: '' }); }}
className="px-6 py-4 bg-gray-100 hover:bg-gray-200 text-gray-500 rounded-2xl font-black uppercase tracking-widest text-xs transition-all"
>
Hủy
</button>
</div>
</div>
) : filteredNotes.length > 0 ? (
<div className="grid grid-cols-1 gap-4">
{filteredNotes.map((note) => (
<div key={note.id} className="bg-white p-5 rounded-3xl border border-gray-100 shadow-sm hover:shadow-md transition-all group">
<div className="flex justify-between items-start mb-3">
<div>
<h4 className="font-bold text-gray-900">{note.title}</h4>
<div className="flex items-center gap-2 text-[10px] text-gray-400 font-bold uppercase mt-1">
<Calendar className="w-3 h-3" />
{new Date(note.createdAt).toLocaleDateString('vi-VN')}
</div>
</div>
<button
onClick={() => handleDeleteNote(note.id)}
className="p-2 text-gray-300 hover:text-red-500 hover:bg-red-50 rounded-xl transition-all opacity-0 group-hover:opacity-100"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div
className="text-sm text-gray-600 line-clamp-3 prose prose-sm max-w-none ql-editor !p-0 [&_table]:border-collapse [&_table]:w-full [&_td]:border [&_td]:border-gray-200 [&_td]:p-2 [&_.ql-align-center]:text-center [&_.ql-align-right]:text-right [&_.ql-align-justify]:text-justify"
dangerouslySetInnerHTML={{ __html: note.content }}
/>
</div>
))}
</div>
) : (
<div className="text-center py-24 bg-white rounded-[40px] border-2 border-dashed border-gray-100 shadow-inner">
<div className="w-20 h-20 bg-amber-50 rounded-3xl flex items-center justify-center mx-auto mb-6 text-amber-500">
<FileText className="w-10 h-10" />
</div>
<h3 className="text-xl font-bold text-gray-900">{searchQuery ? 'Không tìm thấy ghi chú' : 'Chưa có ghi chú nào'}</h3>
<p className="text-sm text-gray-400 max-w-xs mx-auto mt-2">
{searchQuery ? 'Hãy thử tìm kiếm với từ khóa khác.' : 'Hãy lưu lại những cảm nhận, lịch trình riêng hoặc các lưu ý quan trọng cho hành trình của bạn.'}
</p>
{!searchQuery && (
<button
onClick={() => setIsCreating(true)}
className="mt-8 inline-flex items-center gap-2 bg-amber-500 hover:bg-amber-600 text-white px-8 py-4 rounded-2xl font-black uppercase tracking-widest text-xs transition-all shadow-lg shadow-amber-200 active:scale-95"
>
<Plus className="w-5 h-5" /> Tạo ghi chú mới
</button>
)}
</div>
)}
</div>
</div>
);
};
+335 -17
View File
@@ -1,7 +1,17 @@
import React, { useEffect, useState, useMemo } from 'react'; import { useEffect, useState, useMemo } from 'react';
import { ChevronLeft, Image as ImageIcon, Download, Calendar, MapPin, Loader2, Filter, X, Trash2 } from 'lucide-react'; import { ChevronLeft, Image as ImageIcon, Download, Calendar, MapPin, Loader2, Filter, X, Trash2, Edit, Heart } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification'; import { useNotification } from '@/hooks/useNotification';
import { useConfirm } from '@/hooks/useConfirm'; import { useConfirm } from '@/hooks/useConfirm';
import { CoordinateSelectModal } from '../components/CoordinateSelectModal';
const getMostLikedPhoto = (photos: any[]) => {
if (!photos || photos.length === 0) return null;
return [...photos].sort((a, b) => {
const likesA = a.metadata?.likedUserIds?.length || 0;
const likesB = b.metadata?.likedUserIds?.length || 0;
return likesB - likesA;
})[0];
};
export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => { export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
const [photos, setPhotos] = useState<any[]>([]); const [photos, setPhotos] = useState<any[]>([]);
@@ -13,12 +23,140 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
const notify = useNotification(); const notify = useNotification();
const confirm = useConfirm(); const confirm = useConfirm();
const [isEditing, setIsEditing] = useState(false);
const [editTitle, setEditTitle] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editLat, setEditLat] = useState<number | ''>('');
const [editLng, setEditLng] = useState<number | ''>('');
const [isSavingEdit, setIsSavingEdit] = useState(false);
const [isMapOpen, setIsMapOpen] = useState(false);
const [resolvedAddress, setResolvedAddress] = useState<string>('');
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const lat = selectedPhotoForDisplay?.metadata?.lat;
const lng = selectedPhotoForDisplay?.metadata?.lng;
if (typeof lat === 'number' && typeof lng === 'number') {
setResolvedAddress('Đang xác định địa điểm...');
fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&accept-language=vi`)
.then(res => {
if (!res.ok) throw new Error();
return res.json();
})
.then(data => {
if (data && data.display_name) {
const shortAddress = data.display_name.split(',').slice(0, 3).join(',').trim();
setResolvedAddress(shortAddress || data.display_name);
} else {
setResolvedAddress(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
}
})
.catch(() => {
setResolvedAddress(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
});
} else {
setResolvedAddress('Chưa xác định tọa độ');
}
}, [selectedPhotoForDisplay?.id, selectedPhotoForDisplay?.metadata?.lat, selectedPhotoForDisplay?.metadata?.lng]);
useEffect(() => {
if (selectedPhotoForDisplay) {
setEditTitle(selectedPhotoForDisplay.metadata?.title || '');
setEditDescription(selectedPhotoForDisplay.metadata?.description || '');
setEditLat(selectedPhotoForDisplay.metadata?.lat ?? '');
setEditLng(selectedPhotoForDisplay.metadata?.lng ?? '');
setIsEditing(false);
}
}, [selectedPhotoForDisplay]);
const handleSaveEdit = async () => {
if (editLat !== '' && (isNaN(editLat) || editLat < -90 || editLat > 90)) {
notify({ title: 'Lỗi', message: 'Vĩ độ không hợp lệ (-90 đến 90).', type: 'error' });
return;
}
if (editLng !== '' && (isNaN(editLng) || editLng < -180 || editLng > 180)) {
notify({ title: 'Lỗi', message: 'Kinh độ không hợp lệ (-180 đến 180).', type: 'error' });
return;
}
setIsSavingEdit(true);
try {
const response = await fetch(`/api/v1/photos/${selectedPhotoForDisplay.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
},
body: JSON.stringify({
title: editTitle,
description: editDescription,
latitude: editLat === '' ? undefined : editLat,
longitude: editLng === '' ? undefined : editLng
})
});
if (!response.ok) throw new Error('Failed to update photo info');
const updatedPhoto = await response.json();
notify({ title: 'Thành công', message: 'Thông tin ảnh đã được cập nhật.', type: 'success' });
// Update photos list
setPhotos(prev => prev.map(p => p.id === updatedPhoto.id ? { ...p, metadata: updatedPhoto.metadata } : p));
// Update selectedPhotoForDisplay
setSelectedPhotoForDisplay((prev: any) => prev ? ({ ...prev, metadata: updatedPhoto.metadata }) : null);
setIsEditing(false);
} catch (error) {
notify({ title: 'Lỗi', message: 'Không thể cập nhật thông tin ảnh. Vui lòng thử lại.', type: 'error' });
} finally {
setIsSavingEdit(false);
}
};
const currentUser = useMemo(() => {
const userStr = localStorage.getItem('user') || localStorage.getItem('guest_user');
if (!userStr) return null;
try {
return JSON.parse(userStr);
} catch (e) {
return null;
}
}, []);
const likedUserIds = selectedPhotoForDisplay && selectedPhotoForDisplay.metadata && Array.isArray(selectedPhotoForDisplay.metadata.likedUserIds)
? selectedPhotoForDisplay.metadata.likedUserIds
: [];
const isLiked = currentUser && likedUserIds.includes(currentUser.id);
const likeCount = likedUserIds.length;
const handleToggleLike = async () => {
if (!selectedPhotoForDisplay) return;
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
if (!token) return;
try {
const response = await fetch(`/api/v1/photos/${selectedPhotoForDisplay.id}/toggle-like`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const updatedPhoto = await response.json();
setPhotos(prev => prev.map(p => p.id === updatedPhoto.id ? { ...p, metadata: updatedPhoto.metadata } : p));
setSelectedPhotoForDisplay((prev: any) => prev ? ({ ...prev, metadata: updatedPhoto.metadata }) : null);
}
} catch (error) {
console.error('Lỗi khi thích ảnh:', error);
}
};
useEffect(() => { useEffect(() => {
const fetchPhotos = async () => { const fetchPhotos = async () => {
try { try {
const response = await fetch('/api/v1/users/me/photos', { const response = await fetch('/api/v1/users/me/photos', {
headers: { headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}` 'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
} }
}); });
if (!response.ok) throw new Error('Failed to fetch photos'); if (!response.ok) throw new Error('Failed to fetch photos');
@@ -66,7 +204,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
// Effect để thiết lập ảnh được chọn hiển thị hoặc reset nếu ảnh hiện tại không còn trong danh sách lọc // Effect để thiết lập ảnh được chọn hiển thị hoặc reset nếu ảnh hiện tại không còn trong danh sách lọc
useEffect(() => { useEffect(() => {
if (filteredPhotos.length > 0 && (!selectedPhotoForDisplay || !filteredPhotos.some(p => p.id === selectedPhotoForDisplay.id))) { if (filteredPhotos.length > 0 && (!selectedPhotoForDisplay || !filteredPhotos.some(p => p.id === selectedPhotoForDisplay.id))) {
setSelectedPhotoForDisplay(filteredPhotos[0]); setSelectedPhotoForDisplay(getMostLikedPhoto(filteredPhotos));
} else if (filteredPhotos.length === 0) { } else if (filteredPhotos.length === 0) {
setSelectedPhotoForDisplay(null); setSelectedPhotoForDisplay(null);
} else if (selectedPhotoForDisplay) { } else if (selectedPhotoForDisplay) {
@@ -89,7 +227,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
const response = await fetch(`/api/v1/photos/${photoId}`, { const response = await fetch(`/api/v1/photos/${photoId}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}` 'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
} }
}); });
@@ -215,45 +353,196 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
<div className="md:flex-1 bg-white rounded-2xl shadow-lg border border-gray-100 p-4 flex flex-col items-center justify-center min-h-[300px]"> <div className="md:flex-1 bg-white rounded-2xl shadow-lg border border-gray-100 p-4 flex flex-col items-center justify-center min-h-[300px]">
{selectedPhotoForDisplay ? ( {selectedPhotoForDisplay ? (
<div className="relative w-full h-full flex flex-col items-center justify-center"> <div className="relative w-full h-full flex flex-col items-center justify-center">
<div className="relative overflow-hidden rounded-xl shadow-md max-w-full max-h-[calc(100vh-350px)] group">
<img <img
src={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl} src={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl}
alt="Selected Photo" alt="Selected Photo"
className="max-w-full max-h-[calc(100vh-350px)] object-contain rounded-xl shadow-md" className="max-w-full max-h-[calc(100vh-350px)] object-contain cursor-zoom-in"
onClick={() => setIsFullscreen(true)}
/> />
{/* Overlays (Only show when not editing) */}
{!isEditing && (
<>
{/* Like (Heart) button overlay */}
<button
onClick={handleToggleLike}
className="absolute top-4 left-4 z-10 flex items-center gap-1.5 bg-black/60 hover:bg-black/75 border border-white/10 text-white font-bold py-1.5 px-3 rounded-full transition-all active:scale-95 text-[11px] backdrop-blur-md"
title={isLiked ? "Bỏ thích" : "Thích"}
>
<Heart className={`w-4 h-4 transition-colors ${
isLiked ? 'text-rose-500 fill-rose-500 animate-in zoom-in-75' : 'text-gray-300 hover:text-rose-450'
}`} />
<span>{likeCount}</span>
</button>
{/* Delete button overlay */}
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDeletePhoto(selectedPhotoForDisplay.id); handleDeletePhoto(selectedPhotoForDisplay.id);
}} }}
className="absolute top-4 right-4 p-2 bg-red-500/80 backdrop-blur-sm text-white rounded-full shadow-lg hover:bg-red-600 transition-all active:scale-90" className="absolute top-4 right-4 z-10 p-2 bg-black/60 hover:bg-red-600 border border-white/10 text-white rounded-full transition-all active:scale-95 backdrop-blur-md"
title="Xóa ảnh này" title="Xóa ảnh này"
> >
<Trash2 className="w-5 h-5" /> <Trash2 className="w-4 h-4" />
</button> </button>
<div className="mt-6 w-full flex items-center justify-between px-2"> {/* Bottom metadata details gradient panel overlay */}
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent p-6 text-white flex flex-col gap-2 text-left">
<div className="flex justify-between items-start gap-4">
<div className="flex-1 min-w-0">
{selectedPhotoForDisplay.metadata?.title ? (
<h3 className="text-base font-extrabold text-white break-words drop-shadow-md">
{selectedPhotoForDisplay.metadata.title}
</h3>
) : (
<span className="text-xs text-gray-350 italic block mb-1 drop-shadow-md">Chưa tiêu đ</span>
)}
{selectedPhotoForDisplay.metadata?.description ? (
<p className="text-xs text-gray-250 leading-relaxed mt-1 break-words drop-shadow-sm max-h-16 overflow-y-auto no-scrollbar">
{selectedPhotoForDisplay.metadata.description}
</p>
) : (
<span className="text-[11px] text-gray-350 italic block mt-1 drop-shadow-sm">Chưa tả</span>
)}
</div>
{/* Edit Button Overlay */}
<button
onClick={() => setIsEditing(true)}
className="p-2 bg-white/10 hover:bg-white/20 border border-white/15 rounded-xl text-white hover:text-gray-200 transition-all shrink-0 backdrop-blur-sm"
title="Chỉnh sửa thông tin"
>
<Edit className="w-4 h-4" />
</button>
</div>
{/* Additional metadata info inside bottom overlay */}
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-white/10 pt-3 text-xs text-gray-200">
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2 text-gray-900 font-bold"> <div className="flex items-center gap-2 text-gray-300 text-[10px] font-bold uppercase tracking-wider">
<MapPin className="w-4 h-4 text-blue-500" />
{selectedPhotoForDisplay.tour?.title || 'Không rõ hành trình'}
</div>
<div className="flex items-center gap-2 text-gray-400 text-xs font-medium uppercase tracking-wider">
<Calendar className="w-3.5 h-3.5" /> <Calendar className="w-3.5 h-3.5" />
{new Date(selectedPhotoForDisplay.capturedAt).toLocaleDateString('vi-VN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })} Ngày chụp: {new Date(selectedPhotoForDisplay.capturedAt).toLocaleDateString('vi-VN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
</div> </div>
<div className="flex items-center gap-2 font-bold" title={selectedPhotoForDisplay.metadata?.lat && selectedPhotoForDisplay.metadata?.lng ? `${selectedPhotoForDisplay.metadata.lat.toFixed(6)}, ${selectedPhotoForDisplay.metadata.lng.toFixed(6)}` : ''}>
<MapPin className="w-3.5 h-3.5 text-rose-500" />
Đa điểm: {resolvedAddress}
</div>
{selectedPhotoForDisplay.tour?.title && (
<div className="text-[10px] text-gray-400">
Hành trình: {selectedPhotoForDisplay.tour.title}
</div>
)}
</div> </div>
{selectedPhotoForDisplay.originalUrl && ( {selectedPhotoForDisplay.originalUrl && (
<a <a
href={selectedPhotoForDisplay.originalUrl} href={selectedPhotoForDisplay.originalUrl}
download download
className="flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-2xl font-black uppercase tracking-widest text-[10px] transition-all shadow-lg shadow-blue-900/20 active:scale-95" className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold uppercase tracking-widest text-[9px] transition-all shadow-md active:scale-95 shrink-0"
> >
<Download className="w-4 h-4" /> Tải xuống nh gốc <Download className="w-3.5 h-3.5 animate-pulse" /> Tải nh gốc
</a> </a>
)} )}
</div> </div>
</div> </div>
</>
)}
</div>
{isEditing && (
<div className="mt-6 w-full flex flex-col gap-4 px-2">
<div className="space-y-3 bg-gray-50 border border-gray-150 p-4 rounded-2xl w-full">
<h4 className="text-xs font-black uppercase tracking-wider text-blue-605">Chỉnh sửa thông tin nh</h4>
<div className="space-y-2">
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1">Tiêu đ</label>
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
placeholder="Nhập tiêu đề..."
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1"> tả</label>
<textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Nhập mô tả..."
rows={2}
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1"> đ</label>
<input
type="number"
step="any"
value={editLat}
onChange={(e) => setEditLat(e.target.value === '' ? '' : parseFloat(e.target.value))}
placeholder="Vĩ độ..."
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1">Kinh đ</label>
<input
type="number"
step="any"
value={editLng}
onChange={(e) => setEditLng(e.target.value === '' ? '' : parseFloat(e.target.value))}
placeholder="Kinh độ..."
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
</div>
<div className="flex justify-start">
<button
type="button"
onClick={() => setIsMapOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 border border-gray-200 text-gray-700 hover:text-gray-900 rounded-xl text-[10px] font-bold transition-all"
>
<MapPin className="w-3.5 h-3.5 text-rose-500" />
Chọn trên bản đ
</button>
</div>
<div className="flex justify-end gap-2 mt-2">
<button
onClick={() => setIsEditing(false)}
disabled={isSavingEdit}
className="px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg text-xs font-bold transition-all"
>
Hủy
</button>
<button
onClick={handleSaveEdit}
disabled={isSavingEdit}
className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition-all"
>
{isSavingEdit ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Đang lưu...
</>
) : (
'Lưu lại'
)}
</button>
</div>
</div>
</div>
)}
</div>
) : ( ) : (
<div className="text-center text-gray-400 py-20"> <div className="text-center text-gray-400 py-20">
<ImageIcon className="w-16 h-16 mx-auto mb-4 opacity-20" /> <ImageIcon className="w-16 h-16 mx-auto mb-4 opacity-20" />
@@ -296,6 +585,35 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
</div> </div>
{} {}
</div> </div>
<CoordinateSelectModal
isOpen={isMapOpen}
onClose={() => setIsMapOpen(false)}
initialLat={typeof editLat === 'number' ? editLat : undefined}
initialLng={typeof editLng === 'number' ? editLng : undefined}
onSelect={(lat, lng) => {
setEditLat(lat);
setEditLng(lng);
}}
/>
{isFullscreen && selectedPhotoForDisplay && (
<div
className="fixed inset-0 z-[9999] bg-black/95 flex items-center justify-center cursor-zoom-out animate-in fade-in duration-200"
onClick={() => setIsFullscreen(false)}
>
<button
onClick={() => setIsFullscreen(false)}
className="fixed top-6 right-6 p-3 bg-black/50 hover:bg-black/75 border border-white/10 rounded-full text-white transition-colors z-[10000]"
>
<X className="w-6 h-6" />
</button>
<img
src={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl}
alt="Fullscreen photo"
className="max-w-full max-h-full object-contain select-none animate-in zoom-in-95 duration-200"
/>
</div>
)}
</div> </div>
); );
}; };
+110 -66
View File
@@ -1,12 +1,12 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { User, Mail, Lock, ArrowRight, ChevronLeft, Compass, Phone, MapPin } from 'lucide-react'; import { User, Mail, Lock, ArrowRight, ChevronLeft, Phone, MapPin, ShieldCheck } from 'lucide-react';
interface SignupPageProps { interface SignupPageProps {
onBack: () => void; onBack: () => void;
onSuccess: () => void; onSuccess: () => void;
} }
export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => { const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
email: '', email: '',
@@ -15,21 +15,29 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
phone: '', phone: '',
address: '' address: ''
}); });
const [step, setStep] = useState<'form' | 'otp'>('form');
const [otp, setOtp] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const handleChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
setIsLoading(true);
try {
if (step === 'form') {
if (formData.password !== formData.confirmPassword) { if (formData.password !== formData.confirmPassword) {
setError('Mật khẩu xác nhận không khớp'); setError('Mật khẩu xác nhận không khớp');
setIsLoading(false);
return; return;
} }
setIsLoading(true); const response = await fetch(`/api/v1/auth/signup/request`, {
try {
const response = await fetch(`/api/v1/auth/signup`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -40,14 +48,34 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
address: formData.address || undefined address: formData.address || undefined
}), }),
}); });
const data = await response.json(); const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Yêu cầu đăng ký thất bại');
if (!response.ok) { setStep('otp');
throw new Error(data.message || 'Đăng ký thất bại'); } 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,
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(); onSuccess();
}
} catch (err: any) { } catch (err: any) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -56,35 +84,24 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
}; };
return ( return (
<div className="flex h-screen w-full overflow-hidden font-sans bg-white"> <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
{/* Nửa bên trái: Hình ảnh decor (Đồng bộ với LandingPage) */} <div className="max-w-md w-full space-y-8 bg-white p-10 rounded-3xl shadow-2xl">
<div className="hidden lg:block lg:w-1/2 relative bg-blue-900"> <button
<img onClick={onBack}
src="https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop" className="absolute top-6 left-6 text-gray-400 hover:text-gray-600 transition-colors"
alt="Travel background" >
className="absolute inset-0 h-full w-full object-cover opacity-50" <ChevronLeft className="w-6 h-6" />
/>
<div className="absolute inset-0 bg-gradient-to-br from-blue-600/40 to-transparent" />
<div className="absolute top-12 left-12 flex items-center gap-2 text-white z-10">
<Compass className="w-8 h-8" />
<span className="text-2xl font-black uppercase tracking-tighter">Travel Planner</span>
</div>
<div className="absolute bottom-20 left-12 text-white z-10 max-w-md">
<h2 className="text-4xl font-bold mb-4">Bắt đu hành trình của riêng bạn.</h2>
<p className="text-blue-100 opacity-80">Tạo tài khoản đ lưu lại những kế hoạch du lịch tuyệt vời nhất cùng bạn .</p>
</div>
</div>
{/* Nửa bên phải: Form đăng ký */}
<div className="flex-1 flex flex-col justify-center px-8 sm:px-16 lg:px-24 py-12 overflow-y-auto">
<div className="max-w-md w-full mx-auto">
<button onClick={onBack} className="flex items-center text-gray-400 hover:text-blue-600 mb-8 transition-colors font-medium">
<ChevronLeft className="w-5 h-5" /> Quay lại
</button> </button>
<div className="mb-10"> <div className="mb-10">
<h1 className="text-3xl font-extrabold text-gray-900">Tạo tài khoản mới</h1> <h1 className="text-3xl font-extrabold text-gray-900">
<p className="text-gray-500 mt-2 font-medium">Khám phá các tính năng lập kế hoạch chuyên nghiệp.</p> {step === 'form' ? 'Tạo tài khoản mới' : 'Xác thực tài khoản'}
</h1>
<p className="text-gray-500 mt-2 font-medium">
{step === 'form'
? 'Khám phá các tính năng lập kế hoạch chuyên nghiệp.'
: `Vui lòng nhập mã OTP đã được gửi tới ${formData.email}`}
</p>
</div> </div>
{error && ( {error && (
@@ -94,6 +111,8 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
)} )}
<form className="space-y-5" onSubmit={handleSubmit}> <form className="space-y-5" onSubmit={handleSubmit}>
{step === 'form' ? (
<>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Họ tên</label> <label className="text-sm font-bold text-gray-700 ml-1">Họ tên</label>
<div className="relative group"> <div className="relative group">
@@ -101,9 +120,9 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
<input <input
required required
type="text" type="text"
placeholder="Nguyễn Văn A" placeholder="Nhập họ và tên của bạn"
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => handleChange('name', e.target.value)}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all" className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/> />
</div> </div>
@@ -116,24 +135,55 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
<input <input
required required
type="email" type="email"
placeholder="email@example.com" placeholder="Nhập email của bạn"
value={formData.email} value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })} onChange={(e) => handleChange('email', e.target.value)}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all" className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/> />
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Mật khẩu</label>
<div className="relative group">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
required
type="password"
placeholder="Nhập mật khẩu"
value={formData.password}
onChange={(e) => handleChange('password', e.target.value)}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Xác nhận mật khẩu</label>
<div className="relative group">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
required
type="password"
placeholder="Xác nhận mật khẩu"
value={formData.confirmPassword}
onChange={(e) => handleChange('confirmPassword', e.target.value)}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Số điện thoại</label> <label className="text-sm font-bold text-gray-700 ml-1">Số điện thoại</label>
<div className="relative group"> <div className="relative group">
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" /> <Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input <input
type="tel" type="tel"
placeholder="0912 345 678" placeholder="Nhập số điện thoại"
value={formData.phone} value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })} onChange={(e) => handleChange('phone', e.target.value)}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all" className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/> />
</div> </div>
@@ -144,51 +194,45 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" /> <MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input <input
type="text" type="text"
placeholder="Quận 1, TP.HCM" placeholder="Nhập địa chỉ"
value={formData.address} value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })} onChange={(e) => handleChange('address', e.target.value)}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all" className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/> />
</div> </div>
</div> </div>
</div> </div>
</>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> ) : (
<div className="space-y-1.5"> <div className="space-y-2">
<label className="text-sm font-bold text-gray-700 ml-1">Mật khẩu</label> <label className="text-sm font-bold text-gray-700 ml-1">Mã xác thực OTP</label>
<div className="relative group">
<ShieldCheck className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input <input
required required
type="password" type="text"
placeholder="••••••••" maxLength={6}
value={formData.password} placeholder="Nhập 6 số mã OTP"
onChange={(e) => setFormData({ ...formData, password: e.target.value })} value={otp}
className="w-full px-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all" onChange={(e) => setOtp(e.target.value.replace(/\D/g, ''))}
/> className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl text-center font-bold tracking-[0.5em] text-lg focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
</div>
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Xác nhận</label>
<input
required
type="password"
placeholder="••••••••"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
className="w-full px-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/> />
</div> </div>
</div> </div>
)}
<button <button
disabled={isLoading} disabled={isLoading}
type="submit" type="submit"
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-5 rounded-2xl shadow-xl shadow-blue-100 transition-all active:scale-[0.98] disabled:opacity-50 mt-4" className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-5 rounded-2xl shadow-xl shadow-blue-100 transition-all active:scale-[0.98] disabled:opacity-50 mt-4"
> >
{isLoading ? 'Đang xử lý...' : 'Tạo tài khoản'} {isLoading ? 'Đang xử lý...' : step === 'form' ? 'Tạo tài khoản' : 'Xác nhận kích hoạt'}
{!isLoading && <ArrowRight className="w-5 h-5" />} {!isLoading && <ArrowRight className="w-5 h-5" />}
</button> </button>
</form> </form>
</div> </div>
</div> </div>
</div>
); );
}; };
export default SignupPage;
File diff suppressed because it is too large Load Diff
+516 -2
View File
@@ -13,7 +13,8 @@
], ],
"dependencies": { "dependencies": {
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8" "jspdf-autotable": "^5.0.8",
"react-quill": "^2.0.0"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^8.2.2" "concurrently": "^8.2.2"
@@ -24,6 +25,7 @@
"dependencies": { "dependencies": {
"@nestjs/cache-manager": "^3.1.3", "@nestjs/cache-manager": "^3.1.3",
"@nestjs/common": "^11.1.27", "@nestjs/common": "^11.1.27",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.27", "@nestjs/core": "^11.1.27",
"@nestjs/jwt": "^11.0.2", "@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
@@ -36,6 +38,9 @@
"cache-manager": "^7.2.8", "cache-manager": "^7.2.8",
"cache-manager-redis-yet": "^5.1.5", "cache-manager-redis-yet": "^5.1.5",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"exifr": "^7.1.3",
"heic-convert": "^2.1.0",
"nodemailer": "^9.0.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.12.0", "pg": "^8.12.0",
@@ -207,6 +212,8 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-leaflet-cluster": "^2.1.0", "react-leaflet-cluster": "^2.1.0",
"react-quill": "^2.0.0",
"react-quill-new": "^3.8.3",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"zustand": "^5.0.1" "zustand": "^5.0.1"
}, },
@@ -222,6 +229,62 @@
"vite": "^8.0.16" "vite": "^8.0.16"
} }
}, },
"frontend/node_modules/fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"license": "Apache-2.0"
},
"frontend/node_modules/parchment": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
"license": "BSD-3-Clause"
},
"frontend/node_modules/quill": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
"license": "BSD-3-Clause",
"dependencies": {
"eventemitter3": "^5.0.1",
"lodash-es": "^4.17.21",
"parchment": "^3.0.0",
"quill-delta": "^5.1.0"
},
"engines": {
"npm": ">=8.2.3"
}
},
"frontend/node_modules/quill-delta": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
"license": "MIT",
"dependencies": {
"fast-diff": "^1.3.0",
"lodash.clonedeep": "^4.5.0",
"lodash.isequal": "^4.5.0"
},
"engines": {
"node": ">= 12.0.0"
}
},
"frontend/node_modules/react-quill-new": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/react-quill-new/-/react-quill-new-3.8.3.tgz",
"integrity": "sha512-c96PYqFTo0pI4R3e79B3rH9LUIce1kIQbmTBu/imJQZk8305ogyLyBqKKjG2UoInDlquXqePSzmBo2aVia3ttw==",
"license": "MIT",
"dependencies": {
"lodash-es": "^4.17.21",
"quill": "~2.0.3"
},
"peerDependencies": {
"quill-delta": "^5.1.0",
"react": "^16 || ^17 || ^18 || ^19",
"react-dom": "^16 || ^17 || ^18 || ^19"
}
},
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -2015,6 +2078,60 @@
} }
} }
}, },
"node_modules/@nestjs/config": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz",
"integrity": "sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==",
"license": "MIT",
"dependencies": {
"dotenv": "17.4.1",
"dotenv-expand": "12.0.3",
"lodash": "4.18.1"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"rxjs": "^7.1.0"
}
},
"node_modules/@nestjs/config/node_modules/dotenv": {
"version": "17.4.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
"integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/@nestjs/config/node_modules/dotenv-expand": {
"version": "12.0.3",
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz",
"integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==",
"license": "BSD-2-Clause",
"dependencies": {
"dotenv": "^16.4.5"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/@nestjs/config/node_modules/dotenv-expand/node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/@nestjs/core": { "node_modules/@nestjs/core": {
"version": "11.1.27", "version": "11.1.27",
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.27.tgz", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.27.tgz",
@@ -3035,6 +3152,15 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/quill": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@types/quill/-/quill-1.3.10.tgz",
"integrity": "sha512-IhW3fPW+bkt9MLNlycw8u8fWb7oO7W5URC9MfZYHBlA24rex9rs23D5DETChu1zvgVdc5ka64ICjJOgQMr6Shw==",
"license": "MIT",
"dependencies": {
"parchment": "^1.1.2"
}
},
"node_modules/@types/raf": { "node_modules/@types/raf": {
"version": "3.4.3", "version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
@@ -3777,6 +3903,24 @@
"@redis/time-series": "1.1.0" "@redis/time-series": "1.1.0"
} }
}, },
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
"integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"get-intrinsic": "^1.3.0",
"set-function-length": "^1.2.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-bind-apply-helpers": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -4269,6 +4413,26 @@
} }
} }
}, },
"node_modules/deep-equal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz",
"integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==",
"license": "MIT",
"dependencies": {
"is-arguments": "^1.1.1",
"is-date-object": "^1.0.5",
"is-regex": "^1.1.4",
"object-is": "^1.1.5",
"object-keys": "^1.1.1",
"regexp.prototype.flags": "^1.5.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/deepmerge": { "node_modules/deepmerge": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -4292,6 +4456,40 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/define-properties": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.0.1",
"has-property-descriptors": "^1.0.0",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -4681,6 +4879,12 @@
"node": ">=0.8.x" "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": { "node_modules/express": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
@@ -4762,6 +4966,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -4769,6 +4979,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-diff": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
"integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
"license": "Apache-2.0"
},
"node_modules/fast-json-stable-stringify": { "node_modules/fast-json-stable-stringify": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -4983,6 +5199,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/functions-have-names": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/generic-pool": { "node_modules/generic-pool": {
"version": "3.9.0", "version": "3.9.0",
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
@@ -5132,6 +5357,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": { "node_modules/has-symbols": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -5144,6 +5381,21 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hashery": { "node_modules/hashery": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz",
@@ -5168,6 +5420,32 @@
"node": ">= 0.4" "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": { "node_modules/hookified": {
"version": "1.15.1", "version": "1.15.1",
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
@@ -5282,6 +5560,22 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/is-arguments": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-arrayish": { "node_modules/is-arrayish": {
"version": "0.2.1", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
@@ -5289,6 +5583,22 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/is-date-object": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
"integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-fullwidth-code-point": { "node_modules/is-fullwidth-code-point": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -5315,6 +5625,24 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"gopd": "^1.2.0",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-unicode-supported": { "node_modules/is-unicode-supported": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
@@ -5369,6 +5697,12 @@
"jiti": "lib/jiti-cli.mjs" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5538,6 +5872,15 @@
"leaflet": "^1.3.1" "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": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -5843,7 +6186,12 @@
"version": "4.18.1", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true, "license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.clonedeep": { "node_modules/lodash.clonedeep": {
@@ -5864,6 +6212,13 @@
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lodash.isinteger": { "node_modules/lodash.isinteger": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
@@ -6221,6 +6576,15 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/nodemailer": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -6251,6 +6615,31 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/object-is": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -6318,6 +6707,12 @@
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)" "license": "(MIT AND Zlib)"
}, },
"node_modules/parchment": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
"license": "BSD-3-Clause"
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -6582,6 +6977,15 @@
"node": ">=4" "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": { "node_modules/postcss": {
"version": "8.5.15", "version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -6695,6 +7099,49 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/quill": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
"integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
"license": "BSD-3-Clause",
"dependencies": {
"clone": "^2.1.1",
"deep-equal": "^1.0.1",
"eventemitter3": "^2.0.3",
"extend": "^3.0.2",
"parchment": "^1.1.4",
"quill-delta": "^3.6.2"
}
},
"node_modules/quill-delta": {
"version": "3.6.3",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
"integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
"license": "MIT",
"dependencies": {
"deep-equal": "^1.0.1",
"extend": "^3.0.2",
"fast-diff": "1.1.2"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/quill/node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/quill/node_modules/eventemitter3": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
"license": "MIT"
},
"node_modules/raf": { "node_modules/raf": {
"version": "3.4.1", "version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
@@ -6783,6 +7230,21 @@
"react-leaflet": "^4.0.0" "react-leaflet": "^4.0.0"
} }
}, },
"node_modules/react-quill": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-quill/-/react-quill-2.0.0.tgz",
"integrity": "sha512-4qQtv1FtCfLgoD3PXAur5RyxuUbPXQGOHgTlFie3jtxp43mXDtzCKaOgQ3mLyZfi1PUlyjycfivKelFhy13QUg==",
"license": "MIT",
"dependencies": {
"@types/quill": "^1.3.10",
"lodash": "^4.17.4",
"quill": "^1.3.7"
},
"peerDependencies": {
"react": "^16 || ^17 || ^18",
"react-dom": "^16 || ^17 || ^18"
}
},
"node_modules/readable-stream": { "node_modules/readable-stream": {
"version": "3.6.2", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -6913,6 +7375,26 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
"integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"define-properties": "^1.2.1",
"es-errors": "^1.3.0",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"set-function-name": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -7194,6 +7676,38 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/set-function-name": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
"integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
"functions-have-names": "^1.2.3",
"has-property-descriptors": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+2 -1
View File
@@ -21,6 +21,7 @@
}, },
"dependencies": { "dependencies": {
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8" "jspdf-autotable": "^5.0.8",
"react-quill": "^2.0.0"
} }
} }