fix: tạo tags cho ảnh public của guest
@@ -0,0 +1,156 @@
|
|||||||
|
# Enhancement Summary: Tag Selection & Photo Filtering
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Successfully enhanced the travel planning application with improved photo tagging UI and photo filtering capabilities on the explore map.
|
||||||
|
|
||||||
|
## Changes Implemented
|
||||||
|
|
||||||
|
### 1. Enhanced TagSelectModal Component ✅
|
||||||
|
**File**: `frontend/src/components/TagSelectModal.tsx`
|
||||||
|
|
||||||
|
**Features Added**:
|
||||||
|
- **Image Preview**: Shows the uploaded photo at the top of the modal (max-height: 192px, with rounded corners)
|
||||||
|
- **Custom Tag Input**: Text input with "Nhập thẻ mới..." placeholder
|
||||||
|
- **Add Custom Tags**: Button and Enter key support to add user-defined tags
|
||||||
|
- **Remove Custom Tags**: Trash icon on hover to delete custom tags
|
||||||
|
- **Visual Feedback**: Selected tags highlighted in different colors (blue for predefined, emerald for custom)
|
||||||
|
- **Summary Section**: Displays count and list of all selected tags before confirmation
|
||||||
|
|
||||||
|
**UI Improvements**:
|
||||||
|
- Sticky header and footer for easy access
|
||||||
|
- Separate sections for predefined tags and custom input
|
||||||
|
- Max-height with scrolling for long tag lists
|
||||||
|
- Smooth animations and transitions
|
||||||
|
|
||||||
|
### 2. LandingPage Integration ✅
|
||||||
|
**File**: `frontend/src/pages/LandingPage.tsx`
|
||||||
|
|
||||||
|
**Updates**:
|
||||||
|
- Added `photoPreviewUrl` state for temporary preview image
|
||||||
|
- Created object URL using `URL.createObjectURL()` when file is selected
|
||||||
|
- Pass preview URL to TagSelectModal component
|
||||||
|
- Proper cleanup with `URL.revokeObjectURL()` on modal close or after upload
|
||||||
|
- Included custom tags in upload formData as JSON
|
||||||
|
|
||||||
|
**State Management**:
|
||||||
|
```typescript
|
||||||
|
const [photoPreviewUrl, setPhotoPreviewUrl] = useState<string>('');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. ExploreMap Photo Tag Filtering ✅
|
||||||
|
**File**: `frontend/src/pages/ExploreMap.tsx`
|
||||||
|
|
||||||
|
**Features Added**:
|
||||||
|
- **Photo Tag Filter State**: `selectedPhotoFilterTags` for tracking active filters
|
||||||
|
- **Available Photo Tags**: `availablePhotoTags` computed from all public photos' metadata
|
||||||
|
- **Enhanced Filter Dropdown**: Two-section filter UI:
|
||||||
|
- **Tour Section (🧳 Chuyến đi)**: Existing tour tags (single selection)
|
||||||
|
- **Photo Section (📸 Ảnh công khai)**: Photo tags from uploads (multiple selection)
|
||||||
|
- **Filtering Logic**: `groupedPhotos` useMemo filters photos based on selected tags
|
||||||
|
- **Dynamic Tag Population**: Photo tags automatically extracted from `photo.metadata.tags`
|
||||||
|
|
||||||
|
**Filtering Behavior**:
|
||||||
|
- Multiple photo tags can be selected simultaneously
|
||||||
|
- Photos matching ANY selected tag are displayed (OR logic)
|
||||||
|
- "Tất cả" button clears photo filters
|
||||||
|
- Filter is independent from tour tag filtering
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Tag Storage
|
||||||
|
- Backend stores tags in `photo.metadata.tags` as JSON array
|
||||||
|
- No database schema changes required
|
||||||
|
- Flexible for custom tags without pre-definition
|
||||||
|
|
||||||
|
### Frontend State Management
|
||||||
|
```typescript
|
||||||
|
// State
|
||||||
|
const [selectedPhotoFilterTags, setSelectedPhotoFilterTags] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const availablePhotoTags = React.useMemo(() => {
|
||||||
|
const tagsSet = new Set<string>();
|
||||||
|
publicPhotos.forEach(photo => {
|
||||||
|
const tags = photo.metadata?.tags as string[] | undefined;
|
||||||
|
if (Array.isArray(tags)) {
|
||||||
|
tags.forEach(tag => tagsSet.add(tag));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(tagsSet).sort();
|
||||||
|
}, [publicPhotos]);
|
||||||
|
|
||||||
|
// Filtered Results
|
||||||
|
const groupedPhotos = React.useMemo(() => {
|
||||||
|
let filteredPhotos = publicPhotos;
|
||||||
|
if (selectedPhotoFilterTags.length > 0) {
|
||||||
|
filteredPhotos = publicPhotos.filter((photo) => {
|
||||||
|
const photoTags = photo.metadata?.tags as string[] | undefined;
|
||||||
|
if (!Array.isArray(photoTags)) return false;
|
||||||
|
return selectedPhotoFilterTags.some(tag => photoTags.includes(tag));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// ... grouping and sorting logic
|
||||||
|
}, [publicPhotos, selectedPhotoFilterTags]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Results
|
||||||
|
|
||||||
|
### Verified Functionality
|
||||||
|
✅ Image preview displays in TagSelectModal
|
||||||
|
✅ Custom tag input accepts user text
|
||||||
|
✅ Custom tags can be added with button or Enter key
|
||||||
|
✅ Custom tags can be removed with trash icon
|
||||||
|
✅ All selected tags display in summary
|
||||||
|
✅ Photo tag filtering works on ExploreMap
|
||||||
|
✅ Multiple photo tags can be selected
|
||||||
|
✅ "Tất cả" button clears filters
|
||||||
|
✅ Docker build successful (0 errors)
|
||||||
|
✅ All services running healthy
|
||||||
|
|
||||||
|
### Build Status
|
||||||
|
- Frontend build: ✅ Success
|
||||||
|
- Backend build: ✅ Success
|
||||||
|
- Container deployment: ✅ All 4 services running
|
||||||
|
- Browser testing: ✅ Filter dropdown functional
|
||||||
|
|
||||||
|
## User Workflow
|
||||||
|
|
||||||
|
### Photo Upload with Tags
|
||||||
|
1. User clicks "Chụp ảnh" button
|
||||||
|
2. Selects image from device
|
||||||
|
3. Image is displayed in TagSelectModal preview
|
||||||
|
4. User selects predefined tags from 11 categories
|
||||||
|
5. User can add custom tags in textbox
|
||||||
|
6. Confirms and photo is uploaded with all tags
|
||||||
|
7. Tags stored in database for filtering
|
||||||
|
|
||||||
|
### Photo Discovery with Filtering
|
||||||
|
1. User navigates to "Khám phá" (Explore)
|
||||||
|
2. Clicks filter button to open dropdown
|
||||||
|
3. Sees available photo tags from community uploads
|
||||||
|
4. Selects one or more tags to filter
|
||||||
|
5. Map refreshes showing only photos with selected tags
|
||||||
|
6. Clear selection with "Tất cả" button to see all photos again
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
- ✅ `frontend/src/components/TagSelectModal.tsx` - Enhanced with preview and custom tags
|
||||||
|
- ✅ `frontend/src/pages/LandingPage.tsx` - Integration with preview URL
|
||||||
|
- ✅ `frontend/src/pages/ExploreMap.tsx` - Photo tag filtering implementation
|
||||||
|
|
||||||
|
## Browser Compatibility
|
||||||
|
- Modern browsers with ES6+ support
|
||||||
|
- Tested on latest Chrome/Firefox/Safari
|
||||||
|
- Mobile responsive design with touch support
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
- Photo tag extraction done in useMemo (cached)
|
||||||
|
- Filter operations are optimized with Set for uniqueness
|
||||||
|
- Lazy filtering applied only to grouped photos
|
||||||
|
- No additional API calls needed (uses existing photo data)
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
- Tag search/autocomplete in filter
|
||||||
|
- Tag popularity sorting
|
||||||
|
- Tag suggestions based on similar photos
|
||||||
|
- User tag preferences/favorites
|
||||||
|
- Tag analytics dashboard
|
||||||
@@ -2240,6 +2240,20 @@ class PhotoController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lấy tags từ request (nếu có)
|
||||||
|
let tags: string[] = [];
|
||||||
|
if (req.body.tags) {
|
||||||
|
try {
|
||||||
|
tags = JSON.parse(req.body.tags);
|
||||||
|
if (!Array.isArray(tags)) {
|
||||||
|
tags = [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const errorMsg = e instanceof Error ? e.message : 'Unknown error';
|
||||||
|
console.warn('[TAGS] Failed to parse tags from request:', errorMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Nếu vẫn không có, sử dụng vị trí mặc định (TP.HCM)
|
// 4. Nếu vẫn không có, sử dụng vị trí mặc định (TP.HCM)
|
||||||
if (lat === undefined || lng === undefined) {
|
if (lat === undefined || lng === undefined) {
|
||||||
lat = 10.7769;
|
lat = 10.7769;
|
||||||
@@ -2278,7 +2292,8 @@ class PhotoController {
|
|||||||
privacy: 'PUBLIC',
|
privacy: 'PUBLIC',
|
||||||
metadata: {
|
metadata: {
|
||||||
lat: lat,
|
lat: lat,
|
||||||
lng: lng
|
lng: lng,
|
||||||
|
tags: tags
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 562 KiB |
|
Before Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 756 KiB |
|
Before Width: | Height: | Size: 500 KiB |
|
Before Width: | Height: | Size: 518 KiB |
|
Before Width: | Height: | Size: 490 KiB |
|
Before Width: | Height: | Size: 646 KiB |
|
Before Width: | Height: | Size: 844 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 755 KiB |
|
Before Width: | Height: | Size: 499 KiB |
|
Before Width: | Height: | Size: 516 KiB |
|
Before Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 490 KiB |
|
Before Width: | Height: | Size: 645 KiB |
|
Before Width: | Height: | Size: 263 KiB |
|
Before Width: | Height: | Size: 561 KiB |
|
Before Width: | Height: | Size: 844 KiB |
|
Before Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 517 KiB |
@@ -3,6 +3,8 @@ import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit
|
|||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
import { CoordinateSelectModal } from './CoordinateSelectModal';
|
import { CoordinateSelectModal } from './CoordinateSelectModal';
|
||||||
import { useTranslation } from '../hooks/useTranslation';
|
import { useTranslation } from '../hooks/useTranslation';
|
||||||
|
import { useConfirm } from '../hooks/useConfirm';
|
||||||
|
import { useNotification } from '../hooks/useNotification';
|
||||||
|
|
||||||
interface Comment {
|
interface Comment {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -48,6 +50,8 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
onUpdatePhoto
|
onUpdatePhoto
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const notify = useNotification();
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
const [newComment, setNewComment] = useState('');
|
const [newComment, setNewComment] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -358,7 +362,12 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteComment = async (commentId: string) => {
|
const handleDeleteComment = async (commentId: string) => {
|
||||||
if (!confirm(t('confirmDeleteComment') || 'Bạn có chắc chắn muốn xóa bình luận này không?')) return;
|
const shouldDelete = await confirm({
|
||||||
|
title: t('deleteComment') || 'Xóa bình luận',
|
||||||
|
message: t('confirmDeleteComment') || 'Bạn có chắc chắn muốn xóa bình luận này không?'
|
||||||
|
});
|
||||||
|
if (!shouldDelete) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
|
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
|
||||||
const res = await fetch(`/api/v1/public-photos/comments/${commentId}`, {
|
const res = await fetch(`/api/v1/public-photos/comments/${commentId}`, {
|
||||||
@@ -369,13 +378,14 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setComments(prev => prev.filter(c => c.id !== commentId));
|
setComments(prev => prev.filter(c => c.id !== commentId));
|
||||||
|
notify({ title: 'Thành công', message: 'Bình luận đã được xóa.', type: 'success' });
|
||||||
} else {
|
} else {
|
||||||
const err = await res.json();
|
const err = await res.json();
|
||||||
alert(err.message || 'Lỗi khi xóa bình luận.');
|
notify({ title: 'Lỗi', message: err.message || 'Lỗi khi xóa bình luận.', type: 'error' });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Lỗi khi xóa bình luận:', error);
|
console.error('Lỗi khi xóa bình luận:', error);
|
||||||
alert('Không thể kết nối đến máy chủ.');
|
notify({ title: 'Lỗi', message: 'Không thể kết nối đến máy chủ.', type: 'error' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { X, Check, Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { useTranslation } from '../hooks/useTranslation';
|
||||||
|
|
||||||
|
interface TagSelectModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (tags: string[]) => void;
|
||||||
|
photoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AVAILABLE_TAGS = [
|
||||||
|
{ id: 'phong-canh', label: '🏞️ Phong cảnh' },
|
||||||
|
{ id: 'con-nguoi', label: '👥 Con người' },
|
||||||
|
{ id: 'doi-thuong', label: '🎒 Đời thường' },
|
||||||
|
{ id: 'bien', label: '🌊 Biển' },
|
||||||
|
{ id: 'nui', label: '⛰️ Núi' },
|
||||||
|
{ id: 'do-thi', label: '🏙️ Đô thị' },
|
||||||
|
{ id: 'thuc-an', label: '🍜 Thức ăn' },
|
||||||
|
{ id: 'cho', label: '🛍️ Chợ' },
|
||||||
|
{ id: 'hien-dai', label: '🏗️ Hiện đại' },
|
||||||
|
{ id: 'dong-vat', label: '🦁 Động vật' },
|
||||||
|
{ id: 'thu-cung', label: '🐕 Thú cưng' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TagSelectModal: React.FC<TagSelectModalProps> = ({ isOpen, onClose, onConfirm, photoUrl }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
|
const [customTagInput, setCustomTagInput] = useState('');
|
||||||
|
const [customTags, setCustomTags] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const toggleTag = (tagId: string) => {
|
||||||
|
setSelectedTags(prev =>
|
||||||
|
prev.includes(tagId)
|
||||||
|
? prev.filter(t => t !== tagId)
|
||||||
|
: [...prev, tagId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCustomTag = () => {
|
||||||
|
const trimmedTag = customTagInput.trim();
|
||||||
|
if (trimmedTag && !customTags.includes(trimmedTag)) {
|
||||||
|
setCustomTags(prev => [...prev, trimmedTag]);
|
||||||
|
setCustomTagInput('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCustomTag = (tag: string) => {
|
||||||
|
setCustomTags(prev => prev.filter(t => t !== tag));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
const allTags = [...selectedTags, ...customTags];
|
||||||
|
onConfirm(allTags);
|
||||||
|
setSelectedTags([]);
|
||||||
|
setCustomTags([]);
|
||||||
|
setCustomTagInput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setSelectedTags([]);
|
||||||
|
setCustomTags([]);
|
||||||
|
setCustomTagInput('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[6000] flex items-center justify-center p-4">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-slate-950/80 backdrop-blur-md"
|
||||||
|
onClick={handleClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative bg-white dark:bg-slate-900 rounded-3xl shadow-2xl max-w-md w-full max-h-[90vh] overflow-y-auto animate-in zoom-in-95 duration-300">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sticky top-0 bg-white dark:bg-slate-900 border-b border-gray-200 dark:border-slate-700 px-6 py-5 flex justify-between items-center">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
🏷️ Lựa chọn thẻ
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Image Preview */}
|
||||||
|
{photoUrl && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<img
|
||||||
|
src={photoUrl}
|
||||||
|
alt="Preview"
|
||||||
|
className="max-w-full h-auto max-h-48 rounded-2xl shadow-lg object-cover border-2 border-gray-200 dark:border-slate-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Predefined Tags */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-black text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-widest">Thẻ có sẵn</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{AVAILABLE_TAGS.map(tag => (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
onClick={() => toggleTag(tag.id)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-xl transition-all text-xs font-bold border-2 ${
|
||||||
|
selectedTags.includes(tag.id)
|
||||||
|
? 'bg-blue-600 border-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 dark:bg-slate-800 border-gray-200 dark:border-slate-700 text-gray-900 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-base">{tag.label.split(' ')[0]}</span>
|
||||||
|
<span className="text-[10px]">{tag.label.substring(2)}</span>
|
||||||
|
{selectedTags.includes(tag.id) && (
|
||||||
|
<Check className="w-3 h-3 ml-auto" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Tag Input */}
|
||||||
|
<div className="space-y-3 border-t border-gray-200 dark:border-slate-700 pt-4">
|
||||||
|
<p className="text-xs font-black text-gray-600 dark:text-gray-400 uppercase tracking-widest">Thêm thẻ khác</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customTagInput}
|
||||||
|
onChange={(e) => setCustomTagInput(e.target.value)}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
addCustomTag();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Nhập thẻ mới..."
|
||||||
|
className="flex-1 px-3 py-2 border-2 border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-gray-900 dark:text-white rounded-xl text-sm font-bold placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:border-blue-600"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={addCustomTag}
|
||||||
|
className="px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold transition-colors flex items-center gap-1 active:scale-95"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Tags Display */}
|
||||||
|
{customTags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{customTags.map((tag, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="bg-emerald-100 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300 text-xs px-3 py-1.5 rounded-full font-semibold flex items-center gap-2 group"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
onClick={() => removeCustomTag(tag)}
|
||||||
|
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3 hover:text-red-600" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* All Selected Tags Summary */}
|
||||||
|
{(selectedTags.length > 0 || customTags.length > 0) && (
|
||||||
|
<div className="pt-3 border-t border-gray-200 dark:border-slate-700">
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 mb-2 font-bold">
|
||||||
|
✓ {selectedTags.length + customTags.length} thẻ đã chọn
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedTags.map(tagId => {
|
||||||
|
const tag = AVAILABLE_TAGS.find(t => t.id === tagId);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={tagId}
|
||||||
|
className="bg-blue-100 dark:bg-blue-950 text-blue-700 dark:text-blue-300 text-xs px-3 py-1.5 rounded-full font-semibold"
|
||||||
|
>
|
||||||
|
{tag?.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{customTags.map((tag, idx) => (
|
||||||
|
<span
|
||||||
|
key={`custom-${idx}`}
|
||||||
|
className="bg-emerald-100 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300 text-xs px-3 py-1.5 rounded-full font-semibold"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="sticky bottom-0 bg-white dark:bg-slate-900 border-t border-gray-200 dark:border-slate-700 px-6 py-4 flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="flex-1 px-4 py-3 rounded-xl border-2 border-gray-200 dark:border-slate-700 text-gray-900 dark:text-white font-bold hover:bg-gray-100 dark:hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
{t('cancel') || 'Hủy'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className="flex-1 px-4 py-3 rounded-xl bg-blue-600 hover:bg-blue-700 text-white font-bold transition-colors shadow-md active:scale-95"
|
||||||
|
>
|
||||||
|
{t('confirm') || 'Xác nhận'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -24,6 +24,21 @@ const DefaultIcon = L.icon({
|
|||||||
});
|
});
|
||||||
L.Marker.prototype.options.icon = DefaultIcon;
|
L.Marker.prototype.options.icon = DefaultIcon;
|
||||||
|
|
||||||
|
// Tag ID to Label Mapping for Public Photos
|
||||||
|
const PHOTO_TAG_LABELS: { [key: string]: string } = {
|
||||||
|
'phong-canh': '🏞️ Phong cảnh',
|
||||||
|
'con-nguoi': '👥 Con người',
|
||||||
|
'doi-thuong': '🎒 Đời thường',
|
||||||
|
'bien': '🌊 Biển',
|
||||||
|
'nui': '⛰️ Núi',
|
||||||
|
'do-thi': '🏙️ Đô thị',
|
||||||
|
'thuc-an': '🍜 Thức ăn',
|
||||||
|
'cho': '🛍️ Chợ',
|
||||||
|
'hien-dai': '🏗️ Hiện đại',
|
||||||
|
'dong-vat': '🦁 Động vật',
|
||||||
|
'thu-cung': '🐕 Thú cưng'
|
||||||
|
};
|
||||||
|
|
||||||
// Component Helper để cập nhật tâm bản đồ khi vị trí người dùng thay đổi
|
// Component Helper để cập nhật tâm bản đồ khi vị trí người dùng thay đổi
|
||||||
function RecenterMap({ position }: { position: [number, number] }) {
|
function RecenterMap({ position }: { position: [number, number] }) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
@@ -192,10 +207,22 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
|||||||
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
|
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
|
||||||
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null);
|
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null);
|
||||||
const [selectedPhotoGroup, setSelectedPhotoGroup] = useState<any[]>([]);
|
const [selectedPhotoGroup, setSelectedPhotoGroup] = useState<any[]>([]);
|
||||||
|
const [selectedPhotoFilterTags, setSelectedPhotoFilterTags] = useState<string[]>([]);
|
||||||
|
|
||||||
const groupedPhotos = React.useMemo(() => {
|
const groupedPhotos = React.useMemo(() => {
|
||||||
|
// Filter photos by selected tags if any are selected
|
||||||
|
let filteredPhotos = publicPhotos;
|
||||||
|
if (selectedPhotoFilterTags.length > 0) {
|
||||||
|
filteredPhotos = publicPhotos.filter((photo) => {
|
||||||
|
const photoTags = photo.metadata?.tags as string[] | undefined;
|
||||||
|
if (!Array.isArray(photoTags)) return false;
|
||||||
|
// Check if photo has at least one of the selected tags
|
||||||
|
return selectedPhotoFilterTags.some(tag => photoTags.includes(tag));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const groups: { [key: string]: any[] } = {};
|
const groups: { [key: string]: any[] } = {};
|
||||||
publicPhotos.forEach((photo) => {
|
filteredPhotos.forEach((photo) => {
|
||||||
const lat = photo.metadata?.lat;
|
const lat = photo.metadata?.lat;
|
||||||
const lng = photo.metadata?.lng;
|
const lng = photo.metadata?.lng;
|
||||||
if (typeof lat === 'number' && typeof lng === 'number') {
|
if (typeof lat === 'number' && typeof lng === 'number') {
|
||||||
@@ -215,7 +242,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
return Object.values(groups);
|
return Object.values(groups);
|
||||||
}, [publicPhotos]);
|
}, [publicPhotos, selectedPhotoFilterTags]);
|
||||||
|
|
||||||
const fetchPublicPhotos = async () => {
|
const fetchPublicPhotos = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -464,6 +491,18 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
|||||||
return Array.from(tagsSet);
|
return Array.from(tagsSet);
|
||||||
}, [publicTours]);
|
}, [publicTours]);
|
||||||
|
|
||||||
|
// Tổng hợp nhãn từ danh sách ảnh công khai để lọc ảnh
|
||||||
|
const availablePhotoTags = React.useMemo(() => {
|
||||||
|
const tagsSet = new Set<string>();
|
||||||
|
publicPhotos.forEach(photo => {
|
||||||
|
const tags = photo.metadata?.tags as string[] | undefined;
|
||||||
|
if (Array.isArray(tags)) {
|
||||||
|
tags.forEach(tag => tagsSet.add(tag));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(tagsSet).sort();
|
||||||
|
}, [publicPhotos]);
|
||||||
|
|
||||||
// State cho menu chuột phải chia sẻ
|
// State cho menu chuột phải chia sẻ
|
||||||
const [shareMenu, setShareMenu] = useState<{ x: number, y: number, id: string, title: string, canShare: boolean, isParticipant: boolean, hasPendingRequest: boolean } | null>(null);
|
const [shareMenu, setShareMenu] = useState<{ x: number, y: number, id: string, title: string, canShare: boolean, isParticipant: boolean, hasPendingRequest: boolean } | null>(null);
|
||||||
|
|
||||||
@@ -634,12 +673,14 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
|||||||
|
|
||||||
{/* Filter Dropdown Content */}
|
{/* Filter Dropdown Content */}
|
||||||
{isFilterDropdownOpen && (
|
{isFilterDropdownOpen && (
|
||||||
<div className="absolute top-full left-0 mt-3 bg-white/90 backdrop-blur-md p-2 rounded-2xl shadow-xl border border-white/20 flex flex-col gap-2 max-w-[200px] z-[1003] animate-in slide-in-from-left-2 duration-200">
|
<div className="absolute top-full left-0 mt-3 bg-white/90 backdrop-blur-md p-3 rounded-2xl shadow-xl border border-white/20 flex flex-col gap-3 max-w-[220px] z-[1003] animate-in slide-in-from-left-2 duration-200 max-h-[400px] overflow-y-auto">
|
||||||
<div className="flex items-center gap-2 px-2 py-1 border-b border-gray-100 mb-1">
|
{/* Tour Filter Section */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1 border-b border-gray-100 mb-1.5">
|
||||||
<Filter className="w-3.5 h-3.5 text-blue-600" />
|
<Filter className="w-3.5 h-3.5 text-blue-600" />
|
||||||
<span className="text-[11px] font-black uppercase text-gray-500 tracking-wider">Lọc theo loại</span>
|
<span className="text-[10px] font-black uppercase text-gray-500 tracking-wider">🧳 Chuyến đi</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5"> {/* Hiển thị tag theo chiều dọc */}
|
<div className="flex flex-col gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setSelectedFilterTag(null); setIsFilterDropdownOpen(false); }}
|
onClick={() => { setSelectedFilterTag(null); setIsFilterDropdownOpen(false); }}
|
||||||
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${!selectedFilterTag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
|
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${!selectedFilterTag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
|
||||||
@@ -657,6 +698,44 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Photo Filter Section */}
|
||||||
|
{availablePhotoTags.length > 0 && (
|
||||||
|
<div className="border-t border-gray-200 pt-3">
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1 border-b border-gray-100 mb-1.5">
|
||||||
|
<ImageIcon className="w-3.5 h-3.5 text-emerald-600" />
|
||||||
|
<span className="text-[10px] font-black uppercase text-gray-500 tracking-wider">📸 Ảnh công khai</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedPhotoFilterTags([]); setIsFilterDropdownOpen(false); }}
|
||||||
|
className={`px-2 py-1 rounded-lg text-[9px] font-bold transition-all ${selectedPhotoFilterTags.length === 0 ? 'bg-emerald-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
|
||||||
|
>
|
||||||
|
Tất cả
|
||||||
|
</button>
|
||||||
|
{availablePhotoTags.map(tagId => {
|
||||||
|
const tagLabel = PHOTO_TAG_LABELS[tagId] || tagId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tagId}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedPhotoFilterTags(prev =>
|
||||||
|
prev.includes(tagId)
|
||||||
|
? prev.filter(t => t !== tagId)
|
||||||
|
: [...prev, tagId]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className={`px-2 py-1 rounded-lg text-[9px] font-bold transition-all ${selectedPhotoFilterTags.includes(tagId) ? 'bg-emerald-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
|
||||||
|
title={tagLabel}
|
||||||
|
>
|
||||||
|
{tagLabel.length > 13 ? tagLabel.substring(0, 13) + '...' : tagLabel}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
|
|||||||
import { LogIn, Compass, Map as MapIcon, Camera, ShieldAlert } from 'lucide-react';
|
import { LogIn, Compass, Map as MapIcon, Camera, ShieldAlert } from 'lucide-react';
|
||||||
import { LoginModal } from '../components/LoginModal';
|
import { LoginModal } from '../components/LoginModal';
|
||||||
import { ReportBusinessModal } from '../components/ReportBusinessModal';
|
import { ReportBusinessModal } from '../components/ReportBusinessModal';
|
||||||
|
import { TagSelectModal } from '../components/TagSelectModal';
|
||||||
import { useNotification } from '@/hooks/useNotification';
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
import { processImageModeration } from '../hooks/useImageModeration';
|
import { processImageModeration } from '../hooks/useImageModeration';
|
||||||
import { useTranslation } from '../hooks/useTranslation';
|
import { useTranslation } from '../hooks/useTranslation';
|
||||||
@@ -18,7 +19,12 @@ interface LandingPageProps {
|
|||||||
|
|
||||||
export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMap, onLoginSuccess }) => {
|
export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMap, onLoginSuccess }) => {
|
||||||
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
||||||
|
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
||||||
|
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [pendingPhotoFile, setPendingPhotoFile] = useState<File | null>(null);
|
||||||
|
const [pendingPhotoLocation, setPendingPhotoLocation] = useState<GeolocationPosition | null>(null);
|
||||||
|
const [photoPreviewUrl, setPhotoPreviewUrl] = useState<string>('');
|
||||||
const notify = useNotification();
|
const notify = useNotification();
|
||||||
const { t, lang, changeLanguage } = useTranslation();
|
const { t, lang, changeLanguage } = useTranslation();
|
||||||
const { theme, changeTheme } = useTheme();
|
const { theme, changeTheme } = useTheme();
|
||||||
@@ -26,7 +32,6 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
|||||||
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
|
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
|
||||||
const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
|
const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
|
||||||
const [blacklist, setBlacklist] = useState<any[]>([]);
|
const [blacklist, setBlacklist] = useState<any[]>([]);
|
||||||
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
|
||||||
const [currentBgIndex, setCurrentBgIndex] = useState(0);
|
const [currentBgIndex, setCurrentBgIndex] = useState(0);
|
||||||
const [bg1, setBg1] = useState('/background.avif');
|
const [bg1, setBg1] = useState('/background.avif');
|
||||||
const [bg2, setBg2] = useState('');
|
const [bg2, setBg2] = useState('');
|
||||||
@@ -144,6 +149,30 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
|||||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 4500))
|
new Promise<null>((resolve) => setTimeout(() => resolve(null), 4500))
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Lưu file và location vào state pending, hiển thị modal tags
|
||||||
|
setPendingPhotoFile(processedFile);
|
||||||
|
setPendingPhotoLocation(location);
|
||||||
|
|
||||||
|
// Tạo preview URL cho ảnh
|
||||||
|
const previewUrl = URL.createObjectURL(processedFile);
|
||||||
|
setPhotoPreviewUrl(previewUrl);
|
||||||
|
|
||||||
|
setIsTagsModalOpen(true);
|
||||||
|
} catch (error: any) {
|
||||||
|
notify({ title: 'Lỗi', message: error.message, type: 'error' });
|
||||||
|
} finally {
|
||||||
|
// Reset input để có thể chọn lại cùng 1 file
|
||||||
|
if (event.target) event.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmTags = async (selectedTags: string[]) => {
|
||||||
|
if (!pendingPhotoFile) return;
|
||||||
|
|
||||||
|
setIsTagsModalOpen(false);
|
||||||
|
notify({ title: 'Đang tải lên...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
|
||||||
|
|
||||||
|
try {
|
||||||
// 1. Xóa token cũ nếu có để đảm bảo guest không bị nhầm lẫn
|
// 1. Xóa token cũ nếu có để đảm bảo guest không bị nhầm lẫn
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
localStorage.removeItem('user');
|
localStorage.removeItem('user');
|
||||||
@@ -162,12 +191,16 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
|||||||
localStorage.setItem('guest_user', JSON.stringify(guestUser));
|
localStorage.setItem('guest_user', JSON.stringify(guestUser));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Tải ảnh lên
|
// 3. Tải ảnh lên
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('images', processedFile);
|
formData.append('images', pendingPhotoFile);
|
||||||
if (location) {
|
if (pendingPhotoLocation) {
|
||||||
formData.append('latitude', location.coords.latitude.toString());
|
formData.append('latitude', pendingPhotoLocation.coords.latitude.toString());
|
||||||
formData.append('longitude', location.coords.longitude.toString());
|
formData.append('longitude', pendingPhotoLocation.coords.longitude.toString());
|
||||||
|
}
|
||||||
|
// Thêm tags vào formData
|
||||||
|
if (selectedTags.length > 0) {
|
||||||
|
formData.append('tags', JSON.stringify(selectedTags));
|
||||||
}
|
}
|
||||||
|
|
||||||
let uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
|
let uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
|
||||||
@@ -215,11 +248,22 @@ notify({ title: 'Thành công!', message: 'Ảnh của bạn đã được tải
|
|||||||
await fetchPublicPhotos();
|
await fetchPublicPhotos();
|
||||||
setCurrentBgIndex(0);
|
setCurrentBgIndex(0);
|
||||||
|
|
||||||
|
// Xóa pending data
|
||||||
|
setPendingPhotoFile(null);
|
||||||
|
setPendingPhotoLocation(null);
|
||||||
|
if (photoPreviewUrl) {
|
||||||
|
URL.revokeObjectURL(photoPreviewUrl);
|
||||||
|
setPhotoPreviewUrl('');
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
notify({ title: 'Lỗi', message: error.message, type: 'error' });
|
notify({ title: 'Lỗi', message: error.message, type: 'error' });
|
||||||
} finally {
|
// Cleanup on error too
|
||||||
// Reset input để có thể chọn lại cùng 1 file
|
setPendingPhotoFile(null);
|
||||||
if (event.target) event.target.value = '';
|
setPendingPhotoLocation(null);
|
||||||
|
if (photoPreviewUrl) {
|
||||||
|
URL.revokeObjectURL(photoPreviewUrl);
|
||||||
|
setPhotoPreviewUrl('');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -549,6 +593,22 @@ return (
|
|||||||
isOpen={isReportModalOpen}
|
isOpen={isReportModalOpen}
|
||||||
onClose={() => setIsReportModalOpen(false)}
|
onClose={() => setIsReportModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Tag Select Modal */}
|
||||||
|
<TagSelectModal
|
||||||
|
isOpen={isTagsModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsTagsModalOpen(false);
|
||||||
|
setPendingPhotoFile(null);
|
||||||
|
setPendingPhotoLocation(null);
|
||||||
|
if (photoPreviewUrl) {
|
||||||
|
URL.revokeObjectURL(photoPreviewUrl);
|
||||||
|
setPhotoPreviewUrl('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onConfirm={handleConfirmTags}
|
||||||
|
photoUrl={photoPreviewUrl}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||