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);
|
||||
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([
|
||||
(0, common_1.Get)(),
|
||||
@@ -2458,6 +2548,15 @@ __decorate([
|
||||
__metadata("design:paramtypes", [String, Object, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], 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([
|
||||
(0, common_1.Controller)('public-photos'),
|
||||
__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 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';
|
||||
@@ -2374,6 +2374,115 @@ class PublicPhotoController {
|
||||
|
||||
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({
|
||||
|
||||
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" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -400,12 +400,20 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
<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 cursor-zoom-in md:h-full md:max-h-none"
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
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 */}
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
} 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-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 */}
|
||||
@@ -507,12 +537,15 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
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: `
|
||||
<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">
|
||||
<img src="${latestPhoto.imageUrl}" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
@@ -524,7 +557,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
${photoGroup.length}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</a>
|
||||
`,
|
||||
iconSize: [48, 48],
|
||||
iconAnchor: [24, 24]
|
||||
@@ -588,10 +621,22 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
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)}
|
||||
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) =>
|
||||
|
||||
Reference in New Issue
Block a user