fix: sửa lỗi hiển thị thumbnail khi chia sẻ trên facebook

This commit is contained in:
2026-06-20 14:44:31 +07:00
parent fdf41e05fc
commit e34d197dd0
9 changed files with 284 additions and 23 deletions
+99
View File
@@ -2434,6 +2434,96 @@ let PublicPhotoController = class PublicPhotoController {
this.commentGateway.notifyNewPhotoComment(photoId, comment); this.commentGateway.notifyNewPhotoComment(photoId, comment);
return comment; return comment;
} }
async sharePhoto(photoId, req, res) {
const photo = await this.prisma.photo.findUnique({
where: { id: photoId }
});
if (!photo) {
throw new common_1.NotFoundException('Không tìm thấy ảnh.');
}
let host = req.headers['x-forwarded-host'] || req.headers.host || '';
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}`;
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;
if (meta.title && meta.title.trim()) {
title = meta.title;
}
if (meta.description && meta.description.trim()) {
description = meta.description;
}
}
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}`;
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 (0, sharp_1.default)(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);
}
}; };
__decorate([ __decorate([
(0, common_1.Get)(), (0, common_1.Get)(),
@@ -2458,6 +2548,15 @@ __decorate([
__metadata("design:paramtypes", [String, Object, Object]), __metadata("design:paramtypes", [String, Object, Object]),
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], PublicPhotoController.prototype, "addPhotoComment", null); ], PublicPhotoController.prototype, "addPhotoComment", null);
__decorate([
(0, common_1.Get)(':photoId/share'),
__param(0, (0, common_1.Param)('photoId', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Req)()),
__param(2, (0, common_1.Res)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [String, Object, Object]),
__metadata("design:returntype", Promise)
], PublicPhotoController.prototype, "sharePhoto", null);
PublicPhotoController = __decorate([ PublicPhotoController = __decorate([
(0, common_1.Controller)('public-photos'), (0, common_1.Controller)('public-photos'),
__metadata("design:paramtypes", [prisma_service_1.PrismaService, __metadata("design:paramtypes", [prisma_service_1.PrismaService,
+1 -1
View File
File diff suppressed because one or more lines are too long
+110 -1
View File
@@ -9,7 +9,7 @@ import sharp from 'sharp';
import exifr from 'exifr'; import exifr from 'exifr';
import heicConvert from 'heic-convert'; import heicConvert from 'heic-convert';
import { NestFactory } from '@nestjs/core'; 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 { NestExpressApplication } from '@nestjs/platform-express';
import { FilesInterceptor } from '@nestjs/platform-express'; import { FilesInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer'; import { diskStorage } from 'multer';
@@ -2374,6 +2374,115 @@ class PublicPhotoController {
return 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({ @Module({
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>Travel Planner</title> <title>Travel Planner</title>
<script type="module" crossorigin src="/assets/index-DvhesUei.js"></script> <script type="module" crossorigin src="/assets/index-CtrQmjY1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-i0kJVU1C.css"> <link rel="stylesheet" crossorigin href="/assets/index-i0kJVU1C.css">
</head> </head>
<body> <body>
+14 -6
View File
@@ -400,12 +400,20 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
<span>{likeCount}</span> <span>{likeCount}</span>
</button> </button>
<img <a
src={photo.imageUrl} href={`${window.location.origin}/api/v1/public-photos/${photo.id}/share`}
alt="Public Map Upload" 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"
className="w-full h-auto max-h-[85vh] object-contain cursor-zoom-in md:h-full md:max-h-none" onClick={(e) => {
onClick={() => setIsFullscreen(true)} 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> </div>
{/* Info & Timeline overlay inside photo panel */} {/* Info & Timeline overlay inside photo panel */}
+49 -4
View File
@@ -253,11 +253,41 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
() => console.log("Không thể lấy vị trí người dùng") () => console.log("Không thể lấy vị trí người dùng")
); );
} else { } else {
// Cập nhật store để đồng bộ với vị trí khởi tạo từ cache
setMapCenter(initialViewState.center); setMapCenter(initialViewState.center);
} }
}, []); }, []);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const photoId = params.get('photoId');
if (photoId && publicPhotos.length > 0) {
const foundPhoto = publicPhotos.find((p) => p.id === photoId);
if (foundPhoto) {
const lat = foundPhoto.metadata?.lat;
const lng = foundPhoto.metadata?.lng;
if (typeof lat === 'number' && typeof lng === 'number') {
const group = publicPhotos.filter((p) => {
const pLat = p.metadata?.lat;
const pLng = p.metadata?.lng;
return typeof pLat === 'number' && typeof pLng === 'number' &&
Math.abs(pLat - lat) < 0.00001 &&
Math.abs(pLng - lng) < 0.00001;
});
group.sort((a, b) => {
const likesA = a.metadata?.likedUserIds?.length || 0;
const likesB = b.metadata?.likedUserIds?.length || 0;
return likesB - likesA;
});
setSelectedPhoto(foundPhoto);
setSelectedPhotoGroup(group);
} else {
setSelectedPhoto(foundPhoto);
setSelectedPhotoGroup([foundPhoto]);
}
}
}
}, [publicPhotos]);
return ( return (
<div className="h-dvh w-full relative overflow-hidden"> <div className="h-dvh w-full relative overflow-hidden">
{/* Top Bar Container - Chứa tất cả các nút điều hướng phía trên */} {/* Top Bar Container - Chứa tất cả các nút điều hướng phía trên */}
@@ -507,12 +537,15 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
click: () => { click: () => {
setSelectedPhoto(latestPhoto); setSelectedPhoto(latestPhoto);
setSelectedPhotoGroup(photoGroup); 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({ icon={L.divIcon({
className: 'custom-photo-bubble', className: 'custom-photo-bubble',
html: ` html: `
<div class="relative group"> <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"> <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" /> <img src="${latestPhoto.imageUrl}" class="w-full h-full object-cover" />
</div> </div>
@@ -524,7 +557,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
${photoGroup.length} ${photoGroup.length}
</div> </div>
` : ''} ` : ''}
</div> </a>
`, `,
iconSize: [48, 48], iconSize: [48, 48],
iconAnchor: [24, 24] iconAnchor: [24, 24]
@@ -588,10 +621,22 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
onClose={() => { onClose={() => {
setSelectedPhoto(null); setSelectedPhoto(null);
setSelectedPhotoGroup([]); 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} photo={selectedPhoto}
photoGroup={selectedPhotoGroup} photoGroup={selectedPhotoGroup}
onSelectPhoto={(photo) => setSelectedPhoto(photo)} 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} onLoginSuccess={onLoginSuccess}
onUpdatePhoto={(updatedPhoto) => { onUpdatePhoto={(updatedPhoto) => {
setPublicPhotos((prev) => setPublicPhotos((prev) =>