fix: photo edit by admin
This commit is contained in:
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: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>
|
||||
|
||||
@@ -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