feat: implement imageProcessor.ts and fix fullscreen photo lightbox alignment

This commit is contained in:
2026-06-27 21:42:27 +07:00
parent 277f647e40
commit a1bf0d2c08
7 changed files with 424 additions and 254 deletions
+1 -1
View File
@@ -21,7 +21,7 @@
<meta name="twitter:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
<meta name="twitter:description" content="Khám phá những hành trình tuyệt vời và chia sẻ khoảnh khắc đẹp với cộng đồng du lịch." />
<meta name="twitter:image" content="https://yotrip.labz.io.vn/background.avif" />
<script type="module" crossorigin src="/assets/index-BuWSIZwN.js"></script>
<script type="module" crossorigin src="/assets/index-BDb3Pmxn.js"></script>
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-Cyuzqnbw.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-others-VIU5qAPG.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-editor-DOXkNaRS.js">
+26 -16
View File
@@ -823,24 +823,34 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
{isFullscreen && (
<div
className="fixed inset-0 z-[9999] bg-black/95 flex items-end sm:items-center justify-center cursor-zoom-out animate-in fade-in duration-200"
className="fixed inset-0 bg-black/95 backdrop-blur-sm flex flex-col items-center justify-center z-[999999] overflow-hidden animate-fade-in cursor-zoom-out"
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"
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
className={`max-w-full max-h-full sm:max-w-screen-md object-contain select-none animate-in zoom-in-95 duration-200 ${
!isLoggedIn ? 'pointer-events-none' : ''
}`}
/>
{/* Close Button Top Tracker Bar Container */}
<div className="absolute top-6 right-6 z-50">
<button
onClick={() => setIsFullscreen(false)}
className="p-3 bg-black/50 hover:bg-black/75 border border-white/10 rounded-full text-white transition-colors cursor-pointer"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Image wrapper frame context forcing clean alignment metrics */}
<div className="w-full h-full flex items-center justify-center p-4">
<img
src={photo.imageUrl}
alt="Fullscreen photo"
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
className={`max-w-full max-h-full sm:max-w-screen-md object-contain select-none animate-in zoom-in-95 duration-200 ${
!isLoggedIn ? 'pointer-events-none' : ''
}`}
style={{
transform: 'translate3d(0, 0, 0)'
}}
/>
</div>
</div>
)}
</div>
+11 -143
View File
@@ -13,7 +13,7 @@ import { AppDownloadBanner } from '../components/layout/AppDownloadBanner';
import { useNotification } from '@/hooks/useNotification';
import { processImageModeration } from '../hooks/useImageModeration';
import { useTranslation } from '../hooks/useTranslation';
import { compressImage } from '../utils/image';
import { processAndResizeImage } from '../utils/imageProcessor';
interface LandingPageProps {
onContinue?: () => void;
@@ -27,130 +27,6 @@ interface LandingPageProps {
onOpenNavigation?: (payload: any) => void;
}
// Helper to extract GPS location from EXIF tags in JPEG/JPG files
function getExifGps(file: File): Promise<{ latitude: number; longitude: number } | null> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const buffer = e.target?.result as ArrayBuffer;
const view = new DataView(buffer);
if (view.byteLength < 4) {
resolve(null);
return;
}
if (view.getUint16(0, false) !== 0xFFD8) {
resolve(null);
return;
}
let offset = 2;
const length = view.byteLength;
while (offset < length - 2) {
const marker = view.getUint16(offset, false);
if (marker === 0xFFE1) {
const app1Length = view.getUint16(offset + 2, false);
if (offset + 4 + app1Length > length) {
resolve(null);
return;
}
const exifHeader = view.getUint32(offset + 4, false);
if (exifHeader === 0x45786966) {
const tiffOffset = offset + 10;
const bigEndian = view.getUint16(tiffOffset, false) === 0x4D4D;
if (view.getUint16(tiffOffset + 2, bigEndian) !== 0x002A) {
resolve(null);
return;
}
const firstIFD = view.getUint32(tiffOffset + 4, bigEndian);
let ifdOffset = tiffOffset + firstIFD;
if (ifdOffset + 2 > length) {
resolve(null);
return;
}
const numEntries = view.getUint16(ifdOffset, bigEndian);
let gpsInfoOffset = 0;
for (let i = 0; i < numEntries; i++) {
const entryOffset = ifdOffset + 2 + i * 12;
if (entryOffset + 12 > length) break;
const tag = view.getUint16(entryOffset, bigEndian);
if (tag === 0x8825) {
gpsInfoOffset = view.getUint32(entryOffset + 8, bigEndian);
break;
}
}
if (gpsInfoOffset) {
const gpsIFDOffset = tiffOffset + gpsInfoOffset;
if (gpsIFDOffset + 2 > length) {
resolve(null);
return;
}
const numGpsEntries = view.getUint16(gpsIFDOffset, bigEndian);
let latRef = 'N';
let lonRef = 'E';
let latVal: number[] = [];
let lonVal: number[] = [];
for (let i = 0; i < numGpsEntries; i++) {
const entryOffset = gpsIFDOffset + 2 + i * 12;
if (entryOffset + 12 > length) break;
const tag = view.getUint16(entryOffset, bigEndian);
if (tag === 1) {
latRef = String.fromCharCode(view.getUint8(entryOffset + 8));
} else if (tag === 2) {
const offsetVal = view.getUint32(entryOffset + 8, bigEndian);
latVal = readRationalArray(view, tiffOffset + offsetVal, bigEndian, length);
} else if (tag === 3) {
lonRef = String.fromCharCode(view.getUint8(entryOffset + 8));
} else if (tag === 4) {
const offsetVal = view.getUint32(entryOffset + 8, bigEndian);
lonVal = readRationalArray(view, tiffOffset + offsetVal, bigEndian, length);
}
}
if (latVal.length === 3 && lonVal.length === 3) {
const latitude = convertDMSToDD(latVal[0], latVal[1], latVal[2], latRef);
const longitude = convertDMSToDD(lonVal[0], lonVal[1], lonVal[2], lonRef);
resolve({ latitude, longitude });
return;
}
}
}
break;
} else if (marker >= 0xFFD0 && marker <= 0xFFD9) {
offset += 2;
} else {
const blockLength = view.getUint16(offset + 2, false);
offset += 2 + blockLength;
}
}
resolve(null);
} catch (err) {
console.error('Error parsing EXIF:', err);
resolve(null);
}
};
reader.onerror = () => resolve(null);
reader.readAsArrayBuffer(file.slice(0, 128 * 1024));
});
}
function readRationalArray(view: DataView, offset: number, bigEndian: boolean, maxLen: number): number[] {
const values = [];
if (offset + 24 > maxLen) return [];
for (let i = 0; i < 3; i++) {
const num = view.getUint32(offset + i * 8, bigEndian);
const den = view.getUint32(offset + i * 8 + 4, bigEndian);
values.push(den === 0 ? 0 : num / den);
}
return values;
}
function convertDMSToDD(degrees: number, minutes: number, seconds: number, ref: string): number {
let dd = degrees + minutes / 60 + seconds / 3600;
if (ref === 'S' || ref === 'W') {
dd = -dd;
}
return dd;
}
export const LandingPage: React.FC<LandingPageProps> = ({
onGoToSignup,
onGoToMap,
@@ -318,30 +194,22 @@ export const LandingPage: React.FC<LandingPageProps> = ({
notify({ title: 'Đang xử lý...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
try {
// Nén ảnh trước
const compressedFile = await compressImage(file);
// Process image: Reads EXIF data and forces 2K resizing on device memory
const { file: processedFile, latitude: exifLat, longitude: exifLng } = await processAndResizeImage(file);
// 0. Kiểm duyệt ảnh
const moderationResult = await processImageModeration(compressedFile);
const moderationResult = await processImageModeration(processedFile);
if (moderationResult.blocked) {
notify({ title: 'Ảnh bị từ chối', message: `Ảnh ${file.name} chứa nội dung không phù hợp và bị chặn.`, type: 'error' });
return;
}
const processedFile = moderationResult.file;
const finalProcessedFile = moderationResult.file;
// Determine image upload coordinates based on priority checklist:
let finalLat: number | null = null;
let finalLng: number | null = null;
let finalLat: number | null = exifLat;
let finalLng: number | null = exifLng;
// 1. Read EXIF GPS metadata location from the uploaded image itself
try {
const exifLoc = await getExifGps(file);
if (exifLoc) {
finalLat = exifLoc.latitude;
finalLng = exifLoc.longitude;
console.log('[Upload Location] Priority 1: EXIF data coordinates found:', finalLat, finalLng);
}
} catch (e) {
console.error('[Upload Location] Error reading EXIF from file:', e);
if (finalLat !== null && finalLng !== null) {
console.log('[Upload Location] Priority 1: EXIF data coordinates found:', finalLat, finalLng);
}
// 2. Get current mobile/device GPS position of the user
@@ -396,11 +264,11 @@ export const LandingPage: React.FC<LandingPageProps> = ({
}
// Save file and resolved coordinates into state
setPendingPhotoFile(processedFile);
setPendingPhotoFile(finalProcessedFile);
setPendingPhotoLocation({ latitude: finalLat, longitude: finalLng });
// Tạo preview URL cho ảnh
const previewUrl = URL.createObjectURL(processedFile);
const previewUrl = URL.createObjectURL(finalProcessedFile);
setPhotoPreviewUrl(previewUrl);
setIsTagsModalOpen(true);
+205
View File
@@ -0,0 +1,205 @@
// Helper to extract GPS location from EXIF tags in JPEG/JPG files
function getExifGps(file: File): Promise<{ latitude: number; longitude: number } | null> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const buffer = e.target?.result as ArrayBuffer;
const view = new DataView(buffer);
if (view.byteLength < 4) {
resolve(null);
return;
}
if (view.getUint16(0, false) !== 0xFFD8) {
resolve(null);
return;
}
let offset = 2;
const length = view.byteLength;
while (offset < length - 2) {
const marker = view.getUint16(offset, false);
if (marker === 0xFFE1) {
const app1Length = view.getUint16(offset + 2, false);
if (offset + 4 + app1Length > length) {
resolve(null);
return;
}
const exifHeader = view.getUint32(offset + 4, false);
if (exifHeader === 0x45786966) {
const tiffOffset = offset + 10;
const bigEndian = view.getUint16(tiffOffset, false) === 0x4D4D;
if (view.getUint16(tiffOffset + 2, bigEndian) !== 0x002A) {
resolve(null);
return;
}
const firstIFD = view.getUint32(tiffOffset + 4, bigEndian);
let ifdOffset = tiffOffset + firstIFD;
if (ifdOffset + 2 > length) {
resolve(null);
return;
}
const numEntries = view.getUint16(ifdOffset, bigEndian);
let gpsInfoOffset = 0;
for (let i = 0; i < numEntries; i++) {
const entryOffset = ifdOffset + 2 + i * 12;
if (entryOffset + 12 > length) break;
const tag = view.getUint16(entryOffset, bigEndian);
if (tag === 0x8825) {
gpsInfoOffset = view.getUint32(entryOffset + 8, bigEndian);
break;
}
}
if (gpsInfoOffset) {
const gpsIFDOffset = tiffOffset + gpsInfoOffset;
if (gpsIFDOffset + 2 > length) {
resolve(null);
return;
}
const numGpsEntries = view.getUint16(gpsIFDOffset, bigEndian);
let latRef = 'N';
let lonRef = 'E';
let latVal: number[] = [];
let lonVal: number[] = [];
for (let i = 0; i < numGpsEntries; i++) {
const entryOffset = gpsIFDOffset + 2 + i * 12;
if (entryOffset + 12 > length) break;
const tag = view.getUint16(entryOffset, bigEndian);
if (tag === 1) {
latRef = String.fromCharCode(view.getUint8(entryOffset + 8));
} else if (tag === 2) {
const offsetVal = view.getUint32(entryOffset + 8, bigEndian);
latVal = readRationalArray(view, tiffOffset + offsetVal, bigEndian, length);
} else if (tag === 3) {
lonRef = String.fromCharCode(view.getUint8(entryOffset + 8));
} else if (tag === 4) {
const offsetVal = view.getUint32(entryOffset + 8, bigEndian);
lonVal = readRationalArray(view, tiffOffset + offsetVal, bigEndian, length);
}
}
if (latVal.length === 3 && lonVal.length === 3) {
const latitude = convertDMSToDD(latVal[0], latVal[1], latVal[2], latRef);
const longitude = convertDMSToDD(lonVal[0], lonVal[1], lonVal[2], lonRef);
resolve({ latitude, longitude });
return;
}
}
}
break;
} else if (marker >= 0xFFD0 && marker <= 0xFFD9) {
offset += 2;
} else {
const blockLength = view.getUint16(offset + 2, false);
offset += 2 + blockLength;
}
}
resolve(null);
} catch (err) {
console.error('Error parsing EXIF:', err);
resolve(null);
}
};
reader.onerror = () => resolve(null);
reader.readAsArrayBuffer(file.slice(0, 128 * 1024));
});
}
function readRationalArray(view: DataView, offset: number, bigEndian: boolean, maxLen: number): number[] {
const values = [];
if (offset + 24 > maxLen) return [];
for (let i = 0; i < 3; i++) {
const num = view.getUint32(offset + i * 8, bigEndian);
const den = view.getUint32(offset + i * 8 + 4, bigEndian);
values.push(den === 0 ? 0 : num / den);
}
return values;
}
function convertDMSToDD(degrees: number, minutes: number, seconds: number, ref: string): number {
let dd = degrees + minutes / 60 + seconds / 3600;
if (ref === 'S' || ref === 'W') {
dd = -dd;
}
return dd;
}
interface ProcessedImageResult {
file: File;
latitude: number | null;
longitude: number | null;
}
export const processAndResizeImage = async (file: File): Promise<ProcessedImageResult> => {
// 1. Read EXIF coordinates first from the original file
let latitude: number | null = null;
let longitude: number | null = null;
try {
const gps = await getExifGps(file);
if (gps) {
latitude = gps.latitude;
longitude = gps.longitude;
}
} catch (e) {
console.error('[imageProcessor] Failed to read EXIF GPS:', e);
}
// 2. Perform resizing to maximum 2048px on its longest edge
return new Promise((resolve) => {
if (!file.type.startsWith('image/')) {
return resolve({ file, latitude, longitude });
}
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target?.result as string;
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
const MAX_SIZE = 2048;
if (width > MAX_SIZE || height > MAX_SIZE) {
if (width > height) {
height = Math.round((height * MAX_SIZE) / width);
width = MAX_SIZE;
} else {
width = Math.round((width * MAX_SIZE) / height);
height = MAX_SIZE;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
return resolve({ file, latitude, longitude });
}
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
if (blob) {
const newName = file.name.replace(/\.[^/.]+$/, "") + ".jpg";
const resizedFile = new File([blob], newName, {
type: 'image/jpeg',
lastModified: Date.now()
});
resolve({
file: resizedFile,
latitude,
longitude
});
} else {
resolve({ file, latitude, longitude });
}
}, 'image/jpeg', 0.88); // 88% quality sweet-spot
};
img.onerror = () => {
resolve({ file, latitude, longitude });
};
};
reader.onerror = () => {
resolve({ file, latitude, longitude });
};
});
};