Created
January 6, 2025 01:58
-
-
Save ammark47/d266d6f03ec43a6a23f1ef3ece29f088 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useState, useRef, TouchEvent, useEffect } from 'react'; | |
import { Link } from 'react-router-dom'; | |
import { Heart, Eye, MapPin, Share2, Calendar, Tag, Edit, Trash2, List, Info } from 'lucide-react'; | |
import { useUser } from '@clerk/clerk-react'; | |
import { useFavorite } from '../hooks/useFavorite'; | |
import { FastComments } from '@/features/comments'; | |
import { ShareModal } from './ShareModal'; | |
import { TreasureMapViewer } from './TreasureMapViewer'; | |
import { MarkerList } from './MarkerList'; | |
import { AddToTreasureMapGroupModal } from '@/features/treasureMapGroups/components'; | |
import type { TreasureMap } from '@/types/global'; | |
import { format, isValid, parseISO } from 'date-fns'; | |
import { cn } from '@/lib/utils'; | |
import { useAuthButton } from '@/features/auth/hooks'; | |
import { useIsMobile } from '@/hooks/useIsMobile'; | |
interface TreasureMapDetailProps { | |
treasureMap: TreasureMap; | |
onEdit?: ((data: TreasureMap) => Promise<void>) | undefined; | |
onDelete?: (() => Promise<void>) | undefined; | |
isDeleting: boolean; | |
isLoading?: boolean; | |
} | |
export function TreasureMapDetail({ | |
treasureMap, | |
onEdit, | |
onDelete, | |
isDeleting, | |
isLoading = false | |
}: TreasureMapDetailProps) { | |
const [showShareModal, setShowShareModal] = useState(false); | |
const [showAddToGroupModal, setShowAddToGroupModal] = useState(false); | |
const [selectedMarkerId, setSelectedMarkerId] = useState<string | undefined>(undefined); | |
const [showMarkerList, setShowMarkerList] = useState(false); | |
const [activeTab, setActiveTab] = useState<'main' | 'comments'>('main'); | |
const { isSignedIn, user } = useUser(); | |
const { isFavorited, toggleFavorite } = useFavorite(); | |
const renderAuthButton = useAuthButton(); | |
const [showDetails, setShowDetails] = useState(false); | |
const markerPanelRef = useRef<HTMLDivElement>(null); | |
const touchStartY = useRef<number>(0); | |
const currentTranslateY = useRef<number>(0); | |
const isMobile = useIsMobile(); | |
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); | |
const isAnimating = useRef(false); | |
const handleFavoriteClick = async () => { | |
if (!isSignedIn) return; | |
await toggleFavorite(treasureMap.treasureMapId); | |
}; | |
const handleMarkerClick = (markerId: string) => { | |
// First update the selected marker | |
setSelectedMarkerId(markerId); | |
// If we're clicking from the map and the list isn't shown, | |
// ensure the list opens after a brief delay | |
if (!showMarkerList && !isAnimating.current) { | |
isAnimating.current = true; | |
// Reset any existing transition | |
if (markerPanelRef.current) { | |
markerPanelRef.current.style.transition = 'none'; | |
markerPanelRef.current.style.transform = 'translateY(100%)'; | |
} | |
// Force a reflow | |
setTimeout(() => { | |
if (markerPanelRef.current) { | |
markerPanelRef.current.style.transition = 'transform 300ms ease-out'; | |
} | |
setShowMarkerList(true); | |
// Reset animation flag after transition completes | |
setTimeout(() => { | |
isAnimating.current = false; | |
}, 300); | |
}, 50); | |
} | |
}; | |
const formatDate = (dateString: string | undefined | null) => { | |
if (!dateString) return null; | |
try { | |
const date = parseISO(dateString); | |
return isValid(date) ? format(date, 'MMM d, yyyy') : null; | |
} catch (error) { | |
console.error('Error formatting date:', error); | |
return null; | |
} | |
}; | |
const handleTouchStart = (e: TouchEvent) => { | |
touchStartY.current = e.touches[0].clientY; | |
if (markerPanelRef.current) { | |
markerPanelRef.current.style.transition = 'none'; | |
} | |
}; | |
const handleTouchMove = (e: TouchEvent) => { | |
if (!markerPanelRef.current) return; | |
const deltaY = e.touches[0].clientY - touchStartY.current; | |
// Only allow dragging downwards | |
if (deltaY < 0) return; | |
currentTranslateY.current = deltaY; | |
markerPanelRef.current.style.transform = `translateY(${deltaY}px)`; | |
}; | |
const handleTouchEnd = (e: TouchEvent) => { | |
if (!markerPanelRef.current || isAnimating.current) return; | |
isAnimating.current = true; | |
markerPanelRef.current.style.transition = 'transform 300ms ease-out'; | |
// If dragged more than 100px down, close the panel | |
if (currentTranslateY.current > 100) { | |
markerPanelRef.current.style.transform = 'translateY(100%)'; | |
setShowMarkerList(false); | |
} else { | |
markerPanelRef.current.style.transform = 'translateY(0)'; | |
} | |
currentTranslateY.current = 0; | |
// Reset animation flag after transition completes | |
setTimeout(() => { | |
isAnimating.current = false; | |
}, 300); | |
}; | |
// Add this effect to handle panel visibility | |
useEffect(() => { | |
if (markerPanelRef.current) { | |
markerPanelRef.current.style.transform = showMarkerList ? 'translateY(0)' : 'translateY(100%)'; | |
} | |
}, [showMarkerList]); | |
if (isLoading || !treasureMap) { | |
return null; | |
} | |
const formattedDate = formatDate(treasureMap.createdAt); | |
return ( | |
<div className="min-h-screen bg-gray-50"> | |
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 lg:py-8"> | |
{/* Header Card - Compact version for mobile comments tab */} | |
<div className={`bg-white rounded-2xl shadow-sm overflow-hidden mb-6 ${ | |
activeTab === 'comments' ? 'lg:block' : '' | |
}`}> | |
{activeTab === 'comments' ? ( | |
// Compact mobile header for comments tab | |
<div className="lg:hidden px-4 py-3 flex items-center justify-between" style={{ outline: '1px solid green' }}> | |
<Link | |
to={`/profile/${treasureMap.creator.username}`} | |
className="flex items-center gap-3 min-w-0" | |
> | |
<img | |
src={treasureMap.creator.avatar} | |
alt={treasureMap.creator.username} | |
className="w-8 h-8 rounded-full" | |
/> | |
<div className="min-w-0"> | |
<h2 className="font-medium text-gray-900 truncate"> | |
{treasureMap.title} | |
</h2> | |
<p className="text-sm text-gray-500 truncate"> | |
by {treasureMap.creator.username} | |
</p> | |
</div> | |
</Link> | |
<div className="flex items-center gap-2"> | |
{renderAuthButton( | |
handleFavoriteClick, | |
<div className={cn( | |
"p-2 rounded-full flex items-center gap-1.5", | |
isFavorited(treasureMap.treasureMapId) | |
? "text-red-500" | |
: "text-gray-400 hover:text-gray-500" | |
)}> | |
<Heart className={cn( | |
"w-5 h-5", | |
isFavorited(treasureMap.treasureMapId) && "fill-current" | |
)} /> | |
<span className="text-sm font-medium"> | |
{treasureMap.favoritesCount || 0} | |
</span> | |
</div> | |
)} | |
<button | |
onClick={() => setShowShareModal(true)} | |
className="p-2 text-gray-400 hover:text-gray-500 rounded-full" | |
> | |
<Share2 className="w-5 h-5" /> | |
</button> | |
{/* Add creator-specific actions */} | |
{onEdit && ( | |
<button | |
onClick={() => onEdit(treasureMap)} | |
data-testid="edit-map-button" | |
className="p-2 text-gray-400 hover:text-gray-500 rounded-full" | |
> | |
<Edit className="w-5 h-5" /> | |
</button> | |
)} | |
{onDelete && ( | |
<button | |
onClick={onDelete} | |
disabled={isDeleting} | |
data-testid="delete-map-button" | |
className={cn( | |
"p-2 rounded-full", | |
isDeleting | |
? "text-gray-300" | |
: "text-red-400 hover:text-red-500" | |
)} | |
> | |
<Trash2 className="w-5 h-5" /> | |
</button> | |
)} | |
</div> | |
</div> | |
) : ( | |
// Original header content for other views | |
<div className="relative bg-gradient-to-r from-indigo-500/5 to-purple-500/5 px-4 lg:px-8 pt-6 lg:pt-12 pb-4 lg:pb-8"> | |
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6"> | |
{/* Title and metadata - Full width on mobile */} | |
<div className="flex-1 min-w-0"> | |
<h1 className="text-3xl font-bold text-gray-900 mb-4 break-words" data-testid="map-title"> | |
{treasureMap.title} | |
</h1> | |
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-gray-600 text-sm mb-6"> | |
<div className="flex items-center gap-1.5" data-testid="map-location"> | |
<MapPin className="w-4 h-4 text-gray-400" /> | |
<span>{treasureMap.location.name}</span> | |
</div> | |
{formattedDate && ( | |
<div className="flex items-center gap-1.5"> | |
<Calendar className="w-4 h-4 text-gray-400" /> | |
<span>{formattedDate}</span> | |
</div> | |
)} | |
<div className="flex items-center gap-1.5" data-testid="map-category"> | |
<Tag className="w-4 h-4 text-gray-400" /> | |
<span>{treasureMap.category}</span> | |
</div> | |
<div className="flex items-center gap-1.5"> | |
<Eye className="w-4 h-4 text-gray-400" /> | |
<span>{treasureMap.viewCount} {treasureMap.viewCount === 1 ? 'view' : 'views'}</span> | |
</div> | |
</div> | |
{/* Creator info */} | |
<Link | |
to={`/profile/${treasureMap.creator.username}`} | |
className="inline-flex items-center gap-3 hover:bg-black/5 rounded-full p-1 transition-colors" | |
> | |
<img | |
src={treasureMap.creator.avatar} | |
alt={treasureMap.creator.username} | |
className="w-10 h-10 rounded-full ring-2 ring-white" | |
/> | |
<div> | |
<p className="font-medium text-gray-900">{treasureMap.creator.username}</p> | |
</div> | |
</Link> | |
</div> | |
{/* Actions - Horizontal scroll on mobile */} | |
<div className="flex items-center gap-3 overflow-x-auto pb-2 lg:pb-0 -mx-4 px-4 lg:mx-0 lg:px-0" data-testid="treasure-map-actions"> | |
{renderAuthButton( | |
handleFavoriteClick, | |
<div className={cn( | |
"flex items-center gap-2 px-4 py-2 rounded-full transition-all", | |
"hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent", | |
isFavorited(treasureMap.treasureMapId) | |
? "bg-red-500 text-white hover:bg-red-600 hover:ring-red-500" | |
: "bg-white text-gray-700 hover:bg-gray-50 hover:ring-gray-200 ring-1 ring-gray-200" | |
)}> | |
<Heart className={cn( | |
"w-5 h-5", | |
isFavorited(treasureMap.treasureMapId) && "fill-current" | |
)} /> | |
<span className="font-medium" data-testid="favorite-count"> | |
{treasureMap.favoritesCount || 0} | |
</span> | |
</div> | |
)} | |
{renderAuthButton( | |
() => setShowAddToGroupModal(true), | |
<div className="flex items-center gap-2 px-4 py-2 bg-white text-gray-700 rounded-full transition-all hover:bg-gray-50 hover:ring-2 hover:ring-gray-200 hover:ring-offset-2 hover:ring-offset-transparent ring-1 ring-gray-200"> | |
<List className="w-5 h-5" /> | |
<span className="font-medium">Add to Collection</span> | |
</div> | |
)} | |
<button | |
onClick={() => setShowShareModal(true)} | |
className="flex items-center gap-2 px-4 py-2 bg-white text-gray-700 rounded-full transition-all hover:bg-gray-50 hover:ring-2 hover:ring-gray-200 hover:ring-offset-2 hover:ring-offset-transparent ring-1 ring-gray-200" | |
> | |
<Share2 className="w-5 h-5" /> | |
<span className="font-medium">Share</span> | |
</button> | |
{onEdit && ( | |
<button | |
onClick={() => onEdit(treasureMap)} | |
data-testid="edit-map-button" | |
className="flex items-center gap-2 px-4 py-2 bg-white text-gray-700 rounded-full transition-all hover:bg-gray-50 hover:ring-2 hover:ring-gray-200 hover:ring-offset-2 hover:ring-offset-transparent ring-1 ring-gray-200" | |
> | |
<Edit className="w-5 h-5" /> | |
<span className="font-medium">Edit</span> | |
</button> | |
)} | |
{onDelete && ( | |
<button | |
onClick={onDelete} | |
disabled={isDeleting} | |
data-testid="delete-map-button" | |
className="flex items-center gap-2 px-4 py-2 bg-white text-red-600 rounded-full transition-all hover:bg-red-50 hover:ring-2 hover:ring-red-200 hover:ring-offset-2 hover:ring-offset-transparent ring-1 ring-red-200 disabled:opacity-50" | |
> | |
<Trash2 className="w-5 h-5" /> | |
<span className="font-medium"> | |
{isDeleting ? 'Deleting...' : 'Delete'} | |
</span> | |
</button> | |
)} | |
</div> | |
</div> | |
</div> | |
)} | |
{/* Description and Tags */} | |
{(activeTab !== 'comments' || !isMobile) && ( | |
<div className="px-4 lg:px-8 py-6 border-t border-gray-100"> | |
{treasureMap.description && ( | |
<p className="text-gray-600 whitespace-pre-wrap mb-6 last:mb-0" data-testid="map-description"> | |
{treasureMap.description} | |
</p> | |
)} | |
{treasureMap.tags?.length > 0 && ( | |
<div className="flex items-center gap-3"> | |
<Tag className="w-4 h-4 text-gray-400 flex-shrink-0" /> | |
<div className="flex flex-wrap gap-2"> | |
{treasureMap.tags.map(tag => ( | |
<span | |
key={tag.name} | |
className="px-3 py-1 bg-gray-50 text-gray-600 rounded-full text-sm ring-1 ring-gray-200/50" | |
> | |
{tag.name} | |
</span> | |
))} | |
</div> | |
</div> | |
)} | |
</div> | |
)} | |
</div> | |
{/* Mobile Tabs - Adjust button styles */} | |
<div className="lg:hidden border-b border-gray-200 bg-white/95 backdrop-blur-sm fixed bottom-0 left-0 right-0 z-50"> | |
<div className="flex w-full"> | |
<button | |
onClick={() => setActiveTab('main')} | |
className={`flex-1 w-full py-4 px-1 text-center border-t-2 text-sm font-medium relative ${ | |
activeTab === 'main' | |
? 'border-indigo-500 text-indigo-600' | |
: 'border-transparent text-gray-500' | |
}`} | |
> | |
<span className="relative z-10 w-full">Map</span> | |
</button> | |
<button | |
onClick={() => setActiveTab('comments')} | |
className={`flex-1 w-full py-4 px-1 text-center border-t-2 text-sm font-medium relative ${ | |
activeTab === 'comments' | |
? 'border-indigo-500 text-indigo-600' | |
: 'border-transparent text-gray-500' | |
}`} | |
> | |
<span className="relative z-10 w-full">Comments</span> | |
</button> | |
</div> | |
</div> | |
{/* Mobile Content*/} | |
<div className="lg:hidden"> | |
<div className="pb-16"> | |
{activeTab === 'main' && ( | |
<> | |
{/* Full-screen Map with Semi-transparent Header Overlay */} | |
<div className="fixed inset-0 z-0 pointer-events-auto mt-16"> | |
<TreasureMapViewer | |
treasureMap={treasureMap} | |
selectedMarkerId={selectedMarkerId} | |
onMarkerClick={handleMarkerClick} | |
data-testid="treasure-map-viewer" | |
/> | |
{/* Gradient Overlay for Header Visibility */} | |
<div className="absolute top-0 left-0 right-0 h-32 bg-gradient-to-b from-black/50 to-transparent pointer-events-none" /> | |
</div> | |
{/* Update the Floating Header positioning */} | |
<div className="fixed top-16 left-0 right-0 z-10 bg-transparent text-white p-4 pointer-events-none"> | |
<h1 className="text-2xl font-bold mb-2 drop-shadow-md"> | |
{treasureMap.title} | |
</h1> | |
<div className="flex items-center gap-2 mb-4"> | |
<Link | |
to={`/profile/${treasureMap.creator.username}`} | |
className="flex items-center gap-2 pointer-events-auto" | |
> | |
<img | |
src={treasureMap.creator.avatar} | |
alt={treasureMap.creator.username} | |
className="w-8 h-8 rounded-full ring-2 ring-white" | |
/> | |
<span className="font-medium drop-shadow-md"> | |
{treasureMap.creator.username} | |
</span> | |
</Link> | |
</div> | |
{/* Quick Actions Bar */} | |
<div className="flex gap-2 overflow-x-auto pb-2 -mx-4 px-4"> | |
<div className="pointer-events-auto"> | |
{renderAuthButton( | |
handleFavoriteClick, | |
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 backdrop-blur-md"> | |
<Heart className={cn( | |
"w-5 h-5", | |
isFavorited(treasureMap.treasureMapId) ? "fill-red-500 text-red-500" : "text-white" | |
)} /> | |
<span className="font-medium">{treasureMap.favoritesCount || 0}</span> | |
</div> | |
)} | |
</div> | |
<button | |
onClick={() => setShowShareModal(true)} | |
className="flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 backdrop-blur-md text-white pointer-events-auto" | |
> | |
<Share2 className="w-5 h-5" /> | |
</button> | |
<div className="pointer-events-auto"> | |
{renderAuthButton( | |
() => setShowAddToGroupModal(true), | |
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 backdrop-blur-md text-white"> | |
<List className="w-5 h-5" /> | |
</div> | |
)} | |
</div> | |
<button | |
onClick={() => setShowDetails(prev => !prev)} | |
className="flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 backdrop-blur-md text-white pointer-events-auto" | |
> | |
<Info className="w-5 h-5" /> | |
</button> | |
</div> | |
</div> | |
{/* Slide-down Details Panel */} | |
<div className={`fixed top-16 left-0 right-0 z-20 bg-white transform transition-transform duration-300 pointer-events-auto ${ | |
showDetails ? 'translate-y-0' : '-translate-y-full' | |
}`}> | |
<div className="p-4 max-h-[calc(100vh-4rem)] overflow-y-auto"> | |
{/* Full Details Content */} | |
<div className="mb-4"> | |
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-gray-600 text-sm"> | |
{/* ... metadata items ... */} | |
</div> | |
{treasureMap.description && ( | |
<div className="mt-4"> | |
<p className={`text-gray-600 ${!isDescriptionExpanded ? 'line-clamp-3' : ''}`}> | |
{treasureMap.description} | |
</p> | |
{treasureMap.description.length > 150 && ( | |
<button | |
onClick={() => setIsDescriptionExpanded(!isDescriptionExpanded)} | |
className="mt-2 text-sm text-indigo-600 font-medium hover:text-indigo-700" | |
> | |
{isDescriptionExpanded ? 'Show Less' : 'Show More'} | |
</button> | |
)} | |
</div> | |
)} | |
</div> | |
<button | |
onClick={() => setShowDetails(false)} | |
className="w-full py-2 text-gray-600" | |
> | |
Close | |
</button> | |
</div> | |
</div> | |
{/* Floating Marker Toggle Button */} | |
<button | |
onClick={() => setShowMarkerList(!showMarkerList)} | |
className="fixed bottom-20 right-4 z-20 bg-white shadow-lg rounded-full p-4 flex items-center gap-2 pointer-events-auto" | |
> | |
<MapPin className={`w-5 h-5 ${showMarkerList ? 'text-indigo-600' : 'text-gray-600'}`} /> | |
<span className="font-medium"> | |
{treasureMap.markers?.length || 0} {(treasureMap.markers?.length || 0) === 1 ? 'Marker' : 'Markers'} | |
</span> | |
</button> | |
{/* Slide-up Marker Panel */} | |
<div | |
ref={markerPanelRef} | |
className={`fixed bottom-16 left-0 right-0 z-20 bg-white rounded-t-xl shadow-lg transform transition-transform duration-300 pointer-events-auto`} | |
onTouchStart={handleTouchStart} | |
onTouchMove={handleTouchMove} | |
onTouchEnd={handleTouchEnd} | |
> | |
<div className="p-4 border-b border-gray-200"> | |
<div | |
className="w-12 h-1 bg-gray-300 rounded-full mx-auto mb-4 cursor-grab active:cursor-grabbing" | |
onTouchStart={(e) => e.stopPropagation()} | |
/> | |
<h3 className="text-lg font-semibold">Markers</h3> | |
</div> | |
<div className="h-[50vh] overflow-y-auto"> | |
<MarkerList | |
markers={treasureMap.markers || []} | |
selectedMarkerId={selectedMarkerId} | |
onMarkerClick={(markerId) => { | |
handleMarkerClick(markerId); | |
}} | |
/> | |
</div> | |
</div> | |
</> | |
)} | |
{activeTab === 'comments' && ( | |
<div className="mb-24"> | |
<FastComments | |
urlId={`treasure-map-${treasureMap.treasureMapId}`} | |
tenantId={import.meta.env.VITE_FASTCOMMENTS_TENANT_ID} | |
data-testid="fastcomments-widget" | |
/> | |
</div> | |
)} | |
</div> | |
</div> | |
{/* Desktop Layout */} | |
<div className="hidden lg:block"> | |
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> | |
{/* Map */} | |
<div className="lg:col-span-2"> | |
<div className="bg-white rounded-xl shadow-sm overflow-hidden"> | |
<div className="aspect-[16/9] relative"> | |
<TreasureMapViewer | |
treasureMap={treasureMap} | |
selectedMarkerId={selectedMarkerId} | |
onMarkerClick={handleMarkerClick} | |
data-testid="treasure-map-viewer" | |
/> | |
</div> | |
</div> | |
</div> | |
{/* Marker List */} | |
<div className="bg-white rounded-xl shadow-sm overflow-hidden h-[600px] flex flex-col"> | |
<MarkerList | |
markers={treasureMap.markers || []} | |
selectedMarkerId={selectedMarkerId} | |
onMarkerClick={handleMarkerClick} | |
/> | |
</div> | |
</div> | |
{/* Comments - Desktop */} | |
<div className="mt-6" data-testid="comments-section"> | |
<FastComments | |
urlId={`treasure-map-${treasureMap.treasureMapId}`} | |
tenantId={import.meta.env.VITE_FASTCOMMENTS_TENANT_ID} | |
data-testid="fastcomments-widget" | |
/> | |
</div> | |
</div> | |
</div> | |
{showShareModal && ( | |
<ShareModal | |
isOpen={showShareModal} | |
onClose={() => setShowShareModal(false)} | |
treasureMap={treasureMap} | |
/> | |
)} | |
{showAddToGroupModal && typeof user?.publicMetadata?.userId === 'string' && ( | |
<AddToTreasureMapGroupModal | |
isOpen={showAddToGroupModal} | |
onClose={() => setShowAddToGroupModal(false)} | |
treasureMapId={treasureMap.treasureMapId} | |
userId={user.publicMetadata.userId} | |
onSuccess={() => setShowAddToGroupModal(false)} | |
/> | |
)} | |
</div> | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment