497 lines
20 KiB
TypeScript
497 lines
20 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { X, Image as ImageIcon, Loader2, Download, Eye, MapPin, Tag, Trash2, Edit2, Check } from 'lucide-react';
|
|
import { useNotification } from '@/hooks/useNotification';
|
|
import { useConfirm } from '@/hooks/useConfirm';
|
|
|
|
interface PhotoGalleryModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
user: any;
|
|
}
|
|
|
|
const PHOTO_TAGS = [
|
|
{ value: 'phong-canh', label: '🏞️ Phong cảnh' },
|
|
{ value: 'con-nguoi', label: '👥 Con người' },
|
|
{ value: 'doi-thuong', label: '🎒 Đời thường' },
|
|
{ value: 'bien', label: '🌊 Biển' },
|
|
{ value: 'nui', label: '⛰️ Núi' },
|
|
{ value: 'do-thi', label: '🏙️ Đô thị' },
|
|
{ value: 'thuc-an', label: '🍜 Thức ăn' },
|
|
{ value: 'cho', label: '🛍️ Chợ' },
|
|
{ value: 'hien-dai', label: '🏗️ Hiện đại' },
|
|
{ value: 'dong-vat', label: '🦁 Động vật' },
|
|
{ value: 'thu-cung', label: '🐕 Thú cưng' }
|
|
];
|
|
|
|
export const PhotoGalleryModal: React.FC<PhotoGalleryModalProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
user,
|
|
}) => {
|
|
const notify = useNotification();
|
|
const confirm = useConfirm();
|
|
|
|
const [photos, setPhotos] = useState<any[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
// Filtering states
|
|
const [filterTourId, setFilterTourId] = useState<string>('all');
|
|
const [filterTag, setFilterTag] = useState<string>('all');
|
|
|
|
// Preview / Editor States
|
|
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null);
|
|
const [editingPhotoId, setEditingPhotoId] = useState<string | null>(null);
|
|
const [editTitle, setEditTitle] = useState('');
|
|
const [editDescription, setEditDescription] = useState('');
|
|
const [editTags, setEditTags] = useState<string[]>([]);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [isDeletingId, setIsDeletingId] = useState<string | null>(null);
|
|
|
|
const getHeaders = () => ({
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
|
});
|
|
|
|
const loadPhotos = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const res = await fetch('/api/v1/users/me/photos', { headers: getHeaders() });
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setPhotos(data || []);
|
|
} else {
|
|
throw new Error('Không thể tải thư viện ảnh.');
|
|
}
|
|
} catch (e: any) {
|
|
console.error(e);
|
|
notify({
|
|
title: 'Lỗi',
|
|
message: e.message || 'Không thể tải ảnh.',
|
|
type: 'error',
|
|
});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (isOpen && user) {
|
|
loadPhotos();
|
|
}
|
|
}, [isOpen, user]);
|
|
|
|
if (!isOpen || !user) return null;
|
|
|
|
// Extract unique tours from photos list for dropdown filtering
|
|
const uniqueToursMap = new Map();
|
|
photos.forEach(p => {
|
|
if (p.tour?.id && p.tour?.title) {
|
|
uniqueToursMap.set(p.tour.id, p.tour.title);
|
|
}
|
|
});
|
|
const uniqueTours = Array.from(uniqueToursMap.entries()).map(([id, title]) => ({ id, title }));
|
|
|
|
// Handle Photo Deletion
|
|
const handleDelete = async (photoId: string) => {
|
|
const ok = await confirm({
|
|
title: 'Xóa ảnh?',
|
|
message: 'Bạn có chắc chắn muốn xóa bức ảnh này không? Ảnh sẽ được chuyển vào thùng rác.',
|
|
});
|
|
if (!ok) return;
|
|
|
|
setIsDeletingId(photoId);
|
|
try {
|
|
const res = await fetch(`/api/v1/photos/${photoId}`, {
|
|
method: 'DELETE',
|
|
headers: getHeaders(),
|
|
});
|
|
if (res.ok) {
|
|
notify({
|
|
title: 'Thành công',
|
|
message: 'Đã xóa ảnh thành công.',
|
|
type: 'success',
|
|
});
|
|
setPhotos(prev => prev.filter(p => p.id !== photoId));
|
|
if (selectedPhoto?.id === photoId) setSelectedPhoto(null);
|
|
} else {
|
|
throw new Error('Xóa ảnh thất bại.');
|
|
}
|
|
} catch (err: any) {
|
|
notify({
|
|
title: 'Lỗi',
|
|
message: err.message || 'Không thể xóa ảnh.',
|
|
type: 'error',
|
|
});
|
|
} finally {
|
|
setIsDeletingId(null);
|
|
}
|
|
};
|
|
|
|
// Start Editing Tag details
|
|
const startEdit = (photo: any) => {
|
|
setEditingPhotoId(photo.id);
|
|
const meta = photo.metadata || {};
|
|
setEditTitle(meta.title || '');
|
|
setEditDescription(meta.description || '');
|
|
setEditTags(meta.tags || []);
|
|
};
|
|
|
|
const handleTagToggle = (tagValue: string) => {
|
|
setEditTags(prev =>
|
|
prev.includes(tagValue)
|
|
? prev.filter(t => t !== tagValue)
|
|
: [...prev, tagValue]
|
|
);
|
|
};
|
|
|
|
// Save Tagging Details
|
|
const saveEdit = async (photoId: string) => {
|
|
setIsSaving(true);
|
|
try {
|
|
const res = await fetch(`/api/v1/photos/${photoId}`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
|
},
|
|
body: JSON.stringify({
|
|
title: editTitle,
|
|
description: editDescription,
|
|
tags: editTags,
|
|
}),
|
|
});
|
|
|
|
if (res.ok) {
|
|
notify({
|
|
title: 'Thành công',
|
|
message: 'Cập nhật thông tin ảnh thành công.',
|
|
type: 'success',
|
|
});
|
|
// Reload photos list to synchronize
|
|
await loadPhotos();
|
|
setEditingPhotoId(null);
|
|
} else {
|
|
throw new Error('Lỗi cập nhật ảnh.');
|
|
}
|
|
} catch (err: any) {
|
|
notify({
|
|
title: 'Lỗi',
|
|
message: err.message || 'Cập nhật thất bại.',
|
|
type: 'error',
|
|
});
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDownload = async (url: string, filename: string) => {
|
|
try {
|
|
const response = await fetch(url);
|
|
const blob = await response.blob();
|
|
const blobUrl = window.URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = blobUrl;
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
window.URL.revokeObjectURL(blobUrl);
|
|
} catch (e) {
|
|
console.error(e);
|
|
notify({
|
|
title: 'Lỗi tải về',
|
|
message: 'Không thể tải trực tiếp ảnh xuống thiết bị.',
|
|
type: 'error',
|
|
});
|
|
}
|
|
};
|
|
|
|
// Filter photos
|
|
const filteredPhotos = photos.filter((photo) => {
|
|
const matchTour = filterTourId === 'all' || photo.tour?.id === filterTourId;
|
|
const matchTag = filterTag === 'all' || photo.metadata?.tags?.includes(filterTag);
|
|
return matchTour && matchTag;
|
|
});
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[1000000] bg-black/70 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-4 pointer-events-auto">
|
|
<div className="bg-slate-900 w-full sm:max-w-4xl h-[92vh] sm:h-[80vh] rounded-t-3xl sm:rounded-2xl flex flex-col overflow-hidden text-slate-200 text-xs border border-slate-800/80 shadow-2xl animate-in slide-in-from-bottom sm:zoom-in-95 duration-300">
|
|
|
|
{/* Header */}
|
|
<div className="px-5 py-4 border-b border-slate-800/60 flex justify-between items-center shrink-0">
|
|
<span className="font-bold text-sm text-white flex items-center gap-2">
|
|
<ImageIcon className="w-5 h-5 text-emerald-400" /> Thư viện ảnh
|
|
</span>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Dual Filter Header Select Pinned Matrix */}
|
|
<div className="bg-slate-950/40 p-3.5 border-b border-slate-850 flex flex-col sm:flex-row gap-3.5 shrink-0">
|
|
<div className="flex-1 flex flex-col gap-1.5">
|
|
<label className="font-bold text-slate-400 text-[10px] uppercase tracking-wider">Theo hành trình:</label>
|
|
<select
|
|
value={filterTourId}
|
|
onChange={(e) => setFilterTourId(e.target.value)}
|
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white font-bold cursor-pointer focus:outline-none focus:border-indigo-650"
|
|
>
|
|
<option value="all">Tất cả hành trình</option>
|
|
{uniqueTours.map((t) => (
|
|
<option key={t.id} value={t.id}>{t.title}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex-1 flex flex-col gap-1.5">
|
|
<label className="font-bold text-slate-400 text-[10px] uppercase tracking-wider">Theo thẻ phân loại:</label>
|
|
<select
|
|
value={filterTag}
|
|
onChange={(e) => setFilterTag(e.target.value)}
|
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white font-bold cursor-pointer focus:outline-none focus:border-indigo-650"
|
|
>
|
|
<option value="all">Tất cả thẻ tags</option>
|
|
{PHOTO_TAGS.map((t) => (
|
|
<option key={t.value} value={t.value}>{t.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content body grid workspace */}
|
|
<div className="flex-1 overflow-y-auto p-5">
|
|
{isLoading ? (
|
|
<div className="h-full flex items-center justify-center flex-col gap-2 py-20">
|
|
<Loader2 className="w-8 h-8 text-emerald-400 animate-spin" />
|
|
<span className="text-slate-400 font-medium">Đang tải thư viện ảnh...</span>
|
|
</div>
|
|
) : filteredPhotos.length === 0 ? (
|
|
<div className="h-full flex flex-col items-center justify-center text-center py-20 px-6 gap-3">
|
|
<div className="w-16 h-16 bg-slate-850 rounded-full flex items-center justify-center border border-slate-800">
|
|
<ImageIcon className="w-8 h-8 text-slate-500" />
|
|
</div>
|
|
<div>
|
|
<div className="font-bold text-white text-sm">Không tìm thấy bức ảnh nào</div>
|
|
<p className="text-slate-500 mt-1 max-w-xs mx-auto">Không có ảnh nào khớp với bộ lọc hiện tại.</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
{filteredPhotos.map((photo) => {
|
|
const photoTagsList = photo.metadata?.tags || [];
|
|
|
|
return (
|
|
<div
|
|
key={photo.id}
|
|
className="group relative aspect-square rounded-2xl overflow-hidden bg-slate-950 border border-slate-850 hover:border-slate-700 shadow-lg transition-all flex flex-col"
|
|
>
|
|
<img
|
|
src={photo.imageUrl}
|
|
alt="Gallery Item"
|
|
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
/>
|
|
|
|
{/* Standard tags badge dot count */}
|
|
{photoTagsList.length > 0 && (
|
|
<div className="absolute top-2.5 left-2.5 bg-black/60 backdrop-blur-md text-white font-bold text-[9px] px-2 py-0.5 rounded-full flex items-center gap-1 border border-white/10 z-10">
|
|
<Tag className="w-2.5 h-2.5 text-emerald-400 shrink-0" />
|
|
<span>{photoTagsList.length} tags</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Hover controls overlay */}
|
|
<div className="absolute inset-0 bg-black/70 opacity-0 group-hover:opacity-100 flex flex-col justify-between p-3.5 transition-opacity duration-250 z-10">
|
|
<div className="flex justify-end gap-1.5">
|
|
<button
|
|
onClick={() => startEdit(photo)}
|
|
className="p-2 bg-slate-900/85 hover:bg-amber-650 text-white rounded-xl transition-colors cursor-pointer"
|
|
title="Sửa thông tin"
|
|
>
|
|
<Edit2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(photo.id)}
|
|
disabled={isDeletingId === photo.id}
|
|
className="p-2 bg-slate-900/85 hover:bg-rose-650 text-white rounded-xl transition-colors cursor-pointer disabled:opacity-50"
|
|
title="Xóa ảnh"
|
|
>
|
|
{isDeletingId === photo.id ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={() => setSelectedPhoto(photo)}
|
|
className="p-2 bg-slate-900/85 hover:bg-indigo-650 text-white rounded-xl transition-colors cursor-pointer"
|
|
title="Xem chi tiết"
|
|
>
|
|
<Eye className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="min-w-0">
|
|
<div className="text-[10px] font-bold text-white truncate">
|
|
{photo.metadata?.title || 'Chưa đặt tiêu đề'}
|
|
</div>
|
|
{photo.tour?.title && (
|
|
<div className="text-[9px] font-bold text-indigo-300 truncate flex items-center gap-1 mt-0.5">
|
|
<MapPin className="w-2.5 h-2.5 shrink-0" />
|
|
{photo.tour.title}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/* Editor & Tag modification Dialog */}
|
|
{editingPhotoId && (
|
|
<div className="fixed inset-0 z-[1000001] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 pointer-events-auto">
|
|
<div className="bg-slate-900 w-full max-w-md rounded-2xl overflow-hidden border border-slate-800 p-5 space-y-4 shadow-2xl">
|
|
<div className="flex justify-between items-center pb-2 border-b border-slate-800">
|
|
<span className="font-bold text-sm text-white">Chỉnh sửa thông tin ảnh</span>
|
|
<button
|
|
onClick={() => setEditingPhotoId(null)}
|
|
className="p-1 hover:bg-slate-800 text-slate-400 hover:text-white rounded-lg transition-colors"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-3 text-xs">
|
|
<div className="flex flex-col gap-1">
|
|
<label className="font-bold text-slate-400">Tiêu đề ảnh:</label>
|
|
<input
|
|
type="text"
|
|
value={editTitle}
|
|
onChange={(e) => setEditTitle(e.target.value)}
|
|
placeholder="Ví dụ: Hoàng hôn biển Ba Động..."
|
|
className="bg-slate-950 border border-slate-805 rounded-xl p-2.5 text-white focus:outline-none focus:border-indigo-650"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<label className="font-bold text-slate-400">Mô tả ảnh:</label>
|
|
<textarea
|
|
value={editDescription}
|
|
onChange={(e) => setEditDescription(e.target.value)}
|
|
placeholder="Ghi lại kỷ niệm..."
|
|
rows={2}
|
|
className="bg-slate-950 border border-slate-805 rounded-xl p-2.5 text-white resize-none focus:outline-none focus:border-indigo-650"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="font-bold text-slate-400 block mb-1">Gắn thẻ phân loại (Hashtags):</label>
|
|
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto p-1.5 bg-slate-950/60 border border-slate-850 rounded-xl">
|
|
{PHOTO_TAGS.map((tag) => {
|
|
const isSelected = editTags.includes(tag.value);
|
|
return (
|
|
<button
|
|
key={tag.value}
|
|
type="button"
|
|
onClick={() => handleTagToggle(tag.value)}
|
|
className={`px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all cursor-pointer flex items-center gap-1 ${
|
|
isSelected
|
|
? 'bg-emerald-950/40 text-emerald-400 border-emerald-500/50'
|
|
: 'bg-slate-900 border-slate-800 text-slate-400 hover:border-slate-700'
|
|
}`}
|
|
>
|
|
{isSelected && <Check className="w-3 h-3 text-emerald-400 shrink-0" />}
|
|
{tag.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-3 border-t border-slate-850 flex justify-end gap-2">
|
|
<button
|
|
onClick={() => setEditingPhotoId(null)}
|
|
className="px-4 py-2 bg-slate-850 hover:bg-slate-800 font-bold text-slate-300 rounded-xl"
|
|
>
|
|
Hủy
|
|
</button>
|
|
<button
|
|
onClick={() => saveEdit(editingPhotoId)}
|
|
disabled={isSaving}
|
|
className="px-4 py-2 bg-indigo-650 hover:bg-indigo-600 font-bold text-white rounded-xl flex items-center gap-1.5 disabled:opacity-50"
|
|
>
|
|
{isSaving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
|
Lưu lại
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Fullscreen Preview overlay */}
|
|
{selectedPhoto && (
|
|
<div className="fixed inset-0 z-[2000000] bg-black/95 flex flex-col justify-between p-4 pointer-events-auto">
|
|
{/* Close trigger top bar */}
|
|
<div className="flex justify-between items-center w-full pb-3 border-b border-slate-900">
|
|
<div className="text-white font-bold text-xs truncate">
|
|
{selectedPhoto.metadata?.title || 'Xem ảnh'}
|
|
</div>
|
|
<button
|
|
onClick={() => setSelectedPhoto(null)}
|
|
className="p-2 bg-slate-900 hover:bg-slate-800 text-slate-400 hover:text-white rounded-xl cursor-pointer"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Fullscreen Photo view */}
|
|
<div className="flex-1 flex items-center justify-center p-4">
|
|
<div className="max-w-2xl w-full flex flex-col gap-3">
|
|
<img
|
|
src={selectedPhoto.originalUrl || selectedPhoto.imageUrl}
|
|
alt="Fullscreen Preview"
|
|
className="max-w-full max-h-[60vh] object-contain rounded-xl shadow-2xl mx-auto"
|
|
/>
|
|
<div className="bg-slate-900/60 border border-slate-800 p-4 rounded-2xl space-y-1.5">
|
|
{selectedPhoto.metadata?.title && (
|
|
<h4 className="font-bold text-white text-xs">{selectedPhoto.metadata.title}</h4>
|
|
)}
|
|
{selectedPhoto.metadata?.description && (
|
|
<p className="text-slate-400 text-[10px]">{selectedPhoto.metadata.description}</p>
|
|
)}
|
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
|
{selectedPhoto.metadata?.tags?.map((t: string) => (
|
|
<span
|
|
key={t}
|
|
className="px-2 py-0.5 bg-slate-950/80 border border-slate-800 text-slate-400 text-[9px] font-bold rounded-full"
|
|
>
|
|
#{t}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action bottom bar */}
|
|
<div className="flex justify-center items-center py-4 border-t border-slate-900 gap-3">
|
|
<button
|
|
onClick={() => handleDownload(selectedPhoto.originalUrl || selectedPhoto.imageUrl, `yotrip-photo-${selectedPhoto.id}.jpg`)}
|
|
className="flex items-center gap-2 px-5 py-3 bg-emerald-650 hover:bg-emerald-600 font-bold text-white rounded-xl shadow-lg transition-all active:scale-95 cursor-pointer"
|
|
>
|
|
<Download className="w-4 h-4" /> Tải về tệp gốc (.jpg)
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|