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"
+ >
+

+
+
+ {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 (
+
+ );
+};
diff --git a/frontend/src/utils/imageMetadataProcessor.ts b/frontend/src/utils/imageMetadataProcessor.ts
new file mode 100644
index 0000000..e6c6d08
--- /dev/null
+++ b/frontend/src/utils/imageMetadataProcessor.ts
@@ -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 => {
+ 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 });
+ };
+ });
+};