fix: cho phép người đã tải ảnh lên được quyền chỉnh sửa

This commit is contained in:
2026-06-20 08:27:58 +07:00
parent ac11bb56db
commit ae74035ad5
12 changed files with 179 additions and 83 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>Travel Planner</title>
<script type="module" crossorigin src="/assets/index-DChzoIDA.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CHBet_N8.css">
<script type="module" crossorigin src="/assets/index-UWqs1Nt4.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-I6bJ9cOX.css">
</head>
<body>
<div id="root"></div>
+61 -12
View File
@@ -61,6 +61,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
const [isSavingEdit, setIsSavingEdit] = useState(false);
const [isMapOpen, setIsMapOpen] = useState(false);
const [resolvedAddress, setResolvedAddress] = useState<string>('');
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const lat = photo?.metadata?.lat;
@@ -89,7 +90,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
}, [photo?.id, photo?.metadata?.lat, photo?.metadata?.lng]);
const checkCurrentUser = () => {
const userStr = localStorage.getItem('user');
const userStr = localStorage.getItem('user') || localStorage.getItem('guest_user');
if (userStr) {
try {
setCurrentUser(JSON.parse(userStr));
@@ -125,7 +126,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
setIsSavingEdit(true);
try {
const token = localStorage.getItem('token');
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
const res = await fetch(`/api/v1/photos/${photo.id}`, {
method: 'PATCH',
headers: {
@@ -165,7 +166,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
const likeCount = likedUserIds.length;
const handleToggleLike = async () => {
let token = localStorage.getItem('token');
let token = localStorage.getItem('token') || localStorage.getItem('guest_token');
let userObj = currentUser;
if (!token) {
@@ -362,18 +363,18 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
/>
{/* Container */}
<div className="relative w-full max-w-5xl h-[85vh] bg-slate-900 border border-slate-800 rounded-[32px] shadow-2xl overflow-hidden flex flex-col md:flex-row animate-in zoom-in-95 duration-300 text-slate-100">
<div className="relative w-full max-w-5xl h-[90vh] md:h-[85vh] bg-slate-900 border border-slate-800 rounded-[32px] shadow-2xl overflow-y-auto md:overflow-hidden flex flex-col md:flex-row animate-in zoom-in-95 duration-300 text-slate-100">
{/* Close Button Mobile/Desktop */}
<button
onClick={onClose}
className="absolute top-4 right-4 z-50 p-2 bg-slate-950/60 hover:bg-slate-800/80 border border-slate-700/50 rounded-full text-slate-300 hover:text-white transition-colors"
className="fixed md:absolute top-6 right-6 md:top-4 md:right-4 z-50 p-2 bg-slate-950/60 hover:bg-slate-800/80 border border-slate-700/50 rounded-full text-slate-300 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
{/* Left Side: Photo Detail */}
<div className="relative w-full md:w-3/5 h-2/5 md:h-full bg-slate-950 flex items-center justify-center overflow-hidden group">
<div className="relative w-full md:w-3/5 h-[45vh] md:h-full bg-slate-950 flex items-center justify-center overflow-hidden group shrink-0">
{/* Like Button Overlay */}
<button
onClick={handleToggleLike}
@@ -389,10 +390,11 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
<img
src={photo.imageUrl}
alt="Public Map Upload"
className="w-full h-full object-contain"
className="w-full h-full object-contain cursor-zoom-in"
onClick={() => setIsFullscreen(true)}
/>
{/* Info & Timeline overlay inside photo panel */}
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-slate-950 via-slate-950/90 to-transparent flex flex-col gap-4">
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black/60 via-black/20 to-transparent flex flex-col gap-4">
{/* Timeline scroll */}
{photoGroup && photoGroup.length > 1 && (
@@ -559,18 +561,18 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
<MapPin className="w-3.5 h-3.5 text-rose-500" />
Đa điểm: {resolvedAddress}
</div>
<div className="flex items-center gap-1.5 font-semibold text-emerald-400">
<div className="hidden md:flex items-center gap-1.5 font-semibold text-emerald-400">
<User className="w-3.5 h-3.5" />
Người đăng: {photo.uploader?.name || 'Ẩn danh'}
</div>
</div>
{photo.originalUrl && (
{isAuthorized && photo.originalUrl && (
<a
href={photo.originalUrl}
download
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 bg-slate-800/80 hover:bg-slate-700/80 border border-slate-700/50 text-slate-200 hover:text-white font-bold py-1 px-3 rounded-full transition-all active:scale-95 text-[10px]"
className="hidden md:flex items-center gap-1.5 bg-slate-800/80 hover:bg-slate-700/80 border border-slate-700/50 text-slate-200 hover:text-white font-bold py-1 px-3 rounded-full transition-all active:scale-95 text-[10px]"
>
<Download className="w-3.5 h-3.5 text-emerald-500" />
Tải nh gốc
@@ -592,8 +594,36 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
</div>
</div>
{/* Mobile Only: Uploader details & divider */}
<div className="block md:hidden p-6 bg-slate-900 border-t border-slate-800/60 space-y-4 shrink-0">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2.5 text-left">
<div className="p-2.5 bg-emerald-500/10 rounded-xl text-emerald-400">
<User className="w-5 h-5" />
</div>
<div>
<span className="text-[10px] text-slate-450 block uppercase font-bold tracking-wider">Người đăng nh</span>
<span className="text-sm font-semibold text-white">{photo.uploader?.name || 'Ẩn danh'}</span>
</div>
</div>
{isAuthorized && photo.originalUrl && (
<a
href={photo.originalUrl}
download
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 bg-slate-850 hover:bg-slate-800 border border-slate-700 text-slate-200 hover:text-white font-bold py-2 px-4 rounded-xl transition-all text-xs"
>
<Download className="w-4 h-4 text-emerald-500" />
Tải nh gốc
</a>
)}
</div>
<div className="border-t border-slate-800/60 pt-2" />
</div>
{/* Right Side: Comments */}
<div className="w-full md:w-2/5 h-3/5 md:h-full flex flex-col bg-slate-900 border-t md:border-t-0 md:border-l border-slate-800">
<div className="w-full md:w-2/5 h-auto md:h-full flex flex-col bg-slate-900 border-t md:border-t-0 md:border-l border-slate-800">
{/* Comments Header */}
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
@@ -685,6 +715,25 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
setEditLng(lng);
}}
/>
{isFullscreen && (
<div
className="fixed inset-0 z-[9999] bg-black/95 flex items-center justify-center cursor-zoom-out animate-in fade-in duration-200"
onClick={() => setIsFullscreen(false)}
>
<button
onClick={() => setIsFullscreen(false)}
className="fixed top-6 right-6 p-3 bg-black/50 hover:bg-black/75 border border-white/10 rounded-full text-white transition-colors z-[10000]"
>
<X className="w-6 h-6" />
</button>
<img
src={photo.imageUrl}
alt="Fullscreen photo"
className="max-w-full max-h-full object-contain select-none animate-in zoom-in-95 duration-200"
/>
</div>
)}
</div>
);
};
+29 -8
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useMemo } from 'react';
import { useEffect, useState, useMemo } from 'react';
import { ChevronLeft, Image as ImageIcon, Download, Calendar, MapPin, Loader2, Filter, X, Trash2, Edit, Heart } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
import { useConfirm } from '@/hooks/useConfirm';
@@ -22,6 +22,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
const [isSavingEdit, setIsSavingEdit] = useState(false);
const [isMapOpen, setIsMapOpen] = useState(false);
const [resolvedAddress, setResolvedAddress] = useState<string>('');
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const lat = selectedPhotoForDisplay?.metadata?.lat;
@@ -75,7 +76,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
},
body: JSON.stringify({
title: editTitle,
@@ -103,7 +104,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
};
const currentUser = useMemo(() => {
const userStr = localStorage.getItem('user');
const userStr = localStorage.getItem('user') || localStorage.getItem('guest_user');
if (!userStr) return null;
try {
return JSON.parse(userStr);
@@ -120,7 +121,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
const handleToggleLike = async () => {
if (!selectedPhotoForDisplay) return;
const token = localStorage.getItem('token');
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
if (!token) return;
try {
@@ -146,7 +147,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
try {
const response = await fetch('/api/v1/users/me/photos', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
}
});
if (!response.ok) throw new Error('Failed to fetch photos');
@@ -217,7 +218,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
const response = await fetch(`/api/v1/photos/${photoId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
}
});
@@ -347,7 +348,8 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
<img
src={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl}
alt="Selected Photo"
className="max-w-full max-h-[calc(100vh-350px)] object-contain"
className="max-w-full max-h-[calc(100vh-350px)] object-contain cursor-zoom-in"
onClick={() => setIsFullscreen(true)}
/>
{/* Overlays (Only show when not editing) */}
@@ -378,7 +380,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
</button>
{/* Bottom metadata details gradient panel overlay */}
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/95 via-black/50 to-transparent p-6 text-white flex flex-col gap-2">
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent p-6 text-white flex flex-col gap-2 text-left">
<div className="flex justify-between items-start gap-4">
<div className="flex-1 min-w-0">
{selectedPhotoForDisplay.metadata?.title ? (
@@ -584,6 +586,25 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
setEditLng(lng);
}}
/>
{isFullscreen && selectedPhotoForDisplay && (
<div
className="fixed inset-0 z-[9999] bg-black/95 flex items-center justify-center cursor-zoom-out animate-in fade-in duration-200"
onClick={() => setIsFullscreen(false)}
>
<button
onClick={() => setIsFullscreen(false)}
className="fixed top-6 right-6 p-3 bg-black/50 hover:bg-black/75 border border-white/10 rounded-full text-white transition-colors z-[10000]"
>
<X className="w-6 h-6" />
</button>
<img
src={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl}
alt="Fullscreen photo"
className="max-w-full max-h-full object-contain select-none animate-in zoom-in-95 duration-200"
/>
</div>
)}
</div>
);
};
+31 -10
View File
@@ -504,7 +504,7 @@ export const TourDetailPage = ({
const [isMemberDetailOpen, setIsMemberDetailOpen] = useState(false);
const [selectedPhotoForDisplay, setSelectedPhotoForDisplay] = useState<any | null>(null); // New state for large photo display
const currentUserId = useMemo(() => {
const token = localStorage.getItem('token');
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
if (!token) return null;
try {
return JSON.parse(atob(token.split('.')[1])).sub;
@@ -514,7 +514,7 @@ export const TourDetailPage = ({
}, []);
const currentUser = useMemo(() => {
const userStr = localStorage.getItem('user');
const userStr = localStorage.getItem('user') || localStorage.getItem('guest_user');
if (!userStr) return null;
try {
return JSON.parse(userStr);
@@ -531,6 +531,7 @@ export const TourDetailPage = ({
const [isSavingPhotoEdit, setIsSavingPhotoEdit] = useState(false);
const [isMapOpen, setIsMapOpen] = useState(false);
const [resolvedAddress, setResolvedAddress] = useState<string>('');
const [isFullscreen, setIsFullscreen] = useState(false);
const likedUserIds = selectedPhotoForDisplay && selectedPhotoForDisplay.metadata && Array.isArray(selectedPhotoForDisplay.metadata.likedUserIds)
? selectedPhotoForDisplay.metadata.likedUserIds
@@ -544,7 +545,7 @@ export const TourDetailPage = ({
const response = await fetch(`/api/v1/photos/${selectedPhotoForDisplay.id}/toggle-like`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
}
});
if (response.ok) {
@@ -621,7 +622,7 @@ export const TourDetailPage = ({
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
},
body: JSON.stringify({
title: editPhotoTitle,
@@ -735,7 +736,7 @@ export const TourDetailPage = ({
const response = await fetch(`/api/v1/photos/${photoId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
}
});
@@ -2057,7 +2058,8 @@ export const TourDetailPage = ({
<img
src={selectedPhotoForDisplay.imageUrl}
alt="Selected Tour Photo"
className="max-w-full max-h-[calc(100vh-350px)] object-contain"
className="max-w-full max-h-[calc(100vh-350px)] object-contain cursor-zoom-in"
onClick={() => setIsFullscreen(true)}
/>
{/* Overlays (Only show when not editing) */}
@@ -2076,7 +2078,7 @@ export const TourDetailPage = ({
</button>
{/* Delete button overlay (if owner) */}
{currentUserId === selectedPhotoForDisplay.uploaderId && !isPublicView && (
{currentUserId === selectedPhotoForDisplay.uploaderId && (
<button
onClick={(e) => {
e.stopPropagation();
@@ -2090,7 +2092,7 @@ export const TourDetailPage = ({
)}
{/* Bottom metadata details gradient panel overlay */}
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/95 via-black/50 to-transparent p-6 text-white flex flex-col gap-2 text-left">
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent p-6 text-white flex flex-col gap-2 text-left">
<div className="flex justify-between items-start gap-4">
<div className="flex-1 min-w-0">
{selectedPhotoForDisplay.metadata?.title ? (
@@ -2110,7 +2112,7 @@ export const TourDetailPage = ({
</div>
{/* Edit Button Overlay */}
{!isPublicView && (currentUser?.isAdmin || currentUserId === selectedPhotoForDisplay.uploaderId) && (
{((!isPublicView && currentUser?.isAdmin) || (currentUserId && currentUserId === selectedPhotoForDisplay.uploaderId)) && (
<button
onClick={() => setIsEditingPhoto(true)}
className="p-2 bg-white/10 hover:bg-white/20 border border-white/15 rounded-xl text-white hover:text-gray-200 transition-all shrink-0 backdrop-blur-sm"
@@ -2137,7 +2139,7 @@ export const TourDetailPage = ({
</div>
</div>
{selectedPhotoForDisplay.originalUrl && (
{(currentUser?.isAdmin || currentUserId === selectedPhotoForDisplay.uploaderId) && selectedPhotoForDisplay.originalUrl && (
<a
href={selectedPhotoForDisplay.originalUrl}
download
@@ -2744,6 +2746,25 @@ export const TourDetailPage = ({
setEditPhotoLng(lng);
}}
/>
{isFullscreen && selectedPhotoForDisplay && (
<div
className="fixed inset-0 z-[9999] bg-black/95 flex items-center justify-center cursor-zoom-out animate-in fade-in duration-200"
onClick={() => setIsFullscreen(false)}
>
<button
onClick={() => setIsFullscreen(false)}
className="fixed top-6 right-6 p-3 bg-black/50 hover:bg-black/75 border border-white/10 rounded-full text-white transition-colors z-[10000]"
>
<X className="w-6 h-6" />
</button>
<img
src={selectedPhotoForDisplay.imageUrl}
alt="Fullscreen photo"
className="max-w-full max-h-full object-contain select-none animate-in zoom-in-95 duration-200"
/>
</div>
)}
</div>
);
};