fix: cho phép người đã tải ảnh lên được quyền chỉnh sửa
This commit is contained in:
Vendored
+4
-1
@@ -148,8 +148,10 @@ let TourRoleGuard = class TourRoleGuard {
|
|||||||
tourId = loc.leg.tourId;
|
tourId = loc.leg.tourId;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const photo = await this.prisma.photo.findUnique({ where: { id: resourceId }, select: { tourId: true } });
|
const photo = await this.prisma.photo.findUnique({ where: { id: resourceId }, select: { tourId: true, uploaderId: true } });
|
||||||
if (photo) {
|
if (photo) {
|
||||||
|
if (user && photo.uploaderId === user.id)
|
||||||
|
return true;
|
||||||
if (!photo.tourId)
|
if (!photo.tourId)
|
||||||
return true;
|
return true;
|
||||||
tourId = photo.tourId;
|
tourId = photo.tourId;
|
||||||
@@ -2384,6 +2386,7 @@ let PublicPhotoController = class PublicPhotoController {
|
|||||||
originalUrl: true,
|
originalUrl: true,
|
||||||
capturedAt: true,
|
capturedAt: true,
|
||||||
metadata: true,
|
metadata: true,
|
||||||
|
uploaderId: true,
|
||||||
uploader: {
|
uploader: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+3
-1
@@ -129,8 +129,9 @@ export class TourRoleGuard implements CanActivate {
|
|||||||
tourId = loc.leg.tourId;
|
tourId = loc.leg.tourId;
|
||||||
} else {
|
} else {
|
||||||
// Thử xem resourceId có phải là photoId không
|
// Thử xem resourceId có phải là photoId không
|
||||||
const photo = await this.prisma.photo.findUnique({ where: { id: resourceId }, select: { tourId: true } });
|
const photo = await this.prisma.photo.findUnique({ where: { id: resourceId }, select: { tourId: true, uploaderId: true } });
|
||||||
if (photo) {
|
if (photo) {
|
||||||
|
if (user && photo.uploaderId === user.id) return true; // Cho phép chủ sở hữu ảnh đi qua
|
||||||
if (!photo.tourId) return true; // Cho phép đi qua nếu ảnh không thuộc tour nào (ví dụ ảnh ẩn danh public)
|
if (!photo.tourId) return true; // Cho phép đi qua nếu ảnh không thuộc tour nào (ví dụ ảnh ẩn danh public)
|
||||||
tourId = photo.tourId;
|
tourId = photo.tourId;
|
||||||
}
|
}
|
||||||
@@ -2311,6 +2312,7 @@ class PublicPhotoController {
|
|||||||
originalUrl: true,
|
originalUrl: true,
|
||||||
capturedAt: true,
|
capturedAt: true,
|
||||||
metadata: true,
|
metadata: true,
|
||||||
|
uploaderId: true,
|
||||||
uploader: {
|
uploader: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
+44
-44
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<title>Travel Planner</title>
|
<title>Travel Planner</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DChzoIDA.js"></script>
|
<script type="module" crossorigin src="/assets/index-UWqs1Nt4.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CHBet_N8.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-I6bJ9cOX.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||||
const [isMapOpen, setIsMapOpen] = useState(false);
|
const [isMapOpen, setIsMapOpen] = useState(false);
|
||||||
const [resolvedAddress, setResolvedAddress] = useState<string>('');
|
const [resolvedAddress, setResolvedAddress] = useState<string>('');
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const lat = photo?.metadata?.lat;
|
const lat = photo?.metadata?.lat;
|
||||||
@@ -89,7 +90,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
}, [photo?.id, photo?.metadata?.lat, photo?.metadata?.lng]);
|
}, [photo?.id, photo?.metadata?.lat, photo?.metadata?.lng]);
|
||||||
|
|
||||||
const checkCurrentUser = () => {
|
const checkCurrentUser = () => {
|
||||||
const userStr = localStorage.getItem('user');
|
const userStr = localStorage.getItem('user') || localStorage.getItem('guest_user');
|
||||||
if (userStr) {
|
if (userStr) {
|
||||||
try {
|
try {
|
||||||
setCurrentUser(JSON.parse(userStr));
|
setCurrentUser(JSON.parse(userStr));
|
||||||
@@ -125,7 +126,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
|
|
||||||
setIsSavingEdit(true);
|
setIsSavingEdit(true);
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
|
||||||
const res = await fetch(`/api/v1/photos/${photo.id}`, {
|
const res = await fetch(`/api/v1/photos/${photo.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -165,7 +166,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
const likeCount = likedUserIds.length;
|
const likeCount = likedUserIds.length;
|
||||||
|
|
||||||
const handleToggleLike = async () => {
|
const handleToggleLike = async () => {
|
||||||
let token = localStorage.getItem('token');
|
let token = localStorage.getItem('token') || localStorage.getItem('guest_token');
|
||||||
let userObj = currentUser;
|
let userObj = currentUser;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -362,18 +363,18 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Container */}
|
{/* 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 */}
|
{/* Close Button Mobile/Desktop */}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Left Side: Photo Detail */}
|
{/* 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 */}
|
{/* Like Button Overlay */}
|
||||||
<button
|
<button
|
||||||
onClick={handleToggleLike}
|
onClick={handleToggleLike}
|
||||||
@@ -389,10 +390,11 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
<img
|
<img
|
||||||
src={photo.imageUrl}
|
src={photo.imageUrl}
|
||||||
alt="Public Map Upload"
|
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 */}
|
{/* 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 */}
|
{/* Timeline scroll */}
|
||||||
{photoGroup && photoGroup.length > 1 && (
|
{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" />
|
<MapPin className="w-3.5 h-3.5 text-rose-500" />
|
||||||
Địa điểm: {resolvedAddress}
|
Địa điểm: {resolvedAddress}
|
||||||
</div>
|
</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" />
|
<User className="w-3.5 h-3.5" />
|
||||||
Người đăng: {photo.uploader?.name || 'Ẩn danh'}
|
Người đăng: {photo.uploader?.name || 'Ẩn danh'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{photo.originalUrl && (
|
{isAuthorized && photo.originalUrl && (
|
||||||
<a
|
<a
|
||||||
href={photo.originalUrl}
|
href={photo.originalUrl}
|
||||||
download
|
download
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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" />
|
<Download className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
Tải ảnh gốc
|
Tải ảnh gốc
|
||||||
@@ -592,8 +594,36 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* 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 */}
|
{/* Comments Header */}
|
||||||
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
|
<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);
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { ChevronLeft, Image as ImageIcon, Download, Calendar, MapPin, Loader2, Filter, X, Trash2, Edit, Heart } from 'lucide-react';
|
||||||
import { useNotification } from '@/hooks/useNotification';
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
import { useConfirm } from '@/hooks/useConfirm';
|
import { useConfirm } from '@/hooks/useConfirm';
|
||||||
@@ -22,6 +22,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||||
const [isMapOpen, setIsMapOpen] = useState(false);
|
const [isMapOpen, setIsMapOpen] = useState(false);
|
||||||
const [resolvedAddress, setResolvedAddress] = useState<string>('');
|
const [resolvedAddress, setResolvedAddress] = useState<string>('');
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const lat = selectedPhotoForDisplay?.metadata?.lat;
|
const lat = selectedPhotoForDisplay?.metadata?.lat;
|
||||||
@@ -75,7 +76,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: editTitle,
|
title: editTitle,
|
||||||
@@ -103,7 +104,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const currentUser = useMemo(() => {
|
const currentUser = useMemo(() => {
|
||||||
const userStr = localStorage.getItem('user');
|
const userStr = localStorage.getItem('user') || localStorage.getItem('guest_user');
|
||||||
if (!userStr) return null;
|
if (!userStr) return null;
|
||||||
try {
|
try {
|
||||||
return JSON.parse(userStr);
|
return JSON.parse(userStr);
|
||||||
@@ -120,7 +121,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
|
|
||||||
const handleToggleLike = async () => {
|
const handleToggleLike = async () => {
|
||||||
if (!selectedPhotoForDisplay) return;
|
if (!selectedPhotoForDisplay) return;
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -146,7 +147,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/users/me/photos', {
|
const response = await fetch('/api/v1/users/me/photos', {
|
||||||
headers: {
|
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');
|
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}`, {
|
const response = await fetch(`/api/v1/photos/${photoId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
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
|
<img
|
||||||
src={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl}
|
src={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl}
|
||||||
alt="Selected Photo"
|
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) */}
|
{/* Overlays (Only show when not editing) */}
|
||||||
@@ -378,7 +380,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Bottom metadata details gradient panel overlay */}
|
{/* 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 justify-between items-start gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{selectedPhotoForDisplay.metadata?.title ? (
|
{selectedPhotoForDisplay.metadata?.title ? (
|
||||||
@@ -584,6 +586,25 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
setEditLng(lng);
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -504,7 +504,7 @@ export const TourDetailPage = ({
|
|||||||
const [isMemberDetailOpen, setIsMemberDetailOpen] = useState(false);
|
const [isMemberDetailOpen, setIsMemberDetailOpen] = useState(false);
|
||||||
const [selectedPhotoForDisplay, setSelectedPhotoForDisplay] = useState<any | null>(null); // New state for large photo display
|
const [selectedPhotoForDisplay, setSelectedPhotoForDisplay] = useState<any | null>(null); // New state for large photo display
|
||||||
const currentUserId = useMemo(() => {
|
const currentUserId = useMemo(() => {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
try {
|
try {
|
||||||
return JSON.parse(atob(token.split('.')[1])).sub;
|
return JSON.parse(atob(token.split('.')[1])).sub;
|
||||||
@@ -514,7 +514,7 @@ export const TourDetailPage = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const currentUser = useMemo(() => {
|
const currentUser = useMemo(() => {
|
||||||
const userStr = localStorage.getItem('user');
|
const userStr = localStorage.getItem('user') || localStorage.getItem('guest_user');
|
||||||
if (!userStr) return null;
|
if (!userStr) return null;
|
||||||
try {
|
try {
|
||||||
return JSON.parse(userStr);
|
return JSON.parse(userStr);
|
||||||
@@ -531,6 +531,7 @@ export const TourDetailPage = ({
|
|||||||
const [isSavingPhotoEdit, setIsSavingPhotoEdit] = useState(false);
|
const [isSavingPhotoEdit, setIsSavingPhotoEdit] = useState(false);
|
||||||
const [isMapOpen, setIsMapOpen] = useState(false);
|
const [isMapOpen, setIsMapOpen] = useState(false);
|
||||||
const [resolvedAddress, setResolvedAddress] = useState<string>('');
|
const [resolvedAddress, setResolvedAddress] = useState<string>('');
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
const likedUserIds = selectedPhotoForDisplay && selectedPhotoForDisplay.metadata && Array.isArray(selectedPhotoForDisplay.metadata.likedUserIds)
|
const likedUserIds = selectedPhotoForDisplay && selectedPhotoForDisplay.metadata && Array.isArray(selectedPhotoForDisplay.metadata.likedUserIds)
|
||||||
? selectedPhotoForDisplay.metadata.likedUserIds
|
? selectedPhotoForDisplay.metadata.likedUserIds
|
||||||
@@ -544,7 +545,7 @@ export const TourDetailPage = ({
|
|||||||
const response = await fetch(`/api/v1/photos/${selectedPhotoForDisplay.id}/toggle-like`, {
|
const response = await fetch(`/api/v1/photos/${selectedPhotoForDisplay.id}/toggle-like`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -621,7 +622,7 @@ export const TourDetailPage = ({
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: editPhotoTitle,
|
title: editPhotoTitle,
|
||||||
@@ -735,7 +736,7 @@ export const TourDetailPage = ({
|
|||||||
const response = await fetch(`/api/v1/photos/${photoId}`, {
|
const response = await fetch(`/api/v1/photos/${photoId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2057,7 +2058,8 @@ export const TourDetailPage = ({
|
|||||||
<img
|
<img
|
||||||
src={selectedPhotoForDisplay.imageUrl}
|
src={selectedPhotoForDisplay.imageUrl}
|
||||||
alt="Selected Tour Photo"
|
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) */}
|
{/* Overlays (Only show when not editing) */}
|
||||||
@@ -2076,7 +2078,7 @@ export const TourDetailPage = ({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Delete button overlay (if owner) */}
|
{/* Delete button overlay (if owner) */}
|
||||||
{currentUserId === selectedPhotoForDisplay.uploaderId && !isPublicView && (
|
{currentUserId === selectedPhotoForDisplay.uploaderId && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -2090,7 +2092,7 @@ export const TourDetailPage = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bottom metadata details gradient panel overlay */}
|
{/* 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 justify-between items-start gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{selectedPhotoForDisplay.metadata?.title ? (
|
{selectedPhotoForDisplay.metadata?.title ? (
|
||||||
@@ -2110,7 +2112,7 @@ export const TourDetailPage = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Edit Button Overlay */}
|
{/* Edit Button Overlay */}
|
||||||
{!isPublicView && (currentUser?.isAdmin || currentUserId === selectedPhotoForDisplay.uploaderId) && (
|
{((!isPublicView && currentUser?.isAdmin) || (currentUserId && currentUserId === selectedPhotoForDisplay.uploaderId)) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsEditingPhoto(true)}
|
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"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedPhotoForDisplay.originalUrl && (
|
{(currentUser?.isAdmin || currentUserId === selectedPhotoForDisplay.uploaderId) && selectedPhotoForDisplay.originalUrl && (
|
||||||
<a
|
<a
|
||||||
href={selectedPhotoForDisplay.originalUrl}
|
href={selectedPhotoForDisplay.originalUrl}
|
||||||
download
|
download
|
||||||
@@ -2744,6 +2746,25 @@ export const TourDetailPage = ({
|
|||||||
setEditPhotoLng(lng);
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user