Compare commits
14 Commits
6f2dd0fc01
...
e34d197dd0
| Author | SHA1 | Date | |
|---|---|---|---|
| e34d197dd0 | |||
| fdf41e05fc | |||
| ae74035ad5 | |||
| ac11bb56db | |||
| 5081546cdf | |||
| 9da8a9494d | |||
| 4373ed684a | |||
| def0e0d0f9 | |||
| 36e8658dad | |||
| 6c1b77d7d2 | |||
| 4640526e4b | |||
| c7692ab9b6 | |||
| 36fbbb9386 | |||
| ef11309399 |
@@ -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);
|
||||
validate(payload: any): Promise<{
|
||||
id: string;
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
email: string | null;
|
||||
passwordHash: string | null;
|
||||
name: string | null;
|
||||
phone: string | null;
|
||||
address: string | null;
|
||||
@@ -14,6 +14,7 @@ export declare class JwtStrategy extends JwtStrategy_base {
|
||||
createdAt: Date;
|
||||
isAdmin: boolean;
|
||||
isBlocked: boolean;
|
||||
isAnonymous: boolean;
|
||||
}>;
|
||||
}
|
||||
export {};
|
||||
|
||||
@@ -24,5 +24,7 @@ export declare class CommentGateway implements OnGatewayConnection {
|
||||
server: Server;
|
||||
handleConnection(client: Socket): void;
|
||||
handleJoinTour(client: Socket, tourId: string): void;
|
||||
handleJoinPhoto(client: Socket, photoId: string): void;
|
||||
notifyNewComment(tourId: string, data: any): void;
|
||||
notifyNewPhotoComment(photoId: string, data: any): void;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -34,6 +34,8 @@
|
||||
"cache-manager": "^7.2.8",
|
||||
"cache-manager-redis-yet": "^5.1.5",
|
||||
"dotenv": "^17.4.2",
|
||||
"exifr": "^7.1.3",
|
||||
"heic-convert": "^2.1.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
|
||||
@@ -0,0 +1,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 {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
email String? @unique
|
||||
passwordHash String?
|
||||
name String?
|
||||
phone String?
|
||||
address String?
|
||||
@@ -66,6 +66,7 @@ model User {
|
||||
createdAt DateTime @default(now())
|
||||
isAdmin Boolean @default(false)
|
||||
isBlocked Boolean @default(false)
|
||||
isAnonymous Boolean @default(false)
|
||||
|
||||
createdTours Tour[] @relation("TourCreator")
|
||||
tourParticipations TourParticipant[]
|
||||
@@ -192,14 +193,17 @@ model Photo {
|
||||
tour Tour? @relation(fields: [tourId], references: [id], onDelete: SetNull)
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||
uploader User @relation(fields: [uploaderId], references: [id])
|
||||
comments Comment[]
|
||||
}
|
||||
|
||||
model Comment {
|
||||
id String @id @default(uuid())
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
locationId String
|
||||
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||
locationId String?
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||
photoId String?
|
||||
photo Photo? @relation(fields: [photoId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
@@ -6,8 +6,10 @@ import 'reflect-metadata';
|
||||
import * as zlib from 'zlib';
|
||||
import { promisify } from 'util';
|
||||
import sharp from 'sharp';
|
||||
import exifr from 'exifr';
|
||||
import heicConvert from 'heic-convert';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException, Injectable, UseInterceptors, UploadedFiles, Inject, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Res, Query, ForbiddenException, Injectable, UseInterceptors, UploadedFiles, Inject, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
@@ -127,8 +129,12 @@ export class TourRoleGuard implements CanActivate {
|
||||
tourId = loc.leg.tourId;
|
||||
} else {
|
||||
// Thử xem resourceId có phải là photoId không
|
||||
const photo = await this.prisma.photo.findUnique({ where: { id: resourceId }, select: { tourId: true } });
|
||||
if (photo) tourId = photo.tourId;
|
||||
const photo = await this.prisma.photo.findUnique({ where: { id: resourceId }, select: { tourId: true, uploaderId: true } });
|
||||
if (photo) {
|
||||
if (user && photo.uploaderId === user.id) return true; // Cho phép chủ sở hữu ảnh đi qua
|
||||
if (!photo.tourId) return true; // Cho phép đi qua nếu ảnh không thuộc tour nào (ví dụ ảnh ẩn danh public)
|
||||
tourId = photo.tourId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,6 +268,81 @@ class AuthController {
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache
|
||||
) {}
|
||||
|
||||
@Post('convert-guest')
|
||||
async convertGuestToOfficial(@Body() body: any) {
|
||||
const { guestId, email, password, name } = body;
|
||||
|
||||
if (!guestId || !email || !password) {
|
||||
throw new BadRequestException('Vui lòng cung cấp đủ guestId, email và mật khẩu.');
|
||||
}
|
||||
|
||||
// 1. Kiểm tra xem email đã tồn tại với một tài khoản chính thức khác chưa
|
||||
const existingOfficialUser = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: email,
|
||||
isAnonymous: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingOfficialUser) {
|
||||
throw new BadRequestException('Email này đã được một tài khoản khác sử dụng.');
|
||||
}
|
||||
|
||||
// 2. Tìm tài khoản khách
|
||||
const guestUser = await this.prisma.user.findUnique({
|
||||
where: { id: guestId },
|
||||
});
|
||||
|
||||
if (!guestUser || !guestUser.isAnonymous) {
|
||||
throw new NotFoundException('Không tìm thấy tài khoản khách hoặc tài khoản này đã được chuyển đổi.');
|
||||
}
|
||||
|
||||
// 3. Mã hóa mật khẩu và cập nhật người dùng
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
const updatedUser = await this.prisma.user.update({
|
||||
where: { id: guestId },
|
||||
data: {
|
||||
email: email,
|
||||
passwordHash: passwordHash,
|
||||
name: name || guestUser.name, // Cập nhật tên mới nếu có, nếu không giữ lại tên ẩn danh cũ
|
||||
isAnonymous: false, // Đánh dấu đây là tài khoản chính thức
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Tạo và trả về token để người dùng đăng nhập ngay lập tức
|
||||
const payload = { email: updatedUser.email, sub: updatedUser.id };
|
||||
return {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
email: updatedUser.email,
|
||||
name: updatedUser.name,
|
||||
isAdmin: updatedUser.isAdmin,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post('create-guest')
|
||||
async createGuestUser() {
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
isAnonymous: true,
|
||||
name: `Lữ khách #${Math.floor(1000 + Math.random() * 9000)}`,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = { sub: user.id, isAnonymous: true };
|
||||
return {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
isAnonymous: user.isAnonymous,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
async getStatus() {
|
||||
const userCount = await this.prisma.user.count();
|
||||
@@ -319,8 +400,8 @@ class AuthController {
|
||||
}
|
||||
|
||||
@Post('signup/verify')
|
||||
async signupVerify(@Body() body: { email: string; otp: string }) {
|
||||
const { email, otp } = body;
|
||||
async signupVerify(@Body() body: { email: string; otp: string; guestId?: string }) {
|
||||
const { email, otp, guestId } = body;
|
||||
|
||||
const storedOtp = await this.cacheManager.get<string>(`signup_otp:${email}`);
|
||||
if (!storedOtp || storedOtp !== otp) {
|
||||
@@ -333,15 +414,48 @@ class AuthController {
|
||||
}
|
||||
|
||||
const { password, name, phone, address } = JSON.parse(cachedDataStr);
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
let user;
|
||||
if (guestId) {
|
||||
// Tìm tài khoản khách
|
||||
const guestUser = await this.prisma.user.findUnique({
|
||||
where: { id: guestId }
|
||||
});
|
||||
if (guestUser && guestUser.isAnonymous) {
|
||||
// Kiểm tra xem email này đã được sử dụng bởi một tài khoản chính thức khác chưa
|
||||
const existingUser = await this.prisma.user.findFirst({
|
||||
where: { email, isAnonymous: false }
|
||||
});
|
||||
if (existingUser) {
|
||||
throw new BadRequestException('Email này đã được đăng ký bởi tài khoản khác.');
|
||||
}
|
||||
|
||||
// Cập nhật thông tin của tài khoản khách
|
||||
user = await this.prisma.user.update({
|
||||
where: { id: guestId },
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
name,
|
||||
phone,
|
||||
address,
|
||||
isAnonymous: false,
|
||||
},
|
||||
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
const userCount = await this.prisma.user.count();
|
||||
const shouldBeAdmin = userCount === 0;
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
const user = await this.prisma.user.create({
|
||||
user = await this.prisma.user.create({
|
||||
data: { email, passwordHash, name, phone, address, isAdmin: shouldBeAdmin },
|
||||
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
|
||||
});
|
||||
}
|
||||
|
||||
// Xóa dữ liệu cache sau khi đăng ký thành công
|
||||
await Promise.all([
|
||||
@@ -371,7 +485,13 @@ class PublicTourController {
|
||||
}
|
||||
}
|
||||
},
|
||||
photos: true,
|
||||
photos: {
|
||||
include: {
|
||||
uploader: {
|
||||
select: { id: true, name: true }
|
||||
}
|
||||
}
|
||||
},
|
||||
legs: {
|
||||
orderBy: { sequence: 'asc' },
|
||||
include: {
|
||||
@@ -800,7 +920,13 @@ class TourController {
|
||||
}
|
||||
}
|
||||
},
|
||||
photos: true,
|
||||
photos: {
|
||||
include: {
|
||||
uploader: {
|
||||
select: { id: true, name: true }
|
||||
}
|
||||
}
|
||||
},
|
||||
legs: {
|
||||
orderBy: { sequence: 'asc' },
|
||||
include: {
|
||||
@@ -1055,20 +1181,68 @@ class TourController {
|
||||
if (!fs.existsSync(memberOriginalDir)) fs.mkdirSync(memberOriginalDir, { recursive: true });
|
||||
if (!fs.existsSync(tourDisplayPath)) fs.mkdirSync(tourDisplayPath, { recursive: true });
|
||||
|
||||
// Trích xuất tọa độ GPS dự phòng từ request body gửi từ frontend
|
||||
const bodyLat = req.body.latitude ? parseFloat(req.body.latitude) : undefined;
|
||||
const bodyLng = req.body.longitude ? parseFloat(req.body.longitude) : undefined;
|
||||
|
||||
return Promise.all(files.map(async (file) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const extension = path.extname(file.originalname).toLowerCase() || '.jpg';
|
||||
const filename = `${uniqueSuffix}${extension}`;
|
||||
const originalExtension = path.extname(file.originalname).toLowerCase() || '.jpg';
|
||||
const originalFilename = `${uniqueSuffix}${originalExtension}`;
|
||||
const displayFilename = `${uniqueSuffix}.jpg`;
|
||||
|
||||
const originalFilePath = path.join(memberOriginalDir, filename);
|
||||
const displayFilePath = path.join(tourDisplayPath, filename);
|
||||
const originalFilePath = path.join(memberOriginalDir, originalFilename);
|
||||
const displayFilePath = path.join(tourDisplayPath, displayFilename);
|
||||
|
||||
// 1. Lưu ảnh gốc nguyên bản vào thư mục riêng của thành viên
|
||||
await fs.promises.writeFile(originalFilePath, file.buffer);
|
||||
|
||||
// 2. Xử lý ảnh để hiển thị (Độ phân giải 2K: tối đa 2560px)
|
||||
// 2. Trích xuất GPS từ EXIF
|
||||
let lat: number | undefined;
|
||||
let lng: number | undefined;
|
||||
try {
|
||||
const gps = await exifr.gps(file.buffer);
|
||||
if (gps && typeof gps.latitude === 'number' && typeof gps.longitude === 'number') {
|
||||
lat = gps.latitude;
|
||||
lng = gps.longitude;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[EXIF GPS] Không thể giải nén GPS từ EXIF ảnh:', e.message);
|
||||
}
|
||||
|
||||
// 3. Nếu EXIF không có GPS, dùng GPS dự phòng của thiết bị gửi từ Frontend
|
||||
if (lat === undefined || lng === undefined) {
|
||||
if (bodyLat !== undefined && !isNaN(bodyLat) && bodyLng !== undefined && !isNaN(bodyLng)) {
|
||||
lat = bodyLat;
|
||||
lng = bodyLng;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Nếu vẫn không có vị trí nào, ghim tại vị trí mặc định [10.7769, 106.7009]
|
||||
if (lat === undefined || lng === undefined) {
|
||||
lat = 10.7769;
|
||||
lng = 106.7009;
|
||||
}
|
||||
|
||||
// 5. Xử lý ảnh để hiển thị (Độ phân giải 2K: tối đa 2560px)
|
||||
let processBuffer = file.buffer;
|
||||
const isHeic = file.originalname.toLowerCase().endsWith('.heic') || file.originalname.toLowerCase().endsWith('.heif') || file.mimetype === 'image/heic' || file.mimetype === 'image/heif';
|
||||
if (isHeic) {
|
||||
try {
|
||||
processBuffer = await heicConvert({
|
||||
buffer: file.buffer,
|
||||
format: 'JPEG',
|
||||
quality: 1
|
||||
});
|
||||
console.log(`[HEIC] Converted original HEIC image to JPEG for display`);
|
||||
} catch (e) {
|
||||
console.warn('[HEIC] Failed to convert HEIC to JPEG, attempting to process anyway:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Sử dụng Sharp để resize và tối ưu dung lượng ảnh
|
||||
await sharp(file.buffer)
|
||||
await sharp(processBuffer)
|
||||
.rotate()
|
||||
.resize(2560, 2560, {
|
||||
fit: 'inside', // Giữ nguyên tỷ lệ, không vượt quá khung 2K
|
||||
withoutEnlargement: true // Nếu ảnh nhỏ hơn 2K thì giữ nguyên, không làm vỡ ảnh
|
||||
@@ -1076,14 +1250,18 @@ class TourController {
|
||||
.jpeg({ quality: 85 }) // Tối ưu chất lượng/dung lượng
|
||||
.toFile(displayFilePath);
|
||||
|
||||
// 3. Lưu thông tin vào Database (Lưu cả 2 đường dẫn)
|
||||
// 6. Lưu thông tin vào Database (Lưu cả 2 đường dẫn và metadata GPS)
|
||||
return this.prisma.photo.create({
|
||||
data: {
|
||||
tourId: tourId,
|
||||
uploaderId: uploaderId,
|
||||
imageUrl: `/uploads/tours/${filename}`, // URL ảnh 2K dùng để render
|
||||
originalUrl: `/uploads/members/${uploaderId}/originals/${filename}`, // URL ảnh gốc để tải xuống
|
||||
imageUrl: `/uploads/tours/${displayFilename}`, // URL ảnh 2K dùng để render (luôn là .jpg)
|
||||
originalUrl: `/uploads/members/${uploaderId}/originals/${originalFilename}`, // URL ảnh gốc để tải xuống
|
||||
privacy: 'TOUR_ONLY',
|
||||
metadata: {
|
||||
lat: lat,
|
||||
lng: lng
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
@@ -1371,6 +1549,111 @@ class RoutingController {
|
||||
class PhotoController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
@Post('upload-anonymous')
|
||||
@UseGuards(JwtAuthGuard) // Vẫn dùng Guard để lấy user từ token
|
||||
@UseInterceptors(FilesInterceptor('images', 1)) // Chỉ cho phép 1 ảnh mỗi lần
|
||||
async uploadAnonymousPhoto(@UploadedFiles() files: any[], @Req() req: any) {
|
||||
if (!files || files.length === 0) {
|
||||
throw new BadRequestException('Vui lòng chọn một ảnh.');
|
||||
}
|
||||
|
||||
const uploaderId = req.user.id;
|
||||
const isAnonymous = req.user.isAnonymous;
|
||||
|
||||
// Chỉ người dùng ẩn danh mới được dùng endpoint này
|
||||
if (!isAnonymous) {
|
||||
throw new ForbiddenException('Chỉ người dùng khách mới có thể sử dụng tính năng này.');
|
||||
}
|
||||
|
||||
const file = files[0];
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const originalExtension = path.extname(file.originalname).toLowerCase() || '.jpg';
|
||||
const originalFilename = `${uniqueSuffix}${originalExtension}`;
|
||||
const displayFilename = `${uniqueSuffix}.jpg`;
|
||||
|
||||
const memberOriginalDir = path.join(UPLOAD_ROOT, 'members', uploaderId, 'originals');
|
||||
const displayFilePath = path.join(UPLOAD_ROOT, 'tours', displayFilename);
|
||||
const originalFilePath = path.join(memberOriginalDir, originalFilename);
|
||||
|
||||
// Đảm bảo thư mục lưu trữ ảnh gốc tồn tại
|
||||
if (!fs.existsSync(memberOriginalDir)) {
|
||||
fs.mkdirSync(memberOriginalDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 1. Lưu ảnh gốc nguyên bản vào thư mục riêng của khách
|
||||
await fs.promises.writeFile(originalFilePath, file.buffer);
|
||||
|
||||
// 2. Trích xuất GPS từ EXIF bằng exifr
|
||||
let lat: number | undefined;
|
||||
let lng: number | undefined;
|
||||
|
||||
try {
|
||||
const gps = await exifr.gps(file.buffer);
|
||||
if (gps && typeof gps.latitude === 'number' && typeof gps.longitude === 'number') {
|
||||
lat = gps.latitude;
|
||||
lng = gps.longitude;
|
||||
console.log(`[EXIF GPS] Đã tìm thấy tọa độ từ EXIF: lat=${lat}, lng=${lng}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[EXIF GPS] Không thể giải nén GPS từ EXIF ảnh:', e.message);
|
||||
}
|
||||
|
||||
// 3. Nếu EXIF không có tọa độ, dùng tọa độ dự phòng gửi từ frontend
|
||||
if (lat === undefined || lng === undefined) {
|
||||
const bodyLat = req.body.latitude ? parseFloat(req.body.latitude) : undefined;
|
||||
const bodyLng = req.body.longitude ? parseFloat(req.body.longitude) : undefined;
|
||||
|
||||
if (bodyLat !== undefined && !isNaN(bodyLat) && bodyLng !== undefined && !isNaN(bodyLng)) {
|
||||
lat = bodyLat;
|
||||
lng = bodyLng;
|
||||
console.log(`[EXIF GPS] Sử dụng tọa độ dự phòng từ Frontend: lat=${lat}, lng=${lng}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Nếu vẫn không có, sử dụng vị trí mặc định (TP.HCM)
|
||||
if (lat === undefined || lng === undefined) {
|
||||
lat = 10.7769;
|
||||
lng = 106.7009;
|
||||
console.log(`[EXIF GPS] Không tìm thấy tọa độ nào, ghim tại TP.HCM mặc định: lat=${lat}, lng=${lng}`);
|
||||
}
|
||||
|
||||
// 5. Chuyển đổi HEIC nếu cần để lưu hiển thị
|
||||
let processBuffer = file.buffer;
|
||||
const isHeic = file.originalname.toLowerCase().endsWith('.heic') || file.originalname.toLowerCase().endsWith('.heif') || file.mimetype === 'image/heic' || file.mimetype === 'image/heif';
|
||||
if (isHeic) {
|
||||
try {
|
||||
processBuffer = await heicConvert({
|
||||
buffer: file.buffer,
|
||||
format: 'JPEG',
|
||||
quality: 1
|
||||
});
|
||||
console.log(`[HEIC] Converted anonymous HEIC image to JPEG for display`);
|
||||
} catch (e) {
|
||||
console.warn('[HEIC] Failed to convert HEIC to JPEG, attempting to process anyway:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Xử lý ảnh để hiển thị (kích thước tối đa 2K: 2560px)
|
||||
await sharp(processBuffer)
|
||||
.rotate()
|
||||
.resize(2560, 2560, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: 85 })
|
||||
.toFile(displayFilePath);
|
||||
|
||||
return this.prisma.photo.create({
|
||||
data: {
|
||||
uploaderId: uploaderId,
|
||||
imageUrl: `/uploads/tours/${displayFilename}`,
|
||||
originalUrl: `/uploads/members/${uploaderId}/originals/${originalFilename}`,
|
||||
privacy: 'PUBLIC',
|
||||
metadata: {
|
||||
lat: lat,
|
||||
lng: lng
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deletePhoto(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
|
||||
const photo = await this.prisma.photo.findUnique({
|
||||
@@ -1381,8 +1664,8 @@ class PhotoController {
|
||||
throw new NotFoundException('Không tìm thấy ảnh.');
|
||||
}
|
||||
|
||||
// Chỉ người tải lên mới có quyền xóa ảnh của họ
|
||||
if (photo.uploaderId !== req.user.id) {
|
||||
// Chỉ người tải lên hoặc Admin mới có quyền xóa ảnh
|
||||
if (photo.uploaderId !== req.user.id && !req.user.isAdmin) {
|
||||
throw new ForbiddenException('Bạn không có quyền xóa ảnh này.');
|
||||
}
|
||||
|
||||
@@ -1405,16 +1688,100 @@ class PhotoController {
|
||||
await this.prisma.photo.delete({ where: { id } });
|
||||
return { message: 'Ảnh đã được xóa thành công.' };
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async updatePhoto(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() body: { title?: string; description?: string; latitude?: number; longitude?: number },
|
||||
@Req() req: any
|
||||
) {
|
||||
const photo = await this.prisma.photo.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (!photo) {
|
||||
throw new NotFoundException('Không tìm thấy ảnh.');
|
||||
}
|
||||
|
||||
// Chỉ người tải lên hoặc Admin mới có quyền sửa thông tin ảnh
|
||||
if (photo.uploaderId !== req.user.id && !req.user.isAdmin) {
|
||||
throw new ForbiddenException('Bạn không có quyền chỉnh sửa thông tin của bức ảnh này.');
|
||||
}
|
||||
|
||||
const currentMetadata = (photo.metadata as any) || {};
|
||||
const updatedMetadata = {
|
||||
...currentMetadata,
|
||||
lat: body.latitude !== undefined ? body.latitude : currentMetadata.lat,
|
||||
lng: body.longitude !== undefined ? body.longitude : currentMetadata.lng,
|
||||
title: body.title !== undefined ? body.title : currentMetadata.title,
|
||||
description: body.description !== undefined ? body.description : currentMetadata.description,
|
||||
};
|
||||
|
||||
return this.prisma.photo.update({
|
||||
where: { id },
|
||||
data: {
|
||||
metadata: updatedMetadata
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Post(':id/toggle-like')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async toggleLikePhoto(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
|
||||
const userId = req.user.id;
|
||||
const photo = await this.prisma.photo.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (!photo) {
|
||||
throw new NotFoundException('Không tìm thấy ảnh.');
|
||||
}
|
||||
|
||||
const currentMetadata = (photo.metadata as any) || {};
|
||||
const likedUserIds = Array.isArray(currentMetadata.likedUserIds)
|
||||
? currentMetadata.likedUserIds
|
||||
: [];
|
||||
|
||||
const index = likedUserIds.indexOf(userId);
|
||||
let updatedLikedUserIds = [...likedUserIds];
|
||||
|
||||
if (index > -1) {
|
||||
// Unlike
|
||||
updatedLikedUserIds.splice(index, 1);
|
||||
} else {
|
||||
// Like
|
||||
updatedLikedUserIds.push(userId);
|
||||
}
|
||||
|
||||
const updatedMetadata = {
|
||||
...currentMetadata,
|
||||
likedUserIds: updatedLikedUserIds
|
||||
};
|
||||
|
||||
return this.prisma.photo.update({
|
||||
where: { id },
|
||||
data: {
|
||||
metadata: updatedMetadata
|
||||
},
|
||||
include: {
|
||||
uploader: {
|
||||
select: { id: true, name: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('users')
|
||||
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for User management (Admin/Manager)
|
||||
class UserController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AdminGuard)
|
||||
async getAllUsers(@Req() req: any, @Query('q') q?: string) {
|
||||
const currentUserId = req.user?.sub;
|
||||
const currentUserId = req.user?.id;
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: q
|
||||
? {
|
||||
@@ -1444,11 +1811,36 @@ class UserController {
|
||||
|
||||
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for updating user (Admin/Manager)
|
||||
@Patch(':id')
|
||||
async updateUser(@Param('id', ParseUUIDPipe) id: string, @Body() data: any) {
|
||||
async updateUser(@Param('id', ParseUUIDPipe) id: string, @Body() data: any, @Req() req: any) {
|
||||
const requestingUser = req.user;
|
||||
|
||||
// Fetch target user from DB
|
||||
const targetUser = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!targetUser) {
|
||||
throw new NotFoundException('Không tìm thấy người dùng');
|
||||
}
|
||||
|
||||
// 1. If target user is an Admin, only an Admin can update them.
|
||||
// (A manager/normal user cannot reset/change password of an Admin)
|
||||
if (targetUser.isAdmin && !requestingUser.isAdmin) {
|
||||
throw new ForbiddenException('Không có quyền thay đổi thông tin hoặc reset password của Quản trị viên');
|
||||
}
|
||||
|
||||
// 2. A non-admin can only update their own profile.
|
||||
if (!requestingUser.isAdmin && requestingUser.id !== id) {
|
||||
throw new ForbiddenException('Bạn không có quyền thực hiện hành động này');
|
||||
}
|
||||
|
||||
if (data.password) {
|
||||
data.passwordHash = await bcrypt.hash(data.password, 10);
|
||||
delete data.password;
|
||||
}
|
||||
|
||||
// Safety: prevent non-admins from promoting anyone to admin
|
||||
if (!requestingUser.isAdmin && data.isAdmin !== undefined) {
|
||||
delete data.isAdmin;
|
||||
}
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data,
|
||||
@@ -1458,7 +1850,8 @@ class UserController {
|
||||
|
||||
@Roles(ParticipantRole.OWNER) // Only owner can delete user
|
||||
@Delete(':id')
|
||||
async deleteUser(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@UseGuards(AdminGuard)
|
||||
async deleteUser(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!user) throw new NotFoundException('Không tìm thấy người dùng');
|
||||
if (user.isAdmin) {
|
||||
@@ -1496,9 +1889,16 @@ class UserController {
|
||||
|
||||
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for blocking user (Admin/Manager)
|
||||
@Post('block/:id')
|
||||
async toggleBlock(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@UseGuards(AdminGuard)
|
||||
async toggleBlock(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!user) throw new NotFoundException('Người dùng không tồn tại');
|
||||
|
||||
// Safety check: Cannot block an Admin
|
||||
if (user.isAdmin) {
|
||||
throw new BadRequestException('Không thể khóa tài khoản Quản trị viên');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { isBlocked: !user.isBlocked },
|
||||
@@ -1522,10 +1922,21 @@ export class CommentGateway implements OnGatewayConnection {
|
||||
console.log(`[WS] Client ${client.id} joined room: tour_${tourId}`);
|
||||
}
|
||||
|
||||
@SubscribeMessage('joinPhoto')
|
||||
handleJoinPhoto(client: Socket, photoId: string) {
|
||||
client.join(`photo_${photoId}`);
|
||||
console.log(`[WS] Client ${client.id} joined room: photo_${photoId}`);
|
||||
}
|
||||
|
||||
notifyNewComment(tourId: string, data: any) {
|
||||
// Gửi thông báo tới tất cả client trong phòng của Tour này
|
||||
this.server.to(`tour_${tourId}`).emit('commentAdded', data);
|
||||
}
|
||||
|
||||
notifyNewPhotoComment(photoId: string, data: any) {
|
||||
// Gửi thông báo tới tất cả client trong phòng của Ảnh này
|
||||
this.server.to(`photo_${photoId}`).emit('photoCommentAdded', data);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('locations')
|
||||
@@ -1693,6 +2104,387 @@ class AdminOtpController {
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/trash-photos')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
class AdminController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
async getTrashPhotos() {
|
||||
// 1. Lấy tất cả ảnh trong DB
|
||||
const dbPhotos = await this.prisma.photo.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
imageUrl: true,
|
||||
originalUrl: true,
|
||||
privacy: true,
|
||||
tourId: true,
|
||||
}
|
||||
});
|
||||
|
||||
const dbImageUrls = new Set(
|
||||
dbPhotos.map(p => p.imageUrl).filter(Boolean)
|
||||
);
|
||||
const dbOriginalUrls = new Set(
|
||||
dbPhotos.map(p => p.originalUrl).filter(Boolean)
|
||||
);
|
||||
|
||||
// 2. Tìm tất cả tệp tin vật lý trên đĩa
|
||||
const allDiskFiles: string[] = [];
|
||||
const getFilesRecursively = (dir: string) => {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
const list = fs.readdirSync(dir);
|
||||
for (const file of list) {
|
||||
const fullPath = path.join(dir, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
getFilesRecursively(fullPath);
|
||||
} else {
|
||||
allDiskFiles.push(fullPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
getFilesRecursively(UPLOAD_ROOT);
|
||||
|
||||
const trashPhotos: any[] = [];
|
||||
|
||||
// 3. Kiểm tra các tệp tin trên đĩa xem có trong DB không
|
||||
for (const filePath of allDiskFiles) {
|
||||
const relativePath = '/uploads' + filePath.substring(UPLOAD_ROOT.length).replace(/\\/g, '/');
|
||||
if (path.basename(filePath).startsWith('.')) continue;
|
||||
|
||||
const isUsed = dbImageUrls.has(relativePath) || dbOriginalUrls.has(relativePath);
|
||||
|
||||
if (!isUsed) {
|
||||
let size = 0;
|
||||
try {
|
||||
size = fs.statSync(filePath).size;
|
||||
} catch (e) {}
|
||||
|
||||
trashPhotos.push({
|
||||
filePath: filePath,
|
||||
url: relativePath,
|
||||
size: size,
|
||||
reason: 'Tệp tin không tồn tại trong cơ sở dữ liệu (ảnh mồ côi)',
|
||||
type: 'file_only'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Kiểm tra các bản ghi DB mồ côi (không có tourId và privacy không phải PUBLIC)
|
||||
const orphanedDbPhotos = dbPhotos.filter(p => !p.tourId && p.privacy !== 'PUBLIC');
|
||||
for (const dbPhoto of orphanedDbPhotos) {
|
||||
let size = 0;
|
||||
if (dbPhoto.imageUrl) {
|
||||
const fullPath = path.join(process.cwd(), dbPhoto.imageUrl.replace(/^\//, ''));
|
||||
try {
|
||||
if (fs.existsSync(fullPath)) {
|
||||
size += fs.statSync(fullPath).size;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
if (dbPhoto.originalUrl) {
|
||||
const fullPath = path.join(process.cwd(), dbPhoto.originalUrl.replace(/^\//, ''));
|
||||
try {
|
||||
if (fs.existsSync(fullPath)) {
|
||||
size += fs.statSync(fullPath).size;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
trashPhotos.push({
|
||||
id: dbPhoto.id,
|
||||
url: dbPhoto.imageUrl,
|
||||
size: size,
|
||||
reason: 'Bản ghi ảnh riêng tư không gắn với chuyến đi nào',
|
||||
type: 'db_orphaned'
|
||||
});
|
||||
}
|
||||
|
||||
return trashPhotos;
|
||||
}
|
||||
|
||||
@Delete('clean')
|
||||
async cleanTrashPhotos() {
|
||||
const trashList = await this.getTrashPhotos();
|
||||
let deletedCount = 0;
|
||||
let freedSpace = 0;
|
||||
|
||||
for (const item of trashList) {
|
||||
if (item.type === 'file_only') {
|
||||
if (item.filePath && fs.existsSync(item.filePath)) {
|
||||
try {
|
||||
fs.unlinkSync(item.filePath);
|
||||
deletedCount++;
|
||||
freedSpace += item.size;
|
||||
} catch (e) {}
|
||||
}
|
||||
} else if (item.type === 'db_orphaned') {
|
||||
const photo = await this.prisma.photo.findUnique({ where: { id: item.id } });
|
||||
if (photo) {
|
||||
if (photo.imageUrl) {
|
||||
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
|
||||
if (fs.existsSync(displayFilePath)) {
|
||||
try { fs.unlinkSync(displayFilePath); } catch (e) {}
|
||||
}
|
||||
}
|
||||
if (photo.originalUrl) {
|
||||
const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, ''));
|
||||
if (fs.existsSync(originalFilePath)) {
|
||||
try { fs.unlinkSync(originalFilePath); } catch (e) {}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.prisma.photo.delete({ where: { id: item.id } });
|
||||
deletedCount++;
|
||||
freedSpace += item.size;
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Đã dọn dẹp thành công. Đã xóa ${deletedCount} mục, giải phóng ${(freedSpace / (1024 * 1024)).toFixed(2)} MB.`,
|
||||
deletedCount,
|
||||
freedSpace
|
||||
};
|
||||
}
|
||||
|
||||
@Delete('delete-single')
|
||||
async deleteSingleTrash(@Body() body: { type: 'file_only' | 'db_orphaned'; filePath?: string; id?: string }) {
|
||||
const { type, filePath, id } = body;
|
||||
|
||||
if (type === 'file_only') {
|
||||
if (!filePath) throw new BadRequestException('Đường dẫn tệp tin không hợp lệ');
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
if (!resolvedPath.startsWith(UPLOAD_ROOT)) {
|
||||
throw new ForbiddenException('Không được phép xóa tệp tin ngoài thư mục uploads');
|
||||
}
|
||||
|
||||
if (fs.existsSync(resolvedPath)) {
|
||||
fs.unlinkSync(resolvedPath);
|
||||
return { success: true, message: 'Đã xóa tệp tin thành công' };
|
||||
} else {
|
||||
throw new NotFoundException('Tệp tin không tồn tại');
|
||||
}
|
||||
} else if (type === 'db_orphaned') {
|
||||
if (!id) throw new BadRequestException('ID bản ghi không hợp lệ');
|
||||
const photo = await this.prisma.photo.findUnique({ where: { id } });
|
||||
if (!photo) throw new NotFoundException('Bản ghi không tồn tại trong cơ sở dữ liệu');
|
||||
|
||||
if (photo.imageUrl) {
|
||||
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
|
||||
if (fs.existsSync(displayFilePath)) {
|
||||
try { fs.unlinkSync(displayFilePath); } catch (e) {}
|
||||
}
|
||||
}
|
||||
if (photo.originalUrl) {
|
||||
const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, ''));
|
||||
if (fs.existsSync(originalFilePath)) {
|
||||
try { fs.unlinkSync(originalFilePath); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.photo.delete({ where: { id } });
|
||||
return { success: true, message: 'Đã xóa bản ghi và các tệp liên quan thành công' };
|
||||
}
|
||||
|
||||
throw new BadRequestException('Yêu cầu không hợp lệ');
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('public-photos')
|
||||
class PublicPhotoController {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private commentGateway: CommentGateway
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async getPublicPhotos() {
|
||||
return this.prisma.photo.findMany({
|
||||
where: { privacy: 'PUBLIC' },
|
||||
select: {
|
||||
id: true,
|
||||
imageUrl: true,
|
||||
originalUrl: true,
|
||||
capturedAt: true,
|
||||
metadata: true,
|
||||
uploaderId: true,
|
||||
uploader: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { capturedAt: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':photoId/comments')
|
||||
async getPhotoComments(@Param('photoId', ParseUUIDPipe) photoId: string) {
|
||||
return this.prisma.comment.findMany({
|
||||
where: { photoId },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, name: true }
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post(':photoId/comments')
|
||||
async addPhotoComment(
|
||||
@Param('photoId', ParseUUIDPipe) photoId: string,
|
||||
@Body() body: { content: string },
|
||||
@Req() req: any
|
||||
) {
|
||||
const { content } = body;
|
||||
if (!content || content.trim() === '') {
|
||||
throw new BadRequestException('Nội dung bình luận không được để trống.');
|
||||
}
|
||||
|
||||
const photo = await this.prisma.photo.findUnique({
|
||||
where: { id: photoId }
|
||||
});
|
||||
if (!photo) {
|
||||
throw new NotFoundException('Không tìm thấy ảnh.');
|
||||
}
|
||||
|
||||
const comment = await this.prisma.comment.create({
|
||||
data: {
|
||||
content: content.trim(),
|
||||
photoId,
|
||||
userId: req.user.id
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, name: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Phát tín hiệu WebSocket cho các client đang xem ảnh này
|
||||
this.commentGateway.notifyNewPhotoComment(photoId, comment);
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
@Get(':photoId/share')
|
||||
async sharePhoto(
|
||||
@Param('photoId', ParseUUIDPipe) photoId: string,
|
||||
@Req() req: any,
|
||||
@Res() res: any
|
||||
) {
|
||||
const photo = await this.prisma.photo.findUnique({
|
||||
where: { id: photoId }
|
||||
});
|
||||
|
||||
if (!photo) {
|
||||
throw new NotFoundException('Không tìm thấy ảnh.');
|
||||
}
|
||||
|
||||
let host = req.headers['x-forwarded-host'] || req.headers.host || '';
|
||||
|
||||
// Nếu host là localhost/127.0.0.1 nhưng request có header proxy (nghĩa là đang chạy qua Nginx proxy ở production)
|
||||
const isProxied = !!(req.headers['x-forwarded-proto'] || req.headers['x-forwarded-for']);
|
||||
const isLocalhost = host.includes('localhost') || host.includes('127.0.0.1');
|
||||
if (isLocalhost && isProxied) {
|
||||
host = 'yotrip.labz.io.vn';
|
||||
}
|
||||
|
||||
const isLocal = host.includes('localhost') || host.includes('127.0.0.1') || host.startsWith('192.168.') || host.startsWith('10.');
|
||||
const protocol = isLocal ? (req.headers['x-forwarded-proto'] || 'http') : 'https';
|
||||
const baseUrl = `${protocol}://${host}`;
|
||||
|
||||
// Lấy thông tin metadata
|
||||
let title = 'YoTrip - Xem ảnh công khai';
|
||||
let description = 'Xem hình ảnh chia sẻ công khai trên bản đồ hành trình YoTrip.';
|
||||
if (photo.metadata && typeof photo.metadata === 'object') {
|
||||
const meta = photo.metadata as any;
|
||||
if (meta.title && meta.title.trim()) {
|
||||
title = meta.title;
|
||||
}
|
||||
if (meta.description && meta.description.trim()) {
|
||||
description = meta.description;
|
||||
}
|
||||
}
|
||||
|
||||
// Xây dựng đường dẫn ảnh tuyệt đối để Facebook hiển thị thumbnail
|
||||
const fullImageUrl = photo.imageUrl
|
||||
? (photo.imageUrl.startsWith('http') ? photo.imageUrl : `${baseUrl}${photo.imageUrl}`)
|
||||
: '';
|
||||
|
||||
const shareUrl = `${baseUrl}/api/v1/public-photos/${photoId}/share`;
|
||||
const redirectUrl = `${baseUrl}/?photoId=${photoId}`;
|
||||
|
||||
// Lấy kích thước thực tế của ảnh bằng sharp để Facebook hiển thị chuẩn xác không cần chờ xử lý bất đồng bộ
|
||||
let imageWidth = 1200;
|
||||
let imageHeight = 630;
|
||||
try {
|
||||
if (photo.imageUrl) {
|
||||
const localImagePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
|
||||
if (fs.existsSync(localImagePath)) {
|
||||
const imageMeta = await sharp(localImagePath).metadata();
|
||||
if (imageMeta.width && imageMeta.height) {
|
||||
imageWidth = imageMeta.width;
|
||||
imageHeight = imageMeta.height;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Share] Không thể lấy kích thước ảnh:', err.message);
|
||||
}
|
||||
|
||||
const htmlContent = `<!DOCTYPE html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>${title}</title>
|
||||
<meta name="description" content="${description}">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:site_name" content="YoTrip">
|
||||
<meta property="og:locale" content="vi_VN">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="${shareUrl}">
|
||||
<meta property="og:title" content="${title}">
|
||||
<meta property="og:description" content="${description}">
|
||||
<meta property="og:image" content="${fullImageUrl}">
|
||||
<meta property="og:image:secure_url" content="${fullImageUrl}">
|
||||
<meta property="og:image:type" content="image/jpeg">
|
||||
<meta property="og:image:width" content="${imageWidth}">
|
||||
<meta property="og:image:height" content="${imageHeight}">
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:url" content="${shareUrl}">
|
||||
<meta property="twitter:title" content="${title}">
|
||||
<meta property="twitter:description" content="${description}">
|
||||
<meta property="twitter:image" content="${fullImageUrl}">
|
||||
|
||||
<!-- Tự động chuyển hướng người dùng sang frontend chi tiết -->
|
||||
<script>
|
||||
window.location.href = "${redirectUrl}";
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div style="font-family: sans-serif; text-align: center; margin-top: 100px; color: #334155;">
|
||||
<h2>Đang chuyển hướng bạn đến YoTrip...</h2>
|
||||
<p>Nếu trang không tự động tải, <a href="${redirectUrl}">nhấn vào đây</a>.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
res.type('text/html').send(htmlContent);
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
@@ -1716,7 +2508,7 @@ class AdminOtpController {
|
||||
signOptions: { expiresIn: '1d' },
|
||||
}) as any,
|
||||
],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, AdminOtpController],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController, AdminController],
|
||||
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector, EmailService, ConfigService],
|
||||
exports: [PrismaService]
|
||||
})
|
||||
|
||||
|
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">
|
||||
<head>
|
||||
<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" />
|
||||
<title>Travel Planner</title>
|
||||
</head>
|
||||
|
||||
|
After Width: | Height: | Size: 254 KiB |
@@ -112,6 +112,7 @@ function App() {
|
||||
user={user}
|
||||
onViewTour={handleViewTour}
|
||||
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
|
||||
onLoginSuccess={handleLoginSuccess}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -399,8 +399,8 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MapContainer center={currentCoords} zoom={13} className="h-full w-full" zoomControl={false}>
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||
<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" attribution="" />
|
||||
<Marker position={currentCoords} />
|
||||
<MapPicker center={currentCoords} onPick={handlePickLocation} />
|
||||
</MapContainer>
|
||||
|
||||
@@ -70,11 +70,32 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
|
||||
|
||||
setIsUploading(true);
|
||||
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();
|
||||
selectedFiles.forEach(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`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 { X, User, Trash2, Shield, ShieldAlert, Lock, Unlock, Loader2, UserPlus } from 'lucide-react';
|
||||
import { X, User, Trash2, Shield, ShieldAlert, Lock, Unlock, Loader2, Image as ImageIcon } from 'lucide-react';
|
||||
|
||||
interface UserManagementModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -7,8 +7,14 @@ interface UserManagementModalProps {
|
||||
}
|
||||
|
||||
export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen, onClose }) => {
|
||||
const [activeTab, setActiveTab] = useState<'users' | 'photos' | 'trash'>('users');
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [photos, setPhotos] = useState<any[]>([]);
|
||||
const [trashPhotos, setTrashPhotos] = useState<any[]>([]);
|
||||
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 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(() => {
|
||||
if (isOpen) fetchUsers();
|
||||
}, [isOpen]);
|
||||
if (isOpen) {
|
||||
if (activeTab === 'users') {
|
||||
fetchUsers();
|
||||
} else if (activeTab === 'photos') {
|
||||
fetchPhotos();
|
||||
} else if (activeTab === 'trash') {
|
||||
fetchTrashPhotos();
|
||||
}
|
||||
}
|
||||
}, [isOpen, activeTab]);
|
||||
|
||||
const handleToggleBlock = async (id: string) => {
|
||||
try {
|
||||
@@ -60,29 +146,83 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePhoto = async (id: string) => {
|
||||
if (!confirm('Bạn có chắc chắn muốn xóa bức ảnh công khai này?')) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/photos/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message);
|
||||
}
|
||||
fetchPhotos();
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative w-full max-w-4xl bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
<div className="relative w-full max-w-4xl bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col h-[85vh]">
|
||||
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Shield className="w-6 h-6 text-blue-600" /> Quản lý người dùng
|
||||
<Shield className="w-6 h-6 text-blue-600" /> Hệ thống quản trị
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">Quản trị viên có quyền thêm, sửa, xóa hoặc khóa tài khoản.</p>
|
||||
<p className="text-sm text-gray-500">Quản lý thành viên và các tài nguyên công cộng của ứng dụng.</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
|
||||
<X className="w-6 h-6 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Selection */}
|
||||
<div className="flex border-b border-gray-100 bg-gray-50/20 px-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('users')}
|
||||
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 ${
|
||||
activeTab === 'users' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
Thành viên
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('photos')}
|
||||
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 ${
|
||||
activeTab === 'photos' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
Ảnh công cộng
|
||||
</button>
|
||||
<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">
|
||||
{loading ? (
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 text-red-600 rounded-xl font-bold mb-4">{error}</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'users' ? (
|
||||
loading ? (
|
||||
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
|
||||
) : error ? (
|
||||
<div className="p-4 bg-red-50 text-red-600 rounded-xl font-bold">{error}</div>
|
||||
) : (
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
@@ -143,6 +283,131 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
|
||||
))}
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
trashLoading ? (
|
||||
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center bg-gray-50 p-4 rounded-2xl border border-gray-100">
|
||||
<div>
|
||||
<span className="text-sm font-bold text-gray-800">
|
||||
Tổng số lượng: <span className="text-red-500 font-extrabold text-base">{trashPhotos.length}</span> mục ảnh rác
|
||||
</span>
|
||||
<span className="mx-2 text-gray-300">|</span>
|
||||
<span className="text-sm font-bold text-gray-800">
|
||||
Dung lượng ước tính: <span className="text-blue-600 font-extrabold text-base">
|
||||
{(trashPhotos.reduce((acc, curr) => acc + (curr.size || 0), 0) / (1024 * 1024)).toFixed(2)} MB
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{trashPhotos.length > 0 && (
|
||||
<button
|
||||
onClick={handleCleanTrash}
|
||||
disabled={cleaning}
|
||||
className="px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white rounded-xl font-bold text-xs uppercase tracking-wider flex items-center gap-2 shadow-md transition-all active:scale-95 disabled:opacity-50"
|
||||
>
|
||||
{cleaning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||
Dọn sạch ảnh rác
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{trashPhotos.length === 0 ? (
|
||||
<div className="text-center py-20 text-gray-400 italic">Không 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>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents, Tooltip } from 'react-leaflet';
|
||||
import { MapContainer, TileLayer, Marker, useMap, useMapEvents, Tooltip } from 'react-leaflet';
|
||||
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2, Share2, Filter, Tag as TagIcon, MapPin, Loader2 } from 'lucide-react';
|
||||
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Share2, Filter, MapPin, Loader2 } from 'lucide-react';
|
||||
import { UserManagementModal } from '@/components/UserManagementModal';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { CreateTourModal } from '../components/CreateTourModal';
|
||||
import { PublicPhotoModal } from '../components/PublicPhotoModal';
|
||||
|
||||
// Fix lỗi icon mặc định của Leaflet
|
||||
const DefaultIcon = L.icon({
|
||||
@@ -56,7 +57,7 @@ function MapTracker() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void, onOpenMyPhotos: () => void }) => {
|
||||
export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos, onLoginSuccess }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void, onOpenMyPhotos: () => void, onLoginSuccess?: (user: any) => void }) => {
|
||||
// Tối ưu hóa việc lấy dữ liệu từ store bằng selectors để tránh re-render thừa
|
||||
const publicTours = useTourStore(state => state.publicTours);
|
||||
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
|
||||
@@ -76,6 +77,45 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
|
||||
|
||||
const [userPos, setUserPos] = useState<[number, number]>(initialViewState?.center || [10.7769, 106.7009]);
|
||||
const [mapZoom] = useState(initialViewState?.zoom || 13);
|
||||
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null);
|
||||
const [selectedPhotoGroup, setSelectedPhotoGroup] = useState<any[]>([]);
|
||||
|
||||
const groupedPhotos = React.useMemo(() => {
|
||||
const groups: { [key: string]: any[] } = {};
|
||||
publicPhotos.forEach((photo) => {
|
||||
const lat = photo.metadata?.lat;
|
||||
const lng = photo.metadata?.lng;
|
||||
if (typeof lat === 'number' && typeof lng === 'number') {
|
||||
const key = `${lat.toFixed(5)},${lng.toFixed(5)}`;
|
||||
if (!groups[key]) {
|
||||
groups[key] = [];
|
||||
}
|
||||
groups[key].push(photo);
|
||||
}
|
||||
});
|
||||
// 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 [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
// Chỉ fetch dữ liệu khi người dùng đã đăng nhập và có token
|
||||
// Luôn tải danh sách ảnh công khai để hiển thị trên bản đồ cho tất cả mọi người
|
||||
fetchPublicPhotos();
|
||||
|
||||
// Chỉ tải danh sách tour khi người dùng đã đăng nhập và có token
|
||||
if (user || localStorage.getItem('token')) {
|
||||
fetchPublicTours();
|
||||
}
|
||||
@@ -210,15 +253,45 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
|
||||
() => console.log("Không thể lấy vị trí người dùng")
|
||||
);
|
||||
} else {
|
||||
// Cập nhật store để đồng bộ với vị trí khởi tạo từ cache
|
||||
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 (
|
||||
<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 */}
|
||||
<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í */}
|
||||
<div className="flex items-center gap-3 pointer-events-auto">
|
||||
<button
|
||||
@@ -369,10 +442,10 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
|
||||
zoom={mapZoom}
|
||||
className="h-full w-full"
|
||||
preferCanvas={true}
|
||||
attributionControl={false}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution='© OpenStreetMap contributors'
|
||||
/>
|
||||
|
||||
{/* Theo dõi di chuyển bản đồ */}
|
||||
@@ -449,6 +522,49 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
|
||||
);
|
||||
})}
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{groupedPhotos.map((photoGroup) => {
|
||||
const latestPhoto = photoGroup[0];
|
||||
const lat = latestPhoto.metadata?.lat;
|
||||
const lng = latestPhoto.metadata?.lng;
|
||||
if (typeof lat !== 'number' || typeof lng !== 'number') return null;
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={latestPhoto.id}
|
||||
position={[lat, lng]}
|
||||
eventHandlers={{
|
||||
click: () => {
|
||||
setSelectedPhoto(latestPhoto);
|
||||
setSelectedPhotoGroup(photoGroup);
|
||||
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>
|
||||
|
||||
{/* Context Menu Chia sẻ */}
|
||||
@@ -498,6 +614,41 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,7 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { LogIn, Compass, ArrowRight, Map as MapIcon, UserPlus, ShieldCheck } from 'lucide-react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { LogIn, Compass, Map as MapIcon, Camera } from 'lucide-react';
|
||||
import { LoginModal } from '../components/LoginModal';
|
||||
|
||||
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"
|
||||
];
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
|
||||
interface LandingPageProps {
|
||||
onContinue?: () => void;
|
||||
@@ -19,135 +11,313 @@ interface LandingPageProps {
|
||||
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 }) => {
|
||||
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 backgroundImage = useMemo(() => {
|
||||
return TRAVEL_IMAGES[Math.floor(Math.random() * TRAVEL_IMAGES.length)];
|
||||
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
|
||||
const [currentBgIndex, setCurrentBgIndex] = useState(0);
|
||||
const [bg1, setBg1] = useState('/background.avif');
|
||||
const [bg2, setBg2] = useState('');
|
||||
const [fade1, setFade1] = useState(true);
|
||||
const [fade2, setFade2] = useState(false);
|
||||
const [activeSlot, setActiveSlot] = useState<1 | 2>(1);
|
||||
|
||||
useEffect(() => {
|
||||
const nextUrl = publicPhotos.length > 0 ? publicPhotos[currentBgIndex]?.imageUrl : '/background.avif';
|
||||
if (!nextUrl) return;
|
||||
|
||||
const currentUrl = activeSlot === 1 ? bg1 : bg2;
|
||||
if (nextUrl === currentUrl) return;
|
||||
|
||||
if (activeSlot === 1) {
|
||||
setBg2(nextUrl);
|
||||
setFade2(true);
|
||||
setFade1(false);
|
||||
setActiveSlot(2);
|
||||
} else {
|
||||
setBg1(nextUrl);
|
||||
setFade1(true);
|
||||
setFade2(false);
|
||||
setActiveSlot(1);
|
||||
}
|
||||
}, [currentBgIndex, publicPhotos]);
|
||||
|
||||
const fetchPublicPhotos = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/public-photos');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
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 (
|
||||
<div className="flex h-screen w-full flex-col md:flex-row overflow-hidden font-sans bg-gray-50 relative">
|
||||
{/* Logo/Brand Header ở góc trên bên trái */}
|
||||
<div className="absolute top-8 left-8 z-20 flex items-center gap-3 text-white md:text-white drop-shadow-lg">
|
||||
<Compass className="w-10 h-10" />
|
||||
<span className="text-2xl font-black tracking-tighter uppercase">Travel Planner</span>
|
||||
</div>
|
||||
|
||||
{/* Nửa bên trái: Hiển thị một hình ảnh duy nhất */}
|
||||
<div className="hidden md:block md:w-1/2 relative h-full bg-blue-900">
|
||||
<div className="h-dvh w-full overflow-hidden font-sans bg-gray-900 relative">
|
||||
{/* Background Image - Hiển thị trên mọi thiết bị với hiệu ứng Crossfade & Panning */}
|
||||
<div className="absolute inset-0 z-0 bg-gray-950 overflow-hidden">
|
||||
{/* Slot 1 */}
|
||||
{bg1 && (
|
||||
<img
|
||||
src={backgroundImage}
|
||||
className="w-full h-full object-cover opacity-70"
|
||||
alt="Travel Background"
|
||||
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"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-black/40 to-transparent pointer-events-none" />
|
||||
)}
|
||||
|
||||
{/* Thumbnail Grid ở góc dưới bên trái */}
|
||||
<div className="absolute bottom-12 left-8 z-20 flex flex-col gap-2 items-start drop-shadow-2xl">
|
||||
{/* Hàng trên cùng: 1 thumbnail */}
|
||||
<div className="flex gap-2">
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
{/* Hàng thứ 2: 2 thumbnail */}
|
||||
<div className="flex gap-2">
|
||||
<img src={TRAVEL_IMAGES[1]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 2" />
|
||||
<img src={TRAVEL_IMAGES[2]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 3" />
|
||||
</div>
|
||||
|
||||
{/* Hàng dưới cùng: 3 thumbnail */}
|
||||
<div className="flex gap-2">
|
||||
<img src={TRAVEL_IMAGES[3]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 4" />
|
||||
<img src={TRAVEL_IMAGES[4]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 5" />
|
||||
<img src={TRAVEL_IMAGES[5]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nửa bên phải: Nội dung chào mừng và Hành động */}
|
||||
<div className="flex-1 md:w-1/2 flex flex-col justify-center items-center bg-white p-8 lg:p-16 h-full overflow-y-auto">
|
||||
<div className="max-w-md w-full space-y-12">
|
||||
<div className="space-y-4">
|
||||
{isInitialSetup ? (
|
||||
<>
|
||||
<div className="inline-flex items-center px-3 py-1 rounded-full bg-amber-50 text-amber-700 text-xs font-bold border border-amber-100 mb-2">
|
||||
<ShieldCheck className="w-3 h-3 mr-1" /> CẤU HÌNH HỆ THỐNG LẦN ĐẦU
|
||||
</div>
|
||||
<h1 className="text-4xl font-extrabold text-gray-900 leading-tight">
|
||||
Thiết lập Quản trị viên
|
||||
</h1>
|
||||
<p className="text-lg text-gray-500 leading-relaxed">
|
||||
Chào mừng! Hệ thống vừa được cài đặt. Vui lòng tạo tài khoản đầu tiên để quản 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>
|
||||
</>
|
||||
{/* 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 className="grid grid-cols-1 gap-4">
|
||||
{isInitialSetup && (
|
||||
{/* Keyframes cho hiệu ứng panning từ trái sang phải */}
|
||||
<style>{`
|
||||
@keyframes pan-left-to-right {
|
||||
0% {
|
||||
transform: translateX(0) translateZ(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-16.667%) translateZ(0);
|
||||
}
|
||||
}
|
||||
.animate-panning {
|
||||
animation: pan-left-to-right 13500ms linear forwards;
|
||||
will-change: transform;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Top Bar - Thanh điều hướng trên cùng */}
|
||||
<div className="absolute top-0 left-0 right-0 z-20 p-4 pt-[calc(1rem+env(safe-area-inset-top,0px))] flex justify-between items-center">
|
||||
<div className="flex items-center gap-2 text-white drop-shadow-lg">
|
||||
<Compass className="w-8 h-8" />
|
||||
<span className="text-xl font-black tracking-tighter uppercase hidden sm:block">Travel Planner</span>
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<UserPlus className="w-5 h-5" />
|
||||
Bắt đầu thiết lập ngay
|
||||
<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>
|
||||
|
||||
<style>{`
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Bar - Nút hành động chính */}
|
||||
<div className="absolute bottom-[calc(2rem+env(safe-area-inset-bottom,0px))] left-1/2 -translate-x-1/2 z-20 w-full px-4">
|
||||
<button
|
||||
onClick={onGoToMap}
|
||||
className="flex items-center justify-center gap-3 bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-4 px-8 rounded-2xl transition-all shadow-lg shadow-indigo-100"
|
||||
className="w-full max-w-md mx-auto flex items-center justify-center gap-3 bg-white/20 backdrop-blur-lg text-white font-bold py-4 px-8 rounded-2xl transition-all shadow-2xl border border-white/30 hover:bg-white/30 active:scale-95"
|
||||
>
|
||||
<MapIcon className="w-5 h-5" />
|
||||
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>
|
||||
|
||||
{/* Login Modal Component */}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { ChevronLeft, Image as ImageIcon, Download, Calendar, MapPin, Loader2, Filter, X, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { ChevronLeft, Image as ImageIcon, Download, Calendar, MapPin, Loader2, Filter, X, Trash2, Edit, Heart } from 'lucide-react';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
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 }) => {
|
||||
const [photos, setPhotos] = useState<any[]>([]);
|
||||
@@ -13,12 +23,140 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
const notify = useNotification();
|
||||
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(() => {
|
||||
const fetchPhotos = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/users/me/photos', {
|
||||
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');
|
||||
@@ -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
|
||||
useEffect(() => {
|
||||
if (filteredPhotos.length > 0 && (!selectedPhotoForDisplay || !filteredPhotos.some(p => p.id === selectedPhotoForDisplay.id))) {
|
||||
setSelectedPhotoForDisplay(filteredPhotos[0]);
|
||||
setSelectedPhotoForDisplay(getMostLikedPhoto(filteredPhotos));
|
||||
} else if (filteredPhotos.length === 0) {
|
||||
setSelectedPhotoForDisplay(null);
|
||||
} else if (selectedPhotoForDisplay) {
|
||||
@@ -89,7 +227,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
const response = await fetch(`/api/v1/photos/${photoId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
|
||||
}
|
||||
});
|
||||
|
||||
@@ -215,45 +353,196 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
<div className="md:flex-1 bg-white rounded-2xl shadow-lg border border-gray-100 p-4 flex flex-col items-center justify-center min-h-[300px]">
|
||||
{selectedPhotoForDisplay ? (
|
||||
<div className="relative w-full h-full flex flex-col items-center justify-center">
|
||||
<div className="relative overflow-hidden rounded-xl shadow-md max-w-full max-h-[calc(100vh-350px)] group">
|
||||
<img
|
||||
src={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl}
|
||||
alt="Selected Photo"
|
||||
className="max-w-full max-h-[calc(100vh-350px)] object-contain rounded-xl shadow-md"
|
||||
className="max-w-full max-h-[calc(100vh-350px)] object-contain cursor-zoom-in"
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
/>
|
||||
|
||||
{/* Overlays (Only show when not editing) */}
|
||||
{!isEditing && (
|
||||
<>
|
||||
{/* Like (Heart) button overlay */}
|
||||
<button
|
||||
onClick={handleToggleLike}
|
||||
className="absolute top-4 left-4 z-10 flex items-center gap-1.5 bg-black/60 hover:bg-black/75 border border-white/10 text-white font-bold py-1.5 px-3 rounded-full transition-all active:scale-95 text-[11px] backdrop-blur-md"
|
||||
title={isLiked ? "Bỏ thích" : "Thích"}
|
||||
>
|
||||
<Heart className={`w-4 h-4 transition-colors ${
|
||||
isLiked ? 'text-rose-500 fill-rose-500 animate-in zoom-in-75' : 'text-gray-300 hover:text-rose-450'
|
||||
}`} />
|
||||
<span>{likeCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Delete button overlay */}
|
||||
<button
|
||||
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"
|
||||
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-5 h-5" />
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="mt-6 w-full flex items-center justify-between px-2">
|
||||
{/* Bottom metadata details gradient panel overlay */}
|
||||
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent p-6 text-white flex flex-col gap-2 text-left">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
{selectedPhotoForDisplay.metadata?.title ? (
|
||||
<h3 className="text-base font-extrabold text-white break-words drop-shadow-md">
|
||||
{selectedPhotoForDisplay.metadata.title}
|
||||
</h3>
|
||||
) : (
|
||||
<span className="text-xs text-gray-350 italic block mb-1 drop-shadow-md">Chưa 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-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">
|
||||
<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" />
|
||||
{new Date(selectedPhotoForDisplay.capturedAt).toLocaleDateString('vi-VN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
Ngày chụp: {new Date(selectedPhotoForDisplay.capturedAt).toLocaleDateString('vi-VN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</div>
|
||||
<div 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-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-2xl font-black uppercase tracking-widest text-[10px] transition-all shadow-lg shadow-blue-900/20 active:scale-95"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold uppercase tracking-widest text-[9px] transition-all shadow-md active:scale-95 shrink-0"
|
||||
>
|
||||
<Download className="w-4 h-4" /> Tải xuống ảnh gốc
|
||||
<Download className="w-3.5 h-3.5 animate-pulse" /> Tải ảnh gốc
|
||||
</a>
|
||||
)}
|
||||
</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 className="text-center text-gray-400 py-20">
|
||||
<ImageIcon className="w-16 h-16 mx-auto mb-4 opacity-20" />
|
||||
@@ -296,6 +585,35 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
</div>
|
||||
{}
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { User, Mail, Lock, ArrowRight, ChevronLeft, Compass, Phone, MapPin, ShieldCheck } from 'lucide-react';
|
||||
import { User, Mail, Lock, ArrowRight, ChevronLeft, Phone, MapPin, ShieldCheck } from 'lucide-react';
|
||||
|
||||
interface SignupPageProps {
|
||||
onBack: () => void;
|
||||
@@ -54,17 +54,26 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
|
||||
setStep('otp');
|
||||
} else {
|
||||
// Xác thực mã OTP bước hoàn tất
|
||||
const guestUserStr = localStorage.getItem('guest_user');
|
||||
const guestUser = guestUserStr ? JSON.parse(guestUserStr) : null;
|
||||
const guestId = guestUser ? guestUser.id : undefined;
|
||||
|
||||
const response = await fetch(`/api/v1/auth/signup/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: formData.email,
|
||||
otp: otp
|
||||
otp: otp,
|
||||
guestId: guestId
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Mã OTP không chính xác');
|
||||
|
||||
// Dọn dẹp session lữ khách sau khi đăng ký thành công
|
||||
localStorage.removeItem('guest_token');
|
||||
localStorage.removeItem('guest_user');
|
||||
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AddPhotoModal } from '@/components/AddPhotoModal';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline, useMap, Tooltip } from 'react-leaflet';
|
||||
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
||||
import { CoordinateSelectModal } from '../components/CoordinateSelectModal';
|
||||
import {
|
||||
Map as MapIcon,
|
||||
Wallet,
|
||||
@@ -42,7 +43,10 @@ import {
|
||||
Share2,
|
||||
Tag as TagIcon,
|
||||
Trash2,
|
||||
FileText
|
||||
FileText,
|
||||
Edit,
|
||||
Download,
|
||||
Heart
|
||||
} from 'lucide-react';
|
||||
import L from 'leaflet';
|
||||
|
||||
@@ -325,6 +329,15 @@ const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.Lat
|
||||
);
|
||||
};
|
||||
|
||||
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 TourDetailPage = ({
|
||||
onBack,
|
||||
tourId,
|
||||
@@ -500,7 +513,7 @@ export const TourDetailPage = ({
|
||||
const [isMemberDetailOpen, setIsMemberDetailOpen] = useState(false);
|
||||
const [selectedPhotoForDisplay, setSelectedPhotoForDisplay] = useState<any | null>(null); // New state for large photo display
|
||||
const currentUserId = useMemo(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
|
||||
if (!token) return null;
|
||||
try {
|
||||
return JSON.parse(atob(token.split('.')[1])).sub;
|
||||
@@ -508,6 +521,143 @@ export const TourDetailPage = ({
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
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 [isEditingPhoto, setIsEditingPhoto] = useState(false);
|
||||
const [editPhotoTitle, setEditPhotoTitle] = useState('');
|
||||
const [editPhotoDescription, setEditPhotoDescription] = useState('');
|
||||
const [editPhotoLat, setEditPhotoLat] = useState<number | ''>('');
|
||||
const [editPhotoLng, setEditPhotoLng] = useState<number | ''>('');
|
||||
const [isSavingPhotoEdit, setIsSavingPhotoEdit] = useState(false);
|
||||
const [isMapOpen, setIsMapOpen] = useState(false);
|
||||
const [resolvedAddress, setResolvedAddress] = useState<string>('');
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
const likedUserIds = selectedPhotoForDisplay && selectedPhotoForDisplay.metadata && Array.isArray(selectedPhotoForDisplay.metadata.likedUserIds)
|
||||
? selectedPhotoForDisplay.metadata.likedUserIds
|
||||
: [];
|
||||
const isPhotoLiked = currentUser && likedUserIds.includes(currentUser.id);
|
||||
const photoLikeCount = likedUserIds.length;
|
||||
|
||||
const handleToggleLikePhoto = async () => {
|
||||
if (!selectedPhotoForDisplay) return;
|
||||
try {
|
||||
const response = await fetch(`/api/v1/photos/${selectedPhotoForDisplay.id}/toggle-like`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSelectedPhotoForDisplay((prev: any) => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
metadata: {
|
||||
...prev.metadata,
|
||||
likedUserIds: data.likedUserIds
|
||||
}
|
||||
};
|
||||
});
|
||||
if (currentTour) {
|
||||
fetchTour(currentTour.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling like:', error);
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
setEditPhotoTitle(selectedPhotoForDisplay.metadata?.title || '');
|
||||
setEditPhotoDescription(selectedPhotoForDisplay.metadata?.description || '');
|
||||
setEditPhotoLat(selectedPhotoForDisplay.metadata?.lat ?? '');
|
||||
setEditPhotoLng(selectedPhotoForDisplay.metadata?.lng ?? '');
|
||||
setIsEditingPhoto(false);
|
||||
}
|
||||
}, [selectedPhotoForDisplay]);
|
||||
|
||||
const handleSavePhotoEdit = async () => {
|
||||
if (!selectedPhotoForDisplay) return;
|
||||
if (editPhotoLat !== '' && (isNaN(editPhotoLat) || editPhotoLat < -90 || editPhotoLat > 90)) {
|
||||
notify({ title: 'Lỗi', message: 'Vĩ độ không hợp lệ (-90 đến 90).', type: 'error' });
|
||||
return;
|
||||
}
|
||||
if (editPhotoLng !== '' && (isNaN(editPhotoLng) || editPhotoLng < -180 || editPhotoLng > 180)) {
|
||||
notify({ title: 'Lỗi', message: 'Kinh độ không hợp lệ (-180 đến 180).', type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingPhotoEdit(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: editPhotoTitle,
|
||||
description: editPhotoDescription,
|
||||
latitude: editPhotoLat === '' ? undefined : editPhotoLat,
|
||||
longitude: editPhotoLng === '' ? undefined : editPhotoLng
|
||||
})
|
||||
});
|
||||
|
||||
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' });
|
||||
|
||||
setSelectedPhotoForDisplay((prev: any) => prev ? ({ ...prev, metadata: updatedPhoto.metadata }) : null);
|
||||
|
||||
if (currentTour) {
|
||||
fetchTour(currentTour.id);
|
||||
}
|
||||
setIsEditingPhoto(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 {
|
||||
setIsSavingPhotoEdit(false);
|
||||
}
|
||||
};
|
||||
const [joinRequests, setJoinRequests] = useState<any[]>([]);
|
||||
const [titleInput, setTitleInput] = useState(currentTour?.title ?? '');
|
||||
const [descriptionInput, setDescriptionInput] = useState(currentTour?.description ?? '');
|
||||
@@ -541,9 +691,9 @@ export const TourDetailPage = ({
|
||||
// Effect to set initial selected photo for display or reset if current one is no longer in filtered list
|
||||
useEffect(() => {
|
||||
if (filteredPhotos.length > 0 && !selectedPhotoForDisplay) {
|
||||
setSelectedPhotoForDisplay(filteredPhotos[0]);
|
||||
setSelectedPhotoForDisplay(getMostLikedPhoto(filteredPhotos));
|
||||
} else if (selectedPhotoForDisplay && !filteredPhotos.some(p => p.id === selectedPhotoForDisplay.id)) {
|
||||
setSelectedPhotoForDisplay(filteredPhotos.length > 0 ? filteredPhotos[0] : null);
|
||||
setSelectedPhotoForDisplay(filteredPhotos.length > 0 ? getMostLikedPhoto(filteredPhotos) : null);
|
||||
}
|
||||
}, [filteredPhotos, selectedPhotoForDisplay]);
|
||||
const [isCommentModalOpen, setIsCommentModalOpen] = useState(false);
|
||||
@@ -595,7 +745,7 @@ export const TourDetailPage = ({
|
||||
const response = await fetch(`/api/v1/photos/${photoId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
|
||||
}
|
||||
});
|
||||
|
||||
@@ -694,7 +844,7 @@ export const TourDetailPage = ({
|
||||
if (currentTour && currentTour.photos && currentTour.photos.length > 0) {
|
||||
// If there are photos, and no leg is selected, default to 'all' and first photo
|
||||
if (selectedLegIdForPhoto === 'all' && !selectedPhotoForDisplay) {
|
||||
setSelectedPhotoForDisplay(currentTour.photos[0]);
|
||||
setSelectedPhotoForDisplay(getMostLikedPhoto(currentTour.photos));
|
||||
} else if (selectedLegIdForPhoto !== 'all') {
|
||||
// If a specific leg is selected, try to find a photo for that leg
|
||||
const targetLeg = legs.find(l => l.id === selectedLegIdForPhoto);
|
||||
@@ -702,9 +852,9 @@ export const TourDetailPage = ({
|
||||
const locationIdsInLeg = targetLeg.locations.map((loc: any) => loc.id);
|
||||
const photosInLeg = currentTour.photos.filter((p: any) => p.locationId && locationIdsInLeg.includes(p.locationId));
|
||||
if (photosInLeg.length > 0 && !selectedPhotoForDisplay) {
|
||||
setSelectedPhotoForDisplay(photosInLeg[0]);
|
||||
setSelectedPhotoForDisplay(getMostLikedPhoto(photosInLeg));
|
||||
} else if (selectedPhotoForDisplay && !photosInLeg.some(p => p.id === selectedPhotoForDisplay.id)) {
|
||||
setSelectedPhotoForDisplay(photosInLeg.length > 0 ? photosInLeg[0] : null);
|
||||
setSelectedPhotoForDisplay(photosInLeg.length > 0 ? getMostLikedPhoto(photosInLeg) : null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1204,7 +1354,7 @@ export const TourDetailPage = ({
|
||||
date: tourDateDisplay,
|
||||
membersCount: currentTour?.participants?.length || 0,
|
||||
budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND",
|
||||
coverImage: currentTour?.photos?.[0]?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
|
||||
coverImage: getMostLikedPhoto(currentTour?.photos || [])?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1587,8 +1737,9 @@ export const TourDetailPage = ({
|
||||
zoom={mapZoom}
|
||||
className="h-full w-full"
|
||||
preferCanvas={true}
|
||||
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="" />
|
||||
{canEdit && !isPublicView && (
|
||||
<>
|
||||
<MapHoverTip canEdit={canEdit} />
|
||||
@@ -1911,25 +2062,198 @@ export const TourDetailPage = ({
|
||||
{/* Right Column: Large Photo Display */}
|
||||
<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 ? (
|
||||
<div className="relative w-full h-full flex items-center justify-center">
|
||||
<div className="relative w-full h-full flex flex-col items-center justify-center">
|
||||
<div className="relative overflow-hidden rounded-xl shadow-md max-w-full max-h-[calc(100vh-350px)] group">
|
||||
<img
|
||||
src={selectedPhotoForDisplay.imageUrl}
|
||||
alt="Selected Tour Photo"
|
||||
className="max-w-full max-h-[calc(100vh-200px)] object-contain rounded-xl shadow-md"
|
||||
className="max-w-full max-h-[calc(100vh-350px)] object-contain cursor-zoom-in"
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
/>
|
||||
{/* Optional: Add delete button for the large photo */}
|
||||
{currentUserId === selectedPhotoForDisplay.uploaderId && !isPublicView && (
|
||||
|
||||
{/* Overlays (Only show when not editing) */}
|
||||
{!isEditingPhoto && (
|
||||
<>
|
||||
{/* Like (Heart) button overlay */}
|
||||
<button
|
||||
onClick={handleToggleLikePhoto}
|
||||
className="absolute top-4 left-4 z-10 flex items-center gap-1.5 bg-black/60 hover:bg-black/75 border border-white/10 text-white font-bold py-1.5 px-3 rounded-full transition-all active:scale-95 text-[11px] backdrop-blur-md"
|
||||
title={isPhotoLiked ? "Bỏ thích" : "Thích"}
|
||||
>
|
||||
<Heart className={`w-4 h-4 transition-colors ${
|
||||
isPhotoLiked ? 'text-rose-500 fill-rose-500 animate-in zoom-in-75' : 'text-gray-300 hover:text-rose-450'
|
||||
}`} />
|
||||
<span>{photoLikeCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Delete button overlay (if owner) */}
|
||||
{currentUserId === selectedPhotoForDisplay.uploaderId && (
|
||||
<button
|
||||
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-colors"
|
||||
className="absolute top-4 right-4 z-10 p-2 bg-black/60 hover:bg-red-650 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-5 h-5" />
|
||||
<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 */}
|
||||
{((!isPublicView && currentUser?.isAdmin) || (currentUserId && currentUserId === selectedPhotoForDisplay.uploaderId)) && (
|
||||
<button
|
||||
onClick={() => setIsEditingPhoto(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>
|
||||
<div className="text-[10px] text-gray-400">
|
||||
Tải lên bởi: {selectedPhotoForDisplay.uploader?.name || 'Thành viên'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(currentUser?.isAdmin || currentUserId === selectedPhotoForDisplay.uploaderId) && 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>
|
||||
|
||||
{isEditingPhoto && (
|
||||
<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 text-left">
|
||||
<h4 className="text-xs font-black uppercase tracking-wider text-blue-600">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={editPhotoTitle}
|
||||
onChange={(e) => setEditPhotoTitle(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={editPhotoDescription}
|
||||
onChange={(e) => setEditPhotoDescription(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={editPhotoLat}
|
||||
onChange={(e) => setEditPhotoLat(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={editPhotoLng}
|
||||
onChange={(e) => setEditPhotoLng(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={() => setIsEditingPhoto(false)}
|
||||
disabled={isSavingPhotoEdit}
|
||||
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={handleSavePhotoEdit}
|
||||
disabled={isSavingPhotoEdit}
|
||||
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"
|
||||
>
|
||||
{isSavingPhotoEdit ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
Đang lưu...
|
||||
</>
|
||||
) : (
|
||||
'Lưu lại'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-400">
|
||||
@@ -2220,7 +2544,7 @@ export const TourDetailPage = ({
|
||||
{/* Nút X để quay lại */}
|
||||
<button
|
||||
onClick={() => setIsMapFullscreen(false)}
|
||||
className="absolute top-4 left-4 z-[1002] w-11 h-11 bg-white/90 backdrop-blur-md rounded-full shadow-2xl flex items-center justify-center border border-white/20 hover:bg-white transition-all active:scale-95 group"
|
||||
className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] left-4 z-[1002] w-11 h-11 bg-white/90 backdrop-blur-md rounded-full shadow-2xl flex items-center justify-center border border-white/20 hover:bg-white transition-all active:scale-95 group"
|
||||
title="Đóng bản đồ"
|
||||
>
|
||||
<X className="w-6 h-6 text-gray-800 group-hover:rotate-90 transition-transform duration-300" />
|
||||
@@ -2228,7 +2552,7 @@ export const TourDetailPage = ({
|
||||
|
||||
{/* Transparent Top Bar Label - Glassmorphism style */}
|
||||
{selectedRouteInfo && drivingRoute.length > 0 && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[1001] bg-white/20 backdrop-blur-lg px-6 py-2.5 rounded-full border border-white/30 flex items-center gap-4 animate-in fade-in slide-in-from-top-2 duration-500 shadow-xl">
|
||||
<div className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] left-1/2 -translate-x-1/2 z-[1001] bg-white/20 backdrop-blur-lg px-6 py-2.5 rounded-full border border-white/30 flex items-center gap-4 animate-in fade-in slide-in-from-top-2 duration-500 shadow-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<Navigation className="w-4 h-4 text-blue-600 rotate-45 fill-blue-600" />
|
||||
<span className="text-[11px] font-black text-gray-700 uppercase tracking-widest">{selectedRouteInfo.label}</span>
|
||||
@@ -2246,8 +2570,9 @@ export const TourDetailPage = ({
|
||||
zoom={mapZoom}
|
||||
className="h-full w-full"
|
||||
preferCanvas={true}
|
||||
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="" />
|
||||
<MapTourBounds locations={allLocations} />
|
||||
<MapRotationHandler rotation={mapRotation} />
|
||||
<RecenterUser position={userLocation} trigger={locateTrigger} />
|
||||
@@ -2284,7 +2609,7 @@ export const TourDetailPage = ({
|
||||
</MapContainer>
|
||||
|
||||
{/* Overlay điều khiển trên bản đồ toàn màn hình */}
|
||||
<div className="absolute top-4 right-4 z-[1001] flex flex-col gap-2">
|
||||
<div className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] right-4 z-[1001] flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => setIsMapControlsOpen(!isMapControlsOpen)}
|
||||
className="w-11 h-11 bg-white/90 backdrop-blur-md rounded-2xl shadow-xl border border-white text-blue-600 flex items-center justify-center transition-all active:scale-95"
|
||||
@@ -2420,6 +2745,35 @@ export const TourDetailPage = ({
|
||||
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
|
||||
onCommentDeleted={() => handleCommentDecrement(commentLocationId)}
|
||||
/>
|
||||
<CoordinateSelectModal
|
||||
isOpen={isMapOpen}
|
||||
onClose={() => setIsMapOpen(false)}
|
||||
initialLat={typeof editPhotoLat === 'number' ? editPhotoLat : undefined}
|
||||
initialLng={typeof editPhotoLng === 'number' ? editPhotoLng : undefined}
|
||||
onSelect={(lat, lng) => {
|
||||
setEditPhotoLat(lat);
|
||||
setEditPhotoLng(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}
|
||||
alt="Fullscreen photo"
|
||||
className="max-w-full max-h-full object-contain select-none animate-in zoom-in-95 duration-200"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -38,6 +38,8 @@
|
||||
"cache-manager": "^7.2.8",
|
||||
"cache-manager-redis-yet": "^5.1.5",
|
||||
"dotenv": "^17.4.2",
|
||||
"exifr": "^7.1.3",
|
||||
"heic-convert": "^2.1.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
@@ -4877,6 +4879,12 @@
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/exifr": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
|
||||
"integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
@@ -5412,6 +5420,32 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/heic-convert": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/heic-convert/-/heic-convert-2.1.0.tgz",
|
||||
"integrity": "sha512-1qDuRvEHifTVAj3pFIgkqGgJIr0M3X7cxEPjEp0oG4mo8GFjq99DpCo8Eg3kg17Cy0MTjxpFdoBHOatj7ZVKtg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"heic-decode": "^2.0.0",
|
||||
"jpeg-js": "^0.4.4",
|
||||
"pngjs": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/heic-decode": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz",
|
||||
"integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"libheif-js": "^1.19.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hookified": {
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
|
||||
@@ -5663,6 +5697,12 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jpeg-js": {
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
|
||||
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -5832,6 +5872,15 @@
|
||||
"leaflet": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/libheif-js": {
|
||||
"version": "1.19.8",
|
||||
"resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.19.8.tgz",
|
||||
"integrity": "sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ==",
|
||||
"license": "LGPL-3.0",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -6928,6 +6977,15 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
|
||||
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
|
||||