fix: photo edit by admin
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
import exifr from 'exifr';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const searchDir = '.';
|
||||||
|
|
||||||
|
async function walk(dir) {
|
||||||
|
let files = [];
|
||||||
|
const list = fs.readdirSync(dir);
|
||||||
|
for (const file of list) {
|
||||||
|
if (file === 'node_modules' || file === '.git' || file === '.vscode') continue;
|
||||||
|
const fullPath = path.join(dir, file);
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
files = files.concat(await walk(fullPath));
|
||||||
|
} else {
|
||||||
|
if (['.jpg', '.jpeg', '.png'].includes(path.extname(file).toLowerCase())) {
|
||||||
|
files.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const images = await walk(searchDir);
|
||||||
|
console.log(`Found ${images.length} images to scan...`);
|
||||||
|
for (const img of images) {
|
||||||
|
try {
|
||||||
|
const gps = await exifr.gps(img);
|
||||||
|
if (gps) {
|
||||||
|
console.log(`FOUND IMAGE WITH GPS: ${img}`, gps);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Scan completed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import EXIF from 'exif-js';
|
||||||
|
import exifr from 'exifr';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
const files = [
|
||||||
|
'./node_modules/exif-js/example/dsc_09827.jpg',
|
||||||
|
'./node_modules/exif-js/example/DSCN0614_small.jpg',
|
||||||
|
'./node_modules/exif-js/example/Bloated-Hero.jpg',
|
||||||
|
'./node_modules/exif-js/example/Bush-dog.jpg'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const f of files) {
|
||||||
|
console.log(`--- Testing file: ${f} ---`);
|
||||||
|
try {
|
||||||
|
const gpsExifr = await exifr.gps(f);
|
||||||
|
console.log(' exifr.gps:', gpsExifr);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' exifr error:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(f);
|
||||||
|
const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
||||||
|
const parsed = await exifr.parse(arrayBuffer);
|
||||||
|
console.log(' exifr.parse output:', parsed ? {
|
||||||
|
latitude: parsed.latitude,
|
||||||
|
longitude: parsed.longitude,
|
||||||
|
DateTimeOriginal: parsed.DateTimeOriginal,
|
||||||
|
CreateDate: parsed.CreateDate,
|
||||||
|
ModifyDate: parsed.ModifyDate
|
||||||
|
} : 'null');
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' exifr.parse error:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test();
|
||||||
Vendored
+2
-2
@@ -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: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: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" />
|
<meta name="twitter:image" content="https://yotrip.labz.io.vn/background.avif" />
|
||||||
<script type="module" crossorigin src="/assets/index-R09P7UbD.js"></script>
|
<script type="module" crossorigin src="/assets/index-CSiax1SI.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CMxvf4Kt.js">
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CMxvf4Kt.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/vendor-others-CNhtyHGs.js">
|
<link rel="modulepreload" crossorigin href="/assets/vendor-others-CNhtyHGs.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/vendor-editor-BDwQQzB8.js">
|
<link rel="modulepreload" crossorigin href="/assets/vendor-editor-BDwQQzB8.js">
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
<link rel="modulepreload" crossorigin href="/assets/vendor-pdf-C3XQY6t9.js">
|
<link rel="modulepreload" crossorigin href="/assets/vendor-pdf-C3XQY6t9.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/vendor-maps-CcXJxNtP.css">
|
<link rel="stylesheet" crossorigin href="/assets/vendor-maps-CcXJxNtP.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/vendor-react-CuSj0z1j.css">
|
<link rel="stylesheet" crossorigin href="/assets/vendor-react-CuSj0z1j.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-w726aohe.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CIipVipb.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { processMobileImageUpload } from '../utils/imageMetadataProcessor';
|
||||||
|
|
||||||
|
// Mock/impl api client using standard fetch to match the exact blueprint signature
|
||||||
|
const api = {
|
||||||
|
post: async (url: string, data: FormData, config?: { headers?: Record<string, string> }) => {
|
||||||
|
const response = await fetch(`/api/v1${url}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`,
|
||||||
|
...config?.headers,
|
||||||
|
},
|
||||||
|
body: data,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const resData = await response.json();
|
||||||
|
return { data: resData, status: response.status };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useImageUploadController = () => {
|
||||||
|
const handlePhotoSelection = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const rawFile = event.target.files?.[0];
|
||||||
|
if (!rawFile) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Execute metadata preservation and 2K hardware scaling pipeline sequentially
|
||||||
|
const { compressedBlob, latitude, longitude, capturedAt } = await processMobileImageUpload(rawFile);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
// Append the compressed file object
|
||||||
|
formData.append('photo', compressedBlob, 'yotrip_mobile_upload.jpg');
|
||||||
|
|
||||||
|
// Append verified structural location and timing parameters
|
||||||
|
if (latitude !== null && longitude !== null) {
|
||||||
|
formData.append('latitude', latitude.toString());
|
||||||
|
formData.append('longitude', longitude.toString());
|
||||||
|
}
|
||||||
|
if (capturedAt) {
|
||||||
|
formData.append('capturedAt', capturedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send multipart packet securely to the Debian server endpoint
|
||||||
|
const response = await api.post('/photos/upload-with-meta', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ Photo and spatial markers deployed seamlessly onto core map layer.", response.data);
|
||||||
|
|
||||||
|
} catch (pipelineError) {
|
||||||
|
console.error("Critical block failure during mobile media processing pipeline:", pipelineError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { handlePhotoSelection };
|
||||||
|
};
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Clock, X } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface SharedLocationPhoto {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
uploaderName: string;
|
||||||
|
uploaderAvatar?: string;
|
||||||
|
isGuest: boolean;
|
||||||
|
capturedAt: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelineProps {
|
||||||
|
locationName: string;
|
||||||
|
photos: SharedLocationPhoto[];
|
||||||
|
onSelectPhoto: (photoId: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LocationTimelineSheet: React.FC<TimelineProps> = ({ locationName, photos, onSelectPhoto, onClose }) => {
|
||||||
|
// Sort photos chronologically by capture timestamp
|
||||||
|
const chronologicalPhotos = [...photos].sort(
|
||||||
|
(a, b) => new Date(b.capturedAt).getTime() - new Date(a.capturedAt).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 z-[9999] bg-slate-900 border-t border-slate-800 rounded-t-3xl max-h-[85vh] flex flex-col overflow-hidden text-white text-xs shadow-2xl animate-slide-up">
|
||||||
|
{/* Dynamic Header Drag/Close Strip */}
|
||||||
|
<div className="w-full px-5 py-4 border-b border-slate-800/60 flex justify-between items-center bg-slate-900 sticky top-0 z-10">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-sm text-slate-100 truncate max-w-[70vw]">{locationName || "Hành trình tại địa điểm"}</h3>
|
||||||
|
<p className="text-[10px] text-slate-400">Tổng hợp {photos.length} khoảnh khắc từ cộng đồng</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} className="p-2 bg-slate-800 hover:bg-slate-700 rounded-xl text-slate-350 hover:text-white transition-colors">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* THE CHRONOLOGICAL TIMELINE STREAM CANVAS */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-5 space-y-6 relative">
|
||||||
|
{/* Vertical Timeline Track Line */}
|
||||||
|
<div className="absolute left-[27px] top-6 bottom-6 w-[2px] bg-slate-800" />
|
||||||
|
|
||||||
|
{chronologicalPhotos.map((photo) => (
|
||||||
|
<div key={photo.id} className="flex gap-4 items-start relative group">
|
||||||
|
{/* Timeline Node Circle Asset Indicator */}
|
||||||
|
<div className="w-6 h-6 rounded-full bg-blue-600 border-4 border-slate-900 flex items-center justify-center z-10 shrink-0 shadow-md" />
|
||||||
|
|
||||||
|
{/* Core Content Card Box */}
|
||||||
|
<div className="flex-1 bg-slate-950/50 border border-slate-800/80 rounded-2xl p-3 space-y-3 hover:border-slate-700/60 transition-colors">
|
||||||
|
{/* Meta row identifier */}
|
||||||
|
<div className="flex justify-between items-center text-[10px] text-slate-400">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-4 h-4 rounded-full bg-slate-700 flex items-center justify-center font-bold text-[8px] text-white overflow-hidden shrink-0">
|
||||||
|
{photo.uploaderAvatar ? (
|
||||||
|
<img src={photo.uploaderAvatar} className="object-cover w-full h-full" />
|
||||||
|
) : (
|
||||||
|
photo.uploaderName.charAt(0).toUpperCase()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-slate-300 truncate max-w-[120px]">{photo.uploaderName}</span>
|
||||||
|
{photo.isGuest && <span className="bg-slate-800 text-[8px] px-1 py-0.5 rounded text-slate-500 font-bold">Khách</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3 text-slate-500" />
|
||||||
|
<span>{new Date(photo.capturedAt).toLocaleDateString('vi-VN')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clickable Card Thumbnail Container */}
|
||||||
|
<div
|
||||||
|
onClick={() => onSelectPhoto(photo.id)}
|
||||||
|
className="w-full aspect-video rounded-xl overflow-hidden bg-slate-900 relative cursor-pointer active:scale-[0.99] transition-transform"
|
||||||
|
>
|
||||||
|
<img src={photo.url} alt="Timeline view" className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{photo.description && <p className="text-slate-300 leading-relaxed text-[11px] px-0.5">{photo.description}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { X, Loader2 } from 'lucide-react';
|
||||||
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
|
|
||||||
|
interface PublicPhoto {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
capturedAt: string;
|
||||||
|
isFlagged?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminPhotoEditModalProps {
|
||||||
|
photo: PublicPhoto;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaveSuccess: (updatedPhoto: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toLocalDatetimeString = (isoString: string) => {
|
||||||
|
if (!isoString) return '';
|
||||||
|
const d = new Date(isoString);
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(d.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdminPhotoEditModal: React.FC<AdminPhotoEditModalProps> = ({ photo, onClose, onSaveSuccess }) => {
|
||||||
|
const notify = useNotification();
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: photo.title,
|
||||||
|
description: photo.description,
|
||||||
|
latitude: photo.latitude,
|
||||||
|
longitude: photo.longitude,
|
||||||
|
capturedAt: toLocalDatetimeString(photo.capturedAt),
|
||||||
|
isFlagged: !!photo.isFlagged
|
||||||
|
});
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleUpdateSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const response = await fetch(`/api/v1/admin/photos/${photo.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: formData.title,
|
||||||
|
description: formData.description,
|
||||||
|
latitude: Number(formData.latitude),
|
||||||
|
longitude: Number(formData.longitude),
|
||||||
|
capturedAt: new Date(formData.capturedAt).toISOString(),
|
||||||
|
isFlagged: formData.isFlagged
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.message || 'Cập nhật thất bại.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedPhoto = await response.json();
|
||||||
|
notify({ title: 'Thành công', message: 'Đã cập nhật thông tin ảnh quản trị.', type: 'success' });
|
||||||
|
onSaveSuccess(updatedPhoto);
|
||||||
|
onClose();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[AdminEdit] Failed to save updated metadata overrides:", error);
|
||||||
|
notify({ title: 'Lỗi', message: error.message || 'Không thể cập nhật ảnh.', type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm z-[99999] flex items-center justify-center p-4">
|
||||||
|
<form onSubmit={handleUpdateSubmit} className="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md p-6 text-white text-xs space-y-4 shadow-2xl animate-in zoom-in-95 duration-200">
|
||||||
|
<div className="flex justify-between items-center pb-2 border-b border-slate-800">
|
||||||
|
<h3 className="text-sm font-bold text-blue-400">Quản Trị - Sửa Thông Tin Ảnh Public</h3>
|
||||||
|
<button type="button" onClick={onClose} className="p-1 text-slate-400 hover:text-white rounded-lg transition-colors">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-bold">Tiêu đề ảnh</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-slate-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={e => setFormData({...formData, title: e.target.value})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-bold">Mô tả</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-slate-100 focus:outline-none focus:ring-1 focus:ring-blue-500 h-20 resize-none"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={e => setFormData({...formData, description: e.target.value})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-bold">Vĩ độ (Latitude)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-slate-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
value={formData.latitude}
|
||||||
|
onChange={e => setFormData({...formData, latitude: Number(e.target.value)})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-bold">Kinh độ (Longitude)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-slate-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
value={formData.longitude}
|
||||||
|
onChange={e => setFormData({...formData, longitude: Number(e.target.value)})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-bold">Thời gian chụp (Captured At)</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-slate-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
value={formData.capturedAt}
|
||||||
|
onChange={e => setFormData({...formData, capturedAt: e.target.value})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="isFlagged"
|
||||||
|
className="w-4 h-4 bg-slate-950 border border-slate-800 rounded text-blue-500 focus:ring-0 focus:ring-offset-0"
|
||||||
|
checked={formData.isFlagged}
|
||||||
|
onChange={e => setFormData({...formData, isFlagged: e.target.checked})}
|
||||||
|
/>
|
||||||
|
<label htmlFor="isFlagged" className="text-slate-350 cursor-pointer select-none font-bold">Ẩn / Gắn cờ ảnh (Flagged status)</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-2 border-t border-slate-800">
|
||||||
|
<button type="button" onClick={onClose} className="px-4 py-2.5 bg-slate-800 hover:bg-slate-700 rounded-xl transition-all font-bold">Hủy</button>
|
||||||
|
<button type="submit" disabled={isSubmitting} className="px-4 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 rounded-xl font-bold flex items-center gap-1.5 transition-all">
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
Đang lưu...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Lưu thay đổi'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import exifr from 'exifr';
|
||||||
|
|
||||||
|
interface AdvancedUploadPayload {
|
||||||
|
compressedBlob: Blob;
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
capturedAt: string | null; // ISO Timestamp or raw EXIF date string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const processMobileImageUpload = (file: File): Promise<AdvancedUploadPayload> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
let latitude: number | null = null;
|
||||||
|
let longitude: number | null = null;
|
||||||
|
let capturedAt: string | null = null;
|
||||||
|
|
||||||
|
// Read as ArrayBuffer to lock raw binary metadata blocks safely from being stripped
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
reader.onload = async (event) => {
|
||||||
|
const buffer = event.target?.result as ArrayBuffer;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = await exifr.parse(buffer);
|
||||||
|
if (parsed) {
|
||||||
|
if (typeof parsed.latitude === 'number' && typeof parsed.longitude === 'number') {
|
||||||
|
latitude = parsed.latitude;
|
||||||
|
longitude = parsed.longitude;
|
||||||
|
}
|
||||||
|
const dateObj = parsed.DateTimeOriginal || parsed.CreateDate || parsed.ModifyDate;
|
||||||
|
if (dateObj) {
|
||||||
|
capturedAt = dateObj instanceof Date ? dateObj.toISOString() : new Date(dateObj).toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`📊 Metadata Parsed (exifr) - Lat: ${latitude}, Lng: ${longitude}, Date: ${capturedAt}`);
|
||||||
|
} catch (exifError) {
|
||||||
|
console.error("Failed to parse EXIF via exifr from binary buffer stream:", exifError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. PROCEED TO RE-RENDER AND 2K PRE-COMPRESSION
|
||||||
|
const blobUrl = URL.createObjectURL(file);
|
||||||
|
const img = new Image();
|
||||||
|
img.src = blobUrl;
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
let width = img.width;
|
||||||
|
let height = img.height;
|
||||||
|
const MAX_EDGE = 2048; // Rigid 2K production specification limit
|
||||||
|
|
||||||
|
if (width > height) {
|
||||||
|
if (width > MAX_EDGE) {
|
||||||
|
height = Math.round((height * MAX_EDGE) / width);
|
||||||
|
width = MAX_EDGE;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (height > MAX_EDGE) {
|
||||||
|
width = Math.round((width * MAX_EDGE) / height);
|
||||||
|
height = MAX_EDGE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
return resolve({ compressedBlob: file, latitude, longitude, capturedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
canvas.toBlob((finalBlob) => {
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
resolve({
|
||||||
|
compressedBlob: finalBlob || file,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
capturedAt
|
||||||
|
});
|
||||||
|
}, 'image/jpeg', 0.85); // 85% JPEG compression tier
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
resolve({ compressedBlob: file, latitude, longitude, capturedAt });
|
||||||
|
};
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
resolve({ compressedBlob: file, latitude, longitude, capturedAt });
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user