fix: sửa lỗi hiển thị thumbnail khi chia sẻ trên facebook
This commit is contained in:
Vendored
+99
@@ -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,
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+110
-1
@@ -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({
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+8
-8
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user