diff --git a/backend/scratch/find-gps-images.js b/backend/scratch/find-gps-images.js new file mode 100644 index 0000000..8e15fb5 --- /dev/null +++ b/backend/scratch/find-gps-images.js @@ -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(); diff --git a/backend/scratch/test-exif.js b/backend/scratch/test-exif.js new file mode 100644 index 0000000..7fb3e07 --- /dev/null +++ b/backend/scratch/test-exif.js @@ -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(); diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 9661256..8f51ba6 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -21,7 +21,7 @@ - + @@ -30,7 +30,7 @@ - +
diff --git a/frontend/src/components/ImageUploader.tsx b/frontend/src/components/ImageUploader.tsx new file mode 100644 index 0000000..37056be --- /dev/null +++ b/frontend/src/components/ImageUploader.tsx @@ -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 }) => { + 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) => { + 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 }; +}; diff --git a/frontend/src/components/LocationTimelineSheet.tsx b/frontend/src/components/LocationTimelineSheet.tsx new file mode 100644 index 0000000..a1617dc --- /dev/null +++ b/frontend/src/components/LocationTimelineSheet.tsx @@ -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 = ({ 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 ( +
+ {/* Dynamic Header Drag/Close Strip */} +
+
+

{locationName || "Hành trình tại địa điểm"}

+

Tổng hợp {photos.length} khoảnh khắc từ cộng đồng

+
+ +
+ + {/* THE CHRONOLOGICAL TIMELINE STREAM CANVAS */} +
+ {/* Vertical Timeline Track Line */} +
+ + {chronologicalPhotos.map((photo) => ( +
+ {/* Timeline Node Circle Asset Indicator */} +
+ + {/* Core Content Card Box */} +
+ {/* Meta row identifier */} +
+
+
+ {photo.uploaderAvatar ? ( + + ) : ( + photo.uploaderName.charAt(0).toUpperCase() + )} +
+ {photo.uploaderName} + {photo.isGuest && Khách} +
+
+ + {new Date(photo.capturedAt).toLocaleDateString('vi-VN')} +
+
+ + {/* Clickable Card Thumbnail Container */} +
onSelectPhoto(photo.id)} + className="w-full aspect-video rounded-xl overflow-hidden bg-slate-900 relative cursor-pointer active:scale-[0.99] transition-transform" + > + Timeline view +
+ + {photo.description &&

{photo.description}

} +
+
+ ))} +
+
+ ); +}; diff --git a/frontend/src/components/admin/AdminPhotoEditModal.tsx b/frontend/src/components/admin/AdminPhotoEditModal.tsx new file mode 100644 index 0000000..a52565b --- /dev/null +++ b/frontend/src/components/admin/AdminPhotoEditModal.tsx @@ -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 = ({ 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 ( +
+
+
+

Quản Trị - Sửa Thông Tin Ảnh Public

+ +
+ +
+ + setFormData({...formData, title: e.target.value})} + /> +
+ +
+ +