Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e34d197dd0 | |||
| fdf41e05fc | |||
| ae74035ad5 | |||
| ac11bb56db | |||
| 5081546cdf | |||
| 9da8a9494d | |||
| 4373ed684a | |||
| def0e0d0f9 | |||
| 36e8658dad | |||
| 6c1b77d7d2 | |||
| 4640526e4b | |||
| c7692ab9b6 | |||
| 36fbbb9386 | |||
| ef11309399 | |||
| 6f2dd0fc01 | |||
| 623f0164c8 | |||
| 88b47099b1 | |||
| 659d2a0840 | |||
| 68950dba10 | |||
| 3e215ae8e8 | |||
| e7b8c672e0 | |||
| 2462850e2e | |||
| abb61f65cd | |||
| da32d59161 | |||
| 40b1c4f2ab | |||
| 55de887f87 | |||
| ffe3cb6ecd | |||
| 69c67a1636 | |||
| f181009fa5 | |||
| b6551da147 | |||
| 2afb2971da | |||
| 14ffbb8657 | |||
| fbe5ef873b | |||
| d6ee1dde74 | |||
| 288b40ad12 | |||
| 3a3296c340 | |||
| e664a3797e | |||
| 36418673db | |||
| 98e4a4a340 | |||
| bfbbb66747 | |||
| 1933b58f84 | |||
| 05dc80bb6d | |||
| 4d63bff214 | |||
| c5474f1ff6 | |||
| 7ad785fed9 | |||
| e66f242c2c |
@@ -1,3 +1,4 @@
|
|||||||
{
|
{
|
||||||
"continue.enableConsole": true
|
"continue.enableConsole": true,
|
||||||
|
"remote.autoForwardPortsFallback": 0
|
||||||
}
|
}
|
||||||
@@ -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 {};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
|
||||||
|
> backend@0.0.1 start:dev
|
||||||
|
> nest start --watch
|
||||||
|
|
||||||
|
[2J[3J[H[[90m10:46:05 PM[0m] Starting compilation in watch mode...
|
||||||
|
|
||||||
|
[[90m10:46:08 PM[0m] Found 0 errors. Watching for file changes.
|
||||||
|
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[NestFactory] [39m[32mStarting Nest application...[39m
|
||||||
|
--- [PRISMA CHECK] ---
|
||||||
|
DATABASE_URL nhận được: ĐÃ ĐỌC THÀNH CÔNG ✔️
|
||||||
|
----------------------
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mConfigHostModule dependencies initialized[39m[38;5;3m +40ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mJwtModule dependencies initialized[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mConfigModule dependencies initialized[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mCacheModule dependencies initialized[39m[38;5;3m +15ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mAppModule dependencies initialized[39m[38;5;3m +2ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[WebSocketsController] [39m[32mCommentGateway subscribed to the "joinTour" message[39m[38;5;3m +11ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[WebSocketsController] [39m[32mCommentGateway subscribed to the "joinPhoto" message[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mAppController {/api/v1}:[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1, GET} route[39m[38;5;3m +2ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mAuthController {/api/v1/auth}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/convert-guest, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/create-guest, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/status, GET} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/login, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/signup/request, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/signup/verify, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mPublicTourController {/api/v1/tours}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:id/public, GET} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mTourController {/api/v1/tours}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/locations, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/start-point, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/end-point, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/legs/batch, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/legs, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:id, PATCH} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:id, DELETE} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/explore, GET} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:id, GET} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/members, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/join-requests, GET} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/join-requests, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/join-requests/:requestId/accept, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/join-requests/:requestId/reject, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/members/:userId, DELETE} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/photos, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mUserController {/api/v1/users}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users, GET} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users/me/photos, GET} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users/:id, PATCH} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users/:id, DELETE} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users/block/:id, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mRoutingController {/api/v1/routing}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/routing/optimize/:legId, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mLegController {/api/v1/legs}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/legs/:id, PATCH} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/legs/:id, DELETE} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mLocationController {/api/v1/locations}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/locations/:id, PATCH} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/locations/:id, DELETE} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mCommentController {/api/v1/locations}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/locations/:locationId/comments, GET} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/locations/:locationId/comments, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mPhotoController {/api/v1/photos}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/photos/upload-anonymous, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/photos/:id, DELETE} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mPublicPhotoController {/api/v1/public-photos}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/public-photos, GET} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/public-photos/:photoId/comments, GET} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/public-photos/:photoId/comments, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mAdminOtpController {/api/v1/admin/otp}:[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/admin/otp/send, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/admin/otp/verify, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[NestApplication] [39m[32mNest application successfully started[39m[38;5;3m +16ms[39m
|
||||||
|
🚀 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
|
||||||
@@ -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;
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
Before Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 378 KiB |
|
Before Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 225 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
|
After Width: | Height: | Size: 254 KiB |
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,19 +288,41 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const payload: any = {
|
if (isStartPoint) {
|
||||||
...formData,
|
await updateTourStartPoint(tourId, {
|
||||||
expenseAmount: formData.expenseAmount.replace(/\./g, ''), // Loại bỏ dấu chấm trước khi gửi
|
name: formData.name || "Điểm xuất phát",
|
||||||
legId: currentLegId,
|
latitude: parseFloat(formData.latitude as any),
|
||||||
latitude: parseFloat(formData.latitude as any),
|
longitude: parseFloat(formData.longitude as any),
|
||||||
longitude: parseFloat(formData.longitude as any),
|
plannedEnd: formData.plannedStart,
|
||||||
};
|
});
|
||||||
|
} else if (isEndPoint) {
|
||||||
if (editingLocation) {
|
await updateTourEndPoint(tourId, {
|
||||||
await updateLocation(editingLocation.id, payload);
|
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 {
|
} else {
|
||||||
await addLocation(tourId, payload);
|
const payload: any = {
|
||||||
|
...formData,
|
||||||
|
expenseAmount: formData.expenseAmount.replace(/\./g, ''), // Loại bỏ dấu chấm trước khi gửi
|
||||||
|
legId: currentLegId,
|
||||||
|
latitude: parseFloat(formData.latitude as any),
|
||||||
|
longitude: parseFloat(formData.longitude as any),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingLocation) {
|
||||||
|
await updateLocation(editingLocation.id, payload);
|
||||||
|
} else {
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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,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='© <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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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)
|
|
||||||
|
const distanceFromPrev = prevLocation
|
||||||
|
? calculateDistance(prevLocation.latitude, prevLocation.longitude, location.latitude, location.longitude)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const averageSpeed = 35; // km/h
|
|
||||||
const travelTimeMinutes = distanceToNext ? Math.round((distanceToNext / averageSpeed) * 60) : null;
|
|
||||||
|
|
||||||
const dwellMinutes = (location.plannedStart && location.plannedEnd)
|
|
||||||
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
|
||||||
: 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">
|
||||||
<button
|
<div className="flex gap-1 mb-2">
|
||||||
onClick={() => {
|
{onQuickNote && !isPublicView && (
|
||||||
setCommentLocationId(location.id);
|
<button
|
||||||
setCommentLocationName(location.name);
|
onClick={() => onQuickNote(location.name)}
|
||||||
setIsCommentModalOpen(true);
|
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"
|
||||||
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"
|
>
|
||||||
>
|
<FileText className="w-3 h-3" />
|
||||||
<MessageSquare className="w-3 h-3" />
|
</button>
|
||||||
BÌNH LUẬN {location._count?.comments > 0 && `(${location._count.comments})`}
|
)}
|
||||||
</button>
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setCommentLocationId(location.id);
|
||||||
|
setCommentLocationName(location.name);
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3 h-3" />
|
||||||
|
{location._count?.comments > 0 && `(${location._count.comments})`}
|
||||||
|
</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">Mô 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">Vĩ độ</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 có 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 có mô 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 có bình luận nào. Hãy bắt đầu cuộc trò chuyện!</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
comments.map((c) => {
|
||||||
|
return (
|
||||||
|
<div key={c.id} className="flex gap-3 items-start animate-in fade-in slide-in-from-bottom-2 duration-200">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center flex-shrink-0 border border-slate-700/60">
|
||||||
|
<User className="w-4.5 h-4.5 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="bg-slate-800/55 p-3.5 rounded-2xl rounded-tl-none border border-slate-800 shadow-lg">
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<span className="text-xs font-black text-slate-200 truncate">{c.userName}</span>
|
||||||
|
<span className="text-[9px] font-medium text-slate-500">
|
||||||
|
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-300 leading-relaxed break-words">{c.content}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
<div ref={commentsEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment Input Area */}
|
||||||
|
<div className="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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,89 +146,268 @@ 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 lý 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 có quyền thêm, sửa, xóa hoặc khóa tài khoản.</p>
|
<p className="text-sm text-gray-500">Quản lý thành viên và các tài nguyên công cộng của ứng dụng.</p>
|
||||||
</div>
|
</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="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
|
<div className="p-4 bg-red-50 text-red-600 rounded-xl font-bold mb-4">{error}</div>
|
||||||
) : error ? (
|
)}
|
||||||
<div className="p-4 bg-red-50 text-red-600 rounded-xl font-bold">{error}</div>
|
|
||||||
) : (
|
{activeTab === 'users' ? (
|
||||||
<table className="w-full text-left border-collapse">
|
loading ? (
|
||||||
<thead>
|
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
|
||||||
<tr className="text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100">
|
) : (
|
||||||
<th className="pb-4 font-bold px-2">Người dùng</th>
|
<table className="w-full text-left border-collapse">
|
||||||
<th className="pb-4 font-bold">Vai trò</th>
|
<thead>
|
||||||
<th className="pb-4 font-bold">Trạng thái</th>
|
<tr className="text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100">
|
||||||
<th className="pb-4 font-bold text-right">Thao tác</th>
|
<th className="pb-4 font-bold px-2">Người dùng</th>
|
||||||
</tr>
|
<th className="pb-4 font-bold">Vai trò</th>
|
||||||
</thead>
|
<th className="pb-4 font-bold">Trạng thái</th>
|
||||||
<tbody className="divide-y divide-gray-50">
|
<th className="pb-4 font-bold text-right">Thao tác</th>
|
||||||
{users.map(u => (
|
|
||||||
<tr key={u.id} className="group hover:bg-gray-50/50 transition-colors">
|
|
||||||
<td className="py-4 px-2">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
|
|
||||||
{u.name?.charAt(0) || <User className="w-5 h-5" />}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-bold text-gray-900">{u.name || 'N/A'}</div>
|
|
||||||
<div className="text-xs text-gray-400">{u.email}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-4">
|
|
||||||
{u.isAdmin ? (
|
|
||||||
<span className="px-2 py-1 bg-purple-50 text-purple-600 text-[10px] font-black rounded-md border border-purple-100">ADMIN</span>
|
|
||||||
) : (
|
|
||||||
<span className="px-2 py-1 bg-gray-50 text-gray-500 text-[10px] font-black rounded-md border border-gray-100">USER</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-4">
|
|
||||||
{u.isBlocked ? (
|
|
||||||
<span className="flex items-center gap-1 text-red-500 text-xs font-bold"><ShieldAlert className="w-3 h-3" /> Đã khóa</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-green-500 text-xs font-bold">Đang hoạt động</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-4 text-right">
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggleBlock(u.id)}
|
|
||||||
className={`p-2 rounded-xl transition-all ${u.isBlocked ? 'bg-green-50 text-green-600 hover:bg-green-100' : 'bg-amber-50 text-amber-600 hover:bg-amber-100'}`}
|
|
||||||
title={u.isBlocked ? 'Mở khóa' : 'Khóa tài khoản'}
|
|
||||||
>
|
|
||||||
{u.isBlocked ? <Unlock className="w-4 h-4" /> : <Lock className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(u.id)}
|
|
||||||
className="p-2 bg-red-50 text-red-600 rounded-xl hover:bg-red-100 transition-all"
|
|
||||||
title="Xóa người dùng"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-50">
|
||||||
|
{users.map(u => (
|
||||||
|
<tr key={u.id} className="group hover:bg-gray-50/50 transition-colors">
|
||||||
|
<td className="py-4 px-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
|
||||||
|
{u.name?.charAt(0) || <User className="w-5 h-5" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-gray-900">{u.name || 'N/A'}</div>
|
||||||
|
<div className="text-xs text-gray-400">{u.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
{u.isAdmin ? (
|
||||||
|
<span className="px-2 py-1 bg-purple-50 text-purple-600 text-[10px] font-black rounded-md border border-purple-100">ADMIN</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-1 bg-gray-50 text-gray-500 text-[10px] font-black rounded-md border border-gray-100">USER</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
{u.isBlocked ? (
|
||||||
|
<span className="flex items-center gap-1 text-red-500 text-xs font-bold"><ShieldAlert className="w-3 h-3" /> Đã khóa</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-green-500 text-xs font-bold">Đang hoạt động</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleBlock(u.id)}
|
||||||
|
className={`p-2 rounded-xl transition-all ${u.isBlocked ? 'bg-green-50 text-green-600 hover:bg-green-100' : 'bg-amber-50 text-amber-600 hover:bg-amber-100'}`}
|
||||||
|
title={u.isBlocked ? 'Mở khóa' : 'Khóa tài khoản'}
|
||||||
|
>
|
||||||
|
{u.isBlocked ? <Unlock className="w-4 h-4" /> : <Lock className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(u.id)}
|
||||||
|
className="p-2 bg-red-50 text-red-600 rounded-xl hover:bg-red-100 transition-all"
|
||||||
|
title="Xóa người dùng"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
) : 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 có ảnh công cộng nào được tải lên.</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6">
|
||||||
|
{photos.map(p => (
|
||||||
|
<div key={p.id} className="relative group bg-gray-50 border border-gray-100 rounded-2xl overflow-hidden shadow-sm hover:shadow-md transition-all flex flex-col justify-between">
|
||||||
|
<div className="aspect-square bg-slate-900 overflow-hidden flex items-center justify-center relative">
|
||||||
|
<img src={p.imageUrl} alt="Public content" className="w-full h-full object-cover transition-transform group-hover:scale-105" />
|
||||||
|
{/* Delete button shown on hover/focus */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeletePhoto(p.id)}
|
||||||
|
className="absolute top-2 right-2 p-2 bg-red-600 hover:bg-red-500 text-white rounded-xl shadow-lg transition-all active:scale-95 opacity-0 group-hover:opacity-100 focus:opacity-100 z-10"
|
||||||
|
title="Xóa ảnh công cộng"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-white">
|
||||||
|
<div className="font-bold text-xs text-gray-800 truncate" title={p.uploader?.name || 'Ẩn danh'}>
|
||||||
|
Đăng bởi: {p.uploader?.name || 'Ẩn danh'}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-gray-400 mt-1">
|
||||||
|
{new Date(p.capturedAt).toLocaleDateString('vi-VN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
)
|
||||||
|
) : (
|
||||||
|
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 có ả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">Lý 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>
|
||||||
|
|||||||
@@ -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='© 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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 && (
|
||||||
|
<img
|
||||||
|
key={bg1}
|
||||||
|
src={bg1}
|
||||||
|
className="absolute inset-y-0 left-0 h-full w-[120%] max-w-none object-cover transition-opacity duration-[1200ms] ease-in-out animate-panning"
|
||||||
|
style={{
|
||||||
|
opacity: fade1 ? 0.8 : 0,
|
||||||
|
}}
|
||||||
|
alt="Travel Background 1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Slot 2 */}
|
||||||
|
{bg2 && (
|
||||||
|
<img
|
||||||
|
key={bg2}
|
||||||
|
src={bg2}
|
||||||
|
className="absolute inset-y-0 left-0 h-full w-[120%] max-w-none object-cover transition-opacity duration-[1200ms] ease-in-out animate-panning"
|
||||||
|
style={{
|
||||||
|
opacity: fade2 ? 0.8 : 0,
|
||||||
|
}}
|
||||||
|
alt="Travel Background 2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nửa bên trái: Hiển thị một hình ảnh duy nhất */}
|
{/* Keyframes cho hiệu ứng panning từ trái sang phải */}
|
||||||
<div className="hidden md:block md:w-1/2 relative h-full bg-blue-900">
|
<style>{`
|
||||||
<img
|
@keyframes pan-left-to-right {
|
||||||
src={backgroundImage}
|
0% {
|
||||||
className="w-full h-full object-cover opacity-70"
|
transform: translateX(0) translateZ(0);
|
||||||
alt="Travel Background"
|
}
|
||||||
/>
|
100% {
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-black/40 to-transparent pointer-events-none" />
|
transform: translateX(-16.667%) translateZ(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-panning {
|
||||||
|
animation: pan-left-to-right 13500ms linear forwards;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
{/* Thumbnail Grid ở góc dưới bên trái */}
|
{/* Top Bar - Thanh điều hướng trên cùng */}
|
||||||
<div className="absolute bottom-12 left-8 z-20 flex flex-col gap-2 items-start drop-shadow-2xl">
|
<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">
|
||||||
{/* Hàng trên cùng: 1 thumbnail */}
|
<div className="flex items-center gap-2 text-white drop-shadow-lg">
|
||||||
<div className="flex gap-2">
|
<Compass className="w-8 h-8" />
|
||||||
<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" />
|
<span className="text-xl font-black tracking-tighter uppercase hidden sm:block">Travel Planner</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsLoginModalOpen(true)}
|
||||||
|
className="flex items-center justify-center gap-2 bg-white/10 backdrop-blur-md text-white font-bold py-2 px-4 rounded-full border border-white/20 hover:bg-white/20 transition-all"
|
||||||
|
>
|
||||||
|
<LogIn className="w-4 h-4" />
|
||||||
|
<span className="text-sm">Đăng nhập</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body: Animation người lữ hành */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center z-10" >
|
||||||
|
{/* Kích hoạt click vào file input để chọn hoặc chụp ảnh */}
|
||||||
|
<TravelerAnimation onClick={() => fileInputRef.current?.click()} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input chọn file ẩn để chụp/chọn ảnh */}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Community Gallery Previews */}
|
||||||
|
{publicPhotos.length > 0 && (
|
||||||
|
<div className="absolute bottom-[calc(7rem+env(safe-area-inset-bottom,0px))] left-0 right-0 z-20 px-4 flex flex-col items-center gap-2">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-white/70 drop-shadow-md">
|
||||||
|
Khoảnh khắc từ cộng đồng ({publicPhotos.length})
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-3 overflow-x-auto max-w-full no-scrollbar pb-2 px-4 justify-center">
|
||||||
|
{publicPhotos.slice(0, 8).map((photo, index) => (
|
||||||
|
<button
|
||||||
|
key={photo.id}
|
||||||
|
onClick={() => setCurrentBgIndex(index)}
|
||||||
|
className={`relative w-14 h-14 rounded-xl overflow-hidden border-2 transition-all active:scale-95 shrink-0 ${
|
||||||
|
currentBgIndex === index ? 'border-emerald-500 scale-110 shadow-lg' : 'border-white/20 hover:border-white/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img src={photo.imageUrl} alt="Community thumbnail" className="w-full h-full object-cover" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hàng thứ 2: 2 thumbnail */}
|
<style>{`
|
||||||
<div className="flex gap-2">
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
<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" />
|
display: none;
|
||||||
<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>
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
{/* Hàng dưới cùng: 3 thumbnail */}
|
scrollbar-width: none;
|
||||||
<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" />
|
`}</style>
|
||||||
<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>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Nửa bên phải: Nội dung chào mừng và Hành động */}
|
{/* Bottom Bar - Nút hành động chính */}
|
||||||
<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="absolute bottom-[calc(2rem+env(safe-area-inset-bottom,0px))] left-1/2 -translate-x-1/2 z-20 w-full px-4">
|
||||||
<div className="max-w-md w-full space-y-12">
|
<button
|
||||||
<div className="space-y-4">
|
onClick={onGoToMap}
|
||||||
{isInitialSetup ? (
|
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"
|
||||||
<>
|
>
|
||||||
<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">
|
<MapIcon className="w-5 h-5" />
|
||||||
<ShieldCheck className="w-3 h-3 mr-1" /> CẤU HÌNH HỆ THỐNG LẦN ĐẦU
|
Khám phá các hành trình du lịch
|
||||||
</div>
|
</button>
|
||||||
<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 lý và 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 className="grid grid-cols-1 gap-4">
|
|
||||||
{isInitialSetup && (
|
|
||||||
<button
|
|
||||||
onClick={onGoToSignup}
|
|
||||||
className="flex items-center justify-center gap-3 bg-blue-600 hover:bg-blue-700 text-white font-bold py-5 px-8 rounded-2xl transition-all shadow-xl shadow-blue-100 animate-bounce-subtle mb-2"
|
|
||||||
>
|
|
||||||
<UserPlus className="w-5 h-5" />
|
|
||||||
Bắt đầu thiết lập ngay
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onGoToMap}
|
|
||||||
className="flex items-center justify-center gap-3 bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-4 px-8 rounded-2xl transition-all shadow-lg shadow-indigo-100"
|
|
||||||
>
|
|
||||||
<MapIcon className="w-5 h-5" />
|
|
||||||
Khám phá các hành trình du lịch
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsLoginModalOpen(true)}
|
|
||||||
className="flex items-center justify-center gap-2 bg-blue-50 hover:bg-blue-100 text-blue-700 font-bold py-4 rounded-2xl transition-all"
|
|
||||||
>
|
|
||||||
<LogIn className="w-5 h-5" />
|
|
||||||
Đăng nhập
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onGoToSignup}
|
|
||||||
className="flex items-center justify-center gap-2 bg-gray-50 hover:bg-gray-100 text-gray-700 font-bold py-4 rounded-2xl border border-gray-100 transition-all"
|
|
||||||
>
|
|
||||||
<UserPlus className="w-5 h-5" />
|
|
||||||
Đăng ký
|
|
||||||
</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 */}
|
||||||
|
|||||||
@@ -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 cá 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,44 +353,195 @@ 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">
|
||||||
<img
|
<div className="relative overflow-hidden rounded-xl shadow-md max-w-full max-h-[calc(100vh-350px)] group">
|
||||||
src={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl}
|
<img
|
||||||
alt="Selected Photo"
|
src={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl}
|
||||||
className="max-w-full max-h-[calc(100vh-350px)] object-contain rounded-xl shadow-md"
|
alt="Selected Photo"
|
||||||
/>
|
className="max-w-full max-h-[calc(100vh-350px)] object-contain cursor-zoom-in"
|
||||||
<button
|
onClick={() => setIsFullscreen(true)}
|
||||||
onClick={(e) => {
|
/>
|
||||||
e.stopPropagation();
|
|
||||||
handleDeletePhoto(selectedPhotoForDisplay.id);
|
|
||||||
}}
|
|
||||||
className="absolute top-4 right-4 p-2 bg-red-500/80 backdrop-blur-sm text-white rounded-full shadow-lg hover:bg-red-600 transition-all active:scale-90"
|
|
||||||
title="Xóa ảnh này"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="mt-6 w-full flex items-center justify-between px-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-2 text-gray-900 font-bold">
|
|
||||||
<MapPin className="w-4 h-4 text-blue-500" />
|
|
||||||
{selectedPhotoForDisplay.tour?.title || 'Không rõ hành trình'}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-gray-400 text-xs font-medium uppercase tracking-wider">
|
|
||||||
<Calendar className="w-3.5 h-3.5" />
|
|
||||||
{new Date(selectedPhotoForDisplay.capturedAt).toLocaleDateString('vi-VN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedPhotoForDisplay.originalUrl && (
|
{/* Overlays (Only show when not editing) */}
|
||||||
<a
|
{!isEditing && (
|
||||||
href={selectedPhotoForDisplay.originalUrl}
|
<>
|
||||||
download
|
{/* Like (Heart) button overlay */}
|
||||||
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"
|
<button
|
||||||
>
|
onClick={handleToggleLike}
|
||||||
<Download className="w-4 h-4" /> Tải xuống ảnh gốc
|
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"
|
||||||
</a>
|
title={isLiked ? "Bỏ thích" : "Thích"}
|
||||||
|
>
|
||||||
|
<Heart className={`w-4 h-4 transition-colors ${
|
||||||
|
isLiked ? 'text-rose-500 fill-rose-500 animate-in zoom-in-75' : 'text-gray-300 hover:text-rose-450'
|
||||||
|
}`} />
|
||||||
|
<span>{likeCount}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Delete button overlay */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeletePhoto(selectedPhotoForDisplay.id);
|
||||||
|
}}
|
||||||
|
className="absolute top-4 right-4 z-10 p-2 bg-black/60 hover:bg-red-600 border border-white/10 text-white rounded-full transition-all active:scale-95 backdrop-blur-md"
|
||||||
|
title="Xóa ảnh này"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Bottom metadata details gradient panel overlay */}
|
||||||
|
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent p-6 text-white flex flex-col gap-2 text-left">
|
||||||
|
<div className="flex justify-between items-start gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{selectedPhotoForDisplay.metadata?.title ? (
|
||||||
|
<h3 className="text-base font-extrabold text-white break-words drop-shadow-md">
|
||||||
|
{selectedPhotoForDisplay.metadata.title}
|
||||||
|
</h3>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-350 italic block mb-1 drop-shadow-md">Chưa có 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 có mô tả</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Button Overlay */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className="p-2 bg-white/10 hover:bg-white/20 border border-white/15 rounded-xl text-white hover:text-gray-200 transition-all shrink-0 backdrop-blur-sm"
|
||||||
|
title="Chỉnh sửa thông tin"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional metadata info inside bottom overlay */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-white/10 pt-3 text-xs text-gray-200">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-gray-300 text-[10px] font-bold uppercase tracking-wider">
|
||||||
|
<Calendar className="w-3.5 h-3.5" />
|
||||||
|
Ngày chụp: {new Date(selectedPhotoForDisplay.capturedAt).toLocaleDateString('vi-VN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 font-bold" title={selectedPhotoForDisplay.metadata?.lat && selectedPhotoForDisplay.metadata?.lng ? `${selectedPhotoForDisplay.metadata.lat.toFixed(6)}, ${selectedPhotoForDisplay.metadata.lng.toFixed(6)}` : ''}>
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-rose-500" />
|
||||||
|
Địa điểm: {resolvedAddress}
|
||||||
|
</div>
|
||||||
|
{selectedPhotoForDisplay.tour?.title && (
|
||||||
|
<div className="text-[10px] text-gray-400">
|
||||||
|
Hành trình: {selectedPhotoForDisplay.tour.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedPhotoForDisplay.originalUrl && (
|
||||||
|
<a
|
||||||
|
href={selectedPhotoForDisplay.originalUrl}
|
||||||
|
download
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold uppercase tracking-widest text-[9px] transition-all shadow-md active:scale-95 shrink-0"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5 animate-pulse" /> Tải ảnh gốc
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">Mô 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">Vĩ độ</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>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center text-gray-400 py-20">
|
<div className="text-center text-gray-400 py-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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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,39 +15,67 @@ 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('');
|
||||||
|
|
||||||
if (formData.password !== formData.confirmPassword) {
|
|
||||||
setError('Mật khẩu xác nhận không khớp');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/auth/signup`, {
|
if (step === 'form') {
|
||||||
method: 'POST',
|
if (formData.password !== formData.confirmPassword) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
setError('Mật khẩu xác nhận không khớp');
|
||||||
body: JSON.stringify({
|
setIsLoading(false);
|
||||||
email: formData.email,
|
return;
|
||||||
password: formData.password,
|
}
|
||||||
name: formData.name,
|
|
||||||
phone: formData.phone || undefined,
|
|
||||||
address: formData.address || undefined
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
const response = await fetch(`/api/v1/auth/signup/request`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
name: formData.name,
|
||||||
|
phone: formData.phone || undefined,
|
||||||
|
address: formData.address || undefined
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.message || 'Yêu cầu đăng ký thất bại');
|
||||||
|
|
||||||
|
setStep('otp');
|
||||||
|
} else {
|
||||||
|
// Xác thực mã OTP bước hoàn tất
|
||||||
|
const guestUserStr = localStorage.getItem('guest_user');
|
||||||
|
const guestUser = guestUserStr ? JSON.parse(guestUserStr) : null;
|
||||||
|
const guestId = guestUser ? guestUser.id : undefined;
|
||||||
|
|
||||||
if (!response.ok) {
|
const response = await fetch(`/api/v1/auth/signup/verify`, {
|
||||||
throw new Error(data.message || 'Đăng ký thất bại');
|
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,139 +84,155 @@ 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" />
|
||||||
/>
|
</button>
|
||||||
<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 bè.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Nửa bên phải: Form đăng ký */}
|
<div className="mb-10">
|
||||||
<div className="flex-1 flex flex-col justify-center px-8 sm:px-16 lg:px-24 py-12 overflow-y-auto">
|
<h1 className="text-3xl font-extrabold text-gray-900">
|
||||||
<div className="max-w-md w-full mx-auto">
|
{step === 'form' ? 'Tạo tài khoản mới' : 'Xác thực tài khoản'}
|
||||||
<button onClick={onBack} className="flex items-center text-gray-400 hover:text-blue-600 mb-8 transition-colors font-medium">
|
</h1>
|
||||||
<ChevronLeft className="w-5 h-5" /> Quay lại
|
<p className="text-gray-500 mt-2 font-medium">
|
||||||
</button>
|
{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 className="mb-10">
|
{error && (
|
||||||
<h1 className="text-3xl font-extrabold text-gray-900">Tạo tài khoản mới</h1>
|
<div className="mb-6 p-4 bg-red-50 text-red-600 rounded-2xl text-sm font-bold animate-in fade-in slide-in-from-top-1">
|
||||||
<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>
|
{error}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
<form className="space-y-5" onSubmit={handleSubmit}>
|
||||||
<div className="mb-6 p-4 bg-red-50 text-red-600 rounded-2xl text-sm font-bold animate-in fade-in slide-in-from-top-1">
|
{step === 'form' ? (
|
||||||
{error}
|
<>
|
||||||
</div>
|
<div className="space-y-1.5">
|
||||||
)}
|
<label className="text-sm font-bold text-gray-700 ml-1">Họ và tên</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<User 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="text"
|
||||||
|
placeholder="Nhập họ và tên của bạn"
|
||||||
|
value={formData.name}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form className="space-y-5" onSubmit={handleSubmit}>
|
<div className="space-y-1.5">
|
||||||
<div className="space-y-1.5">
|
<label className="text-sm font-bold text-gray-700 ml-1">Email</label>
|
||||||
<label className="text-sm font-bold text-gray-700 ml-1">Họ và tên</label>
|
<div className="relative group">
|
||||||
|
<Mail 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="email"
|
||||||
|
placeholder="Nhập email của bạn"
|
||||||
|
value={formData.email}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<label className="text-sm font-bold text-gray-700 ml-1">Số điện thoại</label>
|
||||||
|
<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" />
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
placeholder="Nhập số điện thoại"
|
||||||
|
value={formData.phone}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-bold text-gray-700 ml-1">Địa chỉ</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<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
|
||||||
|
type="text"
|
||||||
|
placeholder="Nhập địa chỉ"
|
||||||
|
value={formData.address}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-bold text-gray-700 ml-1">Mã xác thực OTP</label>
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<User 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" />
|
<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="text"
|
type="text"
|
||||||
placeholder="Nguyễn Văn A"
|
maxLength={6}
|
||||||
value={formData.name}
|
placeholder="Nhập 6 số mã OTP"
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
value={otp}
|
||||||
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"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<button
|
||||||
<label className="text-sm font-bold text-gray-700 ml-1">Email</label>
|
disabled={isLoading}
|
||||||
<div className="relative group">
|
type="submit"
|
||||||
<Mail 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" />
|
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"
|
||||||
<input
|
>
|
||||||
required
|
{isLoading ? 'Đang xử lý...' : step === 'form' ? 'Tạo tài khoản' : 'Xác nhận kích hoạt'}
|
||||||
type="email"
|
{!isLoading && <ArrowRight className="w-5 h-5" />}
|
||||||
placeholder="email@example.com"
|
</button>
|
||||||
value={formData.email}
|
</form>
|
||||||
onChange={(e) => setFormData({ ...formData, 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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-sm font-bold text-gray-700 ml-1">Số điện thoại</label>
|
|
||||||
<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" />
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
placeholder="0912 345 678"
|
|
||||||
value={formData.phone}
|
|
||||||
onChange={(e) => setFormData({ ...formData, 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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-sm font-bold text-gray-700 ml-1">Địa chỉ</label>
|
|
||||||
<div className="relative group">
|
|
||||||
<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
|
|
||||||
type="text"
|
|
||||||
placeholder="Quận 1, TP.HCM"
|
|
||||||
value={formData.address}
|
|
||||||
onChange={(e) => setFormData({ ...formData, 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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm: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>
|
|
||||||
<input
|
|
||||||
required
|
|
||||||
type="password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
value={formData.password}
|
|
||||||
onChange={(e) => setFormData({ ...formData, password: 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 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>
|
|
||||||
|
|
||||||
<button
|
|
||||||
disabled={isLoading}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
{isLoading ? 'Đang xử lý...' : 'Tạo tài khoản'}
|
|
||||||
{!isLoading && <ArrowRight className="w-5 h-5" />}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default SignupPage;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||