Skip to content

Instantly share code, notes, and snippets.

@ammark47
Created January 6, 2025 01:58
Show Gist options
  • Save ammark47/d266d6f03ec43a6a23f1ef3ece29f088 to your computer and use it in GitHub Desktop.
Save ammark47/d266d6f03ec43a6a23f1ef3ece29f088 to your computer and use it in GitHub Desktop.
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