fix: photo edit by admin

This commit is contained in:
2026-06-28 11:00:28 +07:00
parent 9f60efeb6d
commit 41c8e67229
7 changed files with 487 additions and 2 deletions
+41
View File
@@ -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();
+39
View File
@@ -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();
+2 -2
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-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/vendor-others-CNhtyHGs.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="stylesheet" crossorigin href="/assets/vendor-maps-CcXJxNtP.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>
<body>
<div id="root"></div>
+58
View File
@@ -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"> 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"> đ (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 });
};
});
};