diff --git a/frontend/src/components/MediaCarousel.css b/frontend/src/components/MediaCarousel.css new file mode 100644 index 0000000..6662600 --- /dev/null +++ b/frontend/src/components/MediaCarousel.css @@ -0,0 +1,155 @@ +.media-carousel { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.95); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; +} + +.carousel-close-btn { + position: absolute; + top: 1rem; + right: 1rem; + background-color: rgba(255, 255, 255, 0.9); + border: none; + border-radius: 50%; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 10001; + transition: background-color 0.2s ease, transform 0.2s ease; +} + +.carousel-close-btn:hover { + background-color: white; + transform: scale(1.1); +} + +.carousel-counter { + position: absolute; + top: 1rem; + left: 50%; + transform: translateX(-50%); + color: white; + background-color: rgba(0, 0, 0, 0.6); + padding: 0.5rem 1rem; + border-radius: 20px; + font-size: 0.9rem; + z-index: 10001; +} + +.carousel-info { + position: absolute; + bottom: 1rem; + left: 50%; + transform: translateX(-50%); + color: white; + background-color: rgba(0, 0, 0, 0.6); + padding: 0.75rem 1.5rem; + border-radius: 8px; + z-index: 10001; + max-width: 80%; + text-align: center; +} + +.carousel-nav-btn { + position: absolute; + top: 50%; + transform: translateY(-50%); + background-color: rgba(255, 255, 255, 0.9); + border: none; + border-radius: 50%; + width: 50px; + height: 50px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 10001; + transition: background-color 0.2s ease, transform 0.2s ease; +} + +.carousel-nav-btn:hover { + background-color: white; + transform: translateY(-50%) scale(1.1); +} + +.carousel-prev-btn { + left: 1rem; +} + +.carousel-next-btn { + right: 1rem; +} + +.carousel-media-container { + max-width: 90vw; + max-height: 90vh; + display: flex; + align-items: center; + justify-content: center; +} + +.carousel-media { + max-width: 100%; + max-height: 90vh; + object-fit: contain; + border-radius: 8px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); +} + +/* Mobile adjustments */ +@media (max-width: 768px) { + .media-carousel { + padding: 1rem; + } + + .carousel-nav-btn { + width: 40px; + height: 40px; + } + + .carousel-close-btn { + top: 0.5rem; + right: 0.5rem; + } + + .carousel-counter { + top: 0.5rem; + font-size: 0.8rem; + padding: 0.4rem 0.8rem; + } + + .carousel-info { + bottom: 0.5rem; + padding: 0.5rem 1rem; + font-size: 0.85rem; + max-width: 90%; + } + + .carousel-prev-btn { + left: 0.5rem; + } + + .carousel-next-btn { + right: 0.5rem; + } +} + +/* Keyboard navigation hint */ +@media (min-width: 769px) { + .carousel-nav-btn::after { + content: ''; + position: absolute; + inset: -5px; + } +} diff --git a/frontend/src/components/MediaCarousel.jsx b/frontend/src/components/MediaCarousel.jsx new file mode 100644 index 0000000..519685e --- /dev/null +++ b/frontend/src/components/MediaCarousel.jsx @@ -0,0 +1,213 @@ +import { useState, useEffect, useRef } from 'react' +import { format } from 'date-fns' +import './MediaCarousel.css' + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api' + +function MediaCarousel({ mediaItems, initialIndex, onClose, transcodeVideos = new Set() }) { + const [currentIndex, setCurrentIndex] = useState(initialIndex) + const [touchStart, setTouchStart] = useState(null) + const [touchEnd, setTouchEnd] = useState(null) + const videoRef = useRef(null) + + const currentItem = mediaItems[currentIndex] + const isVideo = currentItem?.media_type?.startsWith('video/') + const shouldTranscode = transcodeVideos.has(currentItem?.id) + + useEffect(() => { + // Prevent body scroll when carousel is open + document.body.style.overflow = 'hidden' + + // Hide UI elements that might appear over the carousel + const datePickers = document.querySelectorAll('.react-datepicker-popper') + datePickers.forEach(picker => { + picker.style.display = 'none' + }) + + const dateFilterContainer = document.querySelector('.date-filter-container') + if (dateFilterContainer) { + dateFilterContainer.style.visibility = 'hidden' + } + + const header = document.querySelector('header') + if (header) { + header.style.visibility = 'hidden' + } + + return () => { + document.body.style.overflow = 'unset' + + // Restore visibility of hidden elements + const datePickers = document.querySelectorAll('.react-datepicker-popper') + datePickers.forEach(picker => { + picker.style.display = '' + }) + + const dateFilterContainer = document.querySelector('.date-filter-container') + if (dateFilterContainer) { + dateFilterContainer.style.visibility = '' + } + + const header = document.querySelector('header') + if (header) { + header.style.visibility = '' + } + } + }, []) + + useEffect(() => { + const handleKeyDown = (e) => { + if (e.key === 'Escape') { + onClose() + } else if (e.key === 'ArrowLeft') { + handlePrevious() + } else if (e.key === 'ArrowRight') { + handleNext() + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [currentIndex]) + + const handlePrevious = () => { + if (currentIndex > 0) { + setCurrentIndex(currentIndex - 1) + } + } + + const handleNext = () => { + if (currentIndex < mediaItems.length - 1) { + setCurrentIndex(currentIndex + 1) + } + } + + // Touch handlers for swipe gestures + const handleTouchStart = (e) => { + setTouchEnd(null) + setTouchStart(e.targetTouches[0].clientX) + } + + const handleTouchMove = (e) => { + setTouchEnd(e.targetTouches[0].clientX) + } + + const handleTouchEnd = () => { + if (!touchStart || !touchEnd) return + + const distance = touchStart - touchEnd + const isLeftSwipe = distance > 50 + const isRightSwipe = distance < -50 + + if (isLeftSwipe) { + handleNext() + } + if (isRightSwipe) { + handlePrevious() + } + } + + const formatTime = (date) => { + return format(new Date(date), 'MMM d, yyyy h:mm a') + } + + return ( +
+ {/* Close button */} + + + {/* Counter */} +
+ {currentIndex + 1} / {mediaItems.length} +
+ + {/* Media info */} +
+
{formatTime(currentItem.date)}
+ {currentItem.body && ( +
{currentItem.body}
+ )} +
+ + {/* Previous button */} + {currentIndex > 0 && ( + + )} + + {/* Next button */} + {currentIndex < mediaItems.length - 1 && ( + + )} + + {/* Media content */} +
e.stopPropagation()} + > + {isVideo ? ( +
+
+ ) +} + +export default MediaCarousel diff --git a/frontend/src/components/MediaGrid.css b/frontend/src/components/MediaGrid.css new file mode 100644 index 0000000..c9ff28f --- /dev/null +++ b/frontend/src/components/MediaGrid.css @@ -0,0 +1,66 @@ +.media-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 8px; + padding: 16px; + width: 100%; +} + +.media-grid-item { + position: relative; + aspect-ratio: 1; + overflow: hidden; + border-radius: 8px; + cursor: pointer; + background-color: #f0f0f0; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.media-grid-item:hover { + transform: scale(1.05); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1; +} + +.media-thumbnail { + width: 100%; + height: 100%; + position: relative; +} + +.media-thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.video-indicator { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: rgba(0, 0, 0, 0.6); + border-radius: 50%; + width: 3rem; + height: 3rem; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .media-grid { + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 4px; + padding: 8px; + } +} + +@media (min-width: 1200px) { + .media-grid { + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + } +} diff --git a/frontend/src/components/MediaGrid.jsx b/frontend/src/components/MediaGrid.jsx new file mode 100644 index 0000000..4e4d6c0 --- /dev/null +++ b/frontend/src/components/MediaGrid.jsx @@ -0,0 +1,182 @@ +import { useState, useEffect } from 'react' +import axios from 'axios' +import MediaCarousel from './MediaCarousel' +import './MediaGrid.css' + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api' + +function MediaGrid({ conversation, startDate, endDate }) { + const [mediaItems, setMediaItems] = useState([]) + const [loading, setLoading] = useState(false) + const [selectedIndex, setSelectedIndex] = useState(null) + const [failedVideos, setFailedVideos] = useState(new Set()) + const [transcodeVideos, setTranscodeVideos] = useState(new Set()) + + useEffect(() => { + fetchMediaItems() + setFailedVideos(new Set()) // Reset failed videos when conversation changes + setTranscodeVideos(new Set()) // Reset transcode videos when conversation changes + }, [conversation, startDate, endDate]) + + const fetchMediaItems = async () => { + setLoading(true) + try { + const params = { + address: conversation.address + } + if (startDate) params.start = startDate.toISOString() + if (endDate) params.end = endDate.toISOString() + + const response = await axios.get(`${API_BASE}/media-items`, { params }) + setMediaItems(response.data || []) + } catch (error) { + console.error('Error fetching media items:', error) + } finally { + setLoading(false) + } + } + + const handleThumbnailClick = (index) => { + setSelectedIndex(index) + } + + const handleCloseCarousel = () => { + setSelectedIndex(null) + } + + const handleVideoError = (itemId, alreadyTranscoded = false) => { + if (!alreadyTranscoded) { + console.log('Video failed to play, retrying with transcoding:', itemId) + // Mark this video to be transcoded and it will reload + setTranscodeVideos(prev => new Set([...prev, itemId])) + return + } + // If already transcoded and still failing, hide it + console.warn('Video not playable even after transcoding:', itemId) + setFailedVideos(prev => new Set([...prev, itemId])) + } + + // Filter out failed videos from display + const displayableItems = mediaItems.filter(item => !failedVideos.has(item.id)) + + if (loading) { + return ( +
+
+ Loading media... +
+
+ ) + } + + if (displayableItems.length === 0 && !loading) { + return ( +
+ + + +

+ {mediaItems.length === 0 + ? 'No photos or videos found in this conversation' + : 'No browser-compatible photos or videos found in this conversation'} +

+
+ ) + } + + return ( + <> + {transcodeVideos.size > 0 && failedVideos.size === 0 && ( +
+ + {transcodeVideos.size} video{transcodeVideos.size > 1 ? 's are' : ' is'} being transcoded for browser compatibility... + +
+ )} + {failedVideos.size > 0 && ( +
+ + {failedVideos.size} video{failedVideos.size > 1 ? 's' : ''} could not be transcoded. + These videos can still be viewed in the regular message view. + +
+ )} +
+ {displayableItems.map((item, index) => { + const isVideo = item.media_type?.startsWith('video/') + return ( +
handleThumbnailClick(index)} + > +
+ {isVideo ? ( + <> +
+
+ ) + })} +
+ + {selectedIndex !== null && ( + + )} + + ) +} + +export default MediaGrid diff --git a/frontend/src/components/MessageThread.jsx b/frontend/src/components/MessageThread.jsx index 35043e3..85d3cf1 100644 --- a/frontend/src/components/MessageThread.jsx +++ b/frontend/src/components/MessageThread.jsx @@ -3,6 +3,7 @@ import { useLocation } from 'react-router-dom' import axios from 'axios' import { format } from 'date-fns' import LazyMedia from './LazyMedia' +import MediaGrid from './MediaGrid' const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api' @@ -12,12 +13,14 @@ function MessageThread({ conversation, startDate, endDate }) { const [loading, setLoading] = useState(false) const [highlightedMessageId, setHighlightedMessageId] = useState(null) const [isPreprintingMedia, setIsPreprintingMedia] = useState(false) + const [showMediaOnly, setShowMediaOnly] = useState(false) const messageRefs = useRef({}) const printTriggeredRef = useRef(false) useEffect(() => { if (conversation) { fetchItems() + setShowMediaOnly(false) // Reset to message view when conversation changes } else { setItems([]) } @@ -464,7 +467,17 @@ function MessageThread({ conversation, startDate, endDate }) { -
+
+