Media-only view
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="media-carousel"
|
||||||
|
onClick={onClose}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
className="carousel-close-btn"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg style={{ width: '1.5rem', height: '1.5rem' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Counter */}
|
||||||
|
<div className="carousel-counter">
|
||||||
|
{currentIndex + 1} / {mediaItems.length}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Media info */}
|
||||||
|
<div className="carousel-info">
|
||||||
|
<div>{formatTime(currentItem.date)}</div>
|
||||||
|
{currentItem.body && (
|
||||||
|
<div className="text-muted small mt-1">{currentItem.body}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Previous button */}
|
||||||
|
{currentIndex > 0 && (
|
||||||
|
<button
|
||||||
|
className="carousel-nav-btn carousel-prev-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handlePrevious()
|
||||||
|
}}
|
||||||
|
aria-label="Previous"
|
||||||
|
>
|
||||||
|
<svg style={{ width: '2rem', height: '2rem' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Next button */}
|
||||||
|
{currentIndex < mediaItems.length - 1 && (
|
||||||
|
<button
|
||||||
|
className="carousel-nav-btn carousel-next-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleNext()
|
||||||
|
}}
|
||||||
|
aria-label="Next"
|
||||||
|
>
|
||||||
|
<svg style={{ width: '2rem', height: '2rem' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Media content */}
|
||||||
|
<div
|
||||||
|
className="carousel-media-container"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{isVideo ? (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
className="carousel-media"
|
||||||
|
src={`${API_BASE}/media?id=${currentItem.id}${shouldTranscode ? '&transcode=true' : ''}`}
|
||||||
|
key={`${currentItem.id}-${shouldTranscode}`}
|
||||||
|
onError={(e) => {
|
||||||
|
console.error('Video playback error:', e)
|
||||||
|
console.error('Video src:', `${API_BASE}/media?id=${currentItem.id}${shouldTranscode ? '&transcode=true' : ''}`)
|
||||||
|
console.error('Video error code:', e.target.error?.code)
|
||||||
|
console.error('Video error message:', e.target.error?.message)
|
||||||
|
}}
|
||||||
|
onLoadStart={() => console.log('Video load started:', currentItem.id)}
|
||||||
|
onCanPlay={() => console.log('Video can play:', currentItem.id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={`${API_BASE}/media?id=${currentItem.id}`}
|
||||||
|
alt={`Media ${currentIndex + 1}`}
|
||||||
|
className="carousel-media"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MediaCarousel
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="d-flex justify-content-center align-items-center" style={{ minHeight: '400px' }}>
|
||||||
|
<div className="spinner-border text-primary" role="status">
|
||||||
|
<span className="visually-hidden">Loading media...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayableItems.length === 0 && !loading) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-5">
|
||||||
|
<svg
|
||||||
|
style={{ width: '4rem', height: '4rem' }}
|
||||||
|
className="mb-3 text-muted opacity-50"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-muted">
|
||||||
|
{mediaItems.length === 0
|
||||||
|
? 'No photos or videos found in this conversation'
|
||||||
|
: 'No browser-compatible photos or videos found in this conversation'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{transcodeVideos.size > 0 && failedVideos.size === 0 && (
|
||||||
|
<div className="alert alert-info mx-3 mt-3 mb-0" role="alert">
|
||||||
|
<small>
|
||||||
|
{transcodeVideos.size} video{transcodeVideos.size > 1 ? 's are' : ' is'} being transcoded for browser compatibility...
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{failedVideos.size > 0 && (
|
||||||
|
<div className="alert alert-warning mx-3 mt-3 mb-0" role="alert">
|
||||||
|
<small>
|
||||||
|
{failedVideos.size} video{failedVideos.size > 1 ? 's' : ''} could not be transcoded.
|
||||||
|
These videos can still be viewed in the regular message view.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="media-grid">
|
||||||
|
{displayableItems.map((item, index) => {
|
||||||
|
const isVideo = item.media_type?.startsWith('video/')
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="media-grid-item"
|
||||||
|
onClick={() => handleThumbnailClick(index)}
|
||||||
|
>
|
||||||
|
<div className="media-thumbnail">
|
||||||
|
{isVideo ? (
|
||||||
|
<>
|
||||||
|
<video
|
||||||
|
src={`${API_BASE}/media?id=${item.id}${transcodeVideos.has(item.id) ? '&transcode=true' : ''}#t=0.1`}
|
||||||
|
preload="metadata"
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
key={`${item.id}-${transcodeVideos.has(item.id)}`}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
display: 'block',
|
||||||
|
pointerEvents: 'none'
|
||||||
|
}}
|
||||||
|
onError={(e) => {
|
||||||
|
if (e.target.error?.code === 4) {
|
||||||
|
// MEDIA_ERR_SRC_NOT_SUPPORTED - unsupported format/codec
|
||||||
|
handleVideoError(item.id, transcodeVideos.has(item.id))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="video-indicator">
|
||||||
|
<svg
|
||||||
|
style={{ width: '2rem', height: '2rem' }}
|
||||||
|
fill="white"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={`${API_BASE}/media?id=${item.id}`}
|
||||||
|
alt={`Media ${index + 1}`}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedIndex !== null && (
|
||||||
|
<MediaCarousel
|
||||||
|
mediaItems={displayableItems}
|
||||||
|
initialIndex={selectedIndex}
|
||||||
|
onClose={handleCloseCarousel}
|
||||||
|
transcodeVideos={transcodeVideos}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MediaGrid
|
||||||
@@ -3,6 +3,7 @@ import { useLocation } from 'react-router-dom'
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import LazyMedia from './LazyMedia'
|
import LazyMedia from './LazyMedia'
|
||||||
|
import MediaGrid from './MediaGrid'
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
|
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 [loading, setLoading] = useState(false)
|
||||||
const [highlightedMessageId, setHighlightedMessageId] = useState(null)
|
const [highlightedMessageId, setHighlightedMessageId] = useState(null)
|
||||||
const [isPreprintingMedia, setIsPreprintingMedia] = useState(false)
|
const [isPreprintingMedia, setIsPreprintingMedia] = useState(false)
|
||||||
|
const [showMediaOnly, setShowMediaOnly] = useState(false)
|
||||||
const messageRefs = useRef({})
|
const messageRefs = useRef({})
|
||||||
const printTriggeredRef = useRef(false)
|
const printTriggeredRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (conversation) {
|
if (conversation) {
|
||||||
fetchItems()
|
fetchItems()
|
||||||
|
setShowMediaOnly(false) // Reset to message view when conversation changes
|
||||||
} else {
|
} else {
|
||||||
setItems([])
|
setItems([])
|
||||||
}
|
}
|
||||||
@@ -464,7 +467,17 @@ function MessageThread({ conversation, startDate, endDate }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="d-flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMediaOnly(!showMediaOnly)}
|
||||||
|
className={`btn btn-sm ${showMediaOnly ? 'btn-primary' : 'btn-outline-primary'} d-flex align-items-center gap-1`}
|
||||||
|
title={showMediaOnly ? "Show all messages" : "Show photos only"}
|
||||||
|
>
|
||||||
|
<svg style={{width: '1rem', height: '1rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span className="d-none d-md-inline">{showMediaOnly ? 'Show All' : 'Photos'}</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleExportPDF}
|
onClick={handleExportPDF}
|
||||||
className="btn btn-sm btn-outline-primary d-flex align-items-center gap-1"
|
className="btn btn-sm btn-outline-primary d-flex align-items-center gap-1"
|
||||||
@@ -481,7 +494,10 @@ function MessageThread({ conversation, startDate, endDate }) {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-fill overflow-auto p-2 p-md-4 bg-light">
|
<div className="flex-fill overflow-auto p-2 p-md-4 bg-light">
|
||||||
{isCallLog ? (
|
{showMediaOnly && !isCallLog ? (
|
||||||
|
// Media Grid View
|
||||||
|
<MediaGrid conversation={conversation} startDate={startDate} endDate={endDate} />
|
||||||
|
) : isCallLog ? (
|
||||||
// Call Log View
|
// Call Log View
|
||||||
<div className="d-flex flex-column gap-3">
|
<div className="d-flex flex-column gap-3">
|
||||||
{items.map((call) => {
|
{items.map((call) => {
|
||||||
|
|||||||
@@ -824,6 +824,61 @@ func GetActivityByAddress(userDB *sql.DB, address string, startDate, endDate *ti
|
|||||||
return activities, nil
|
return activities, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMediaByAddress fetches only media items (images/videos) for a specific address
|
||||||
|
func GetMediaByAddress(userDB *sql.DB, address string, startDate, endDate *time.Time) ([]Message, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, address, COALESCE(body, '') as body, date,
|
||||||
|
COALESCE(contact_name, '') as contact_name, COALESCE(media_type, '') as media_type,
|
||||||
|
read, thread_id
|
||||||
|
FROM messages
|
||||||
|
WHERE record_type IN (1, 2)
|
||||||
|
AND media_type IS NOT NULL
|
||||||
|
AND media_type != ''
|
||||||
|
AND (media_type LIKE 'image/%' OR media_type LIKE 'video/%')
|
||||||
|
`
|
||||||
|
|
||||||
|
args := []interface{}{}
|
||||||
|
if address != "" {
|
||||||
|
query += " AND address = ?"
|
||||||
|
args = append(args, address)
|
||||||
|
}
|
||||||
|
if startDate != nil {
|
||||||
|
query += " AND date >= ?"
|
||||||
|
args = append(args, startDate.Unix())
|
||||||
|
}
|
||||||
|
if endDate != nil {
|
||||||
|
query += " AND date <= ?"
|
||||||
|
args = append(args, endDate.Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY date DESC"
|
||||||
|
|
||||||
|
rows, err := userDB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var mediaItems []Message
|
||||||
|
for rows.Next() {
|
||||||
|
var m Message
|
||||||
|
var dateUnix int64
|
||||||
|
var readInt int64
|
||||||
|
|
||||||
|
err := rows.Scan(&m.ID, &m.Address, &m.Body, &dateUnix, &m.ContactName, &m.MediaType, &readInt, &m.ThreadID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.Date = time.Unix(dateUnix, 0)
|
||||||
|
m.Read = readInt == 1
|
||||||
|
|
||||||
|
mediaItems = append(mediaItems, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaItems, nil
|
||||||
|
}
|
||||||
|
|
||||||
func GetMessageMedia(userDB *sql.DB, messageID string) ([]byte, string, error) {
|
func GetMessageMedia(userDB *sql.DB, messageID string) ([]byte, string, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT COALESCE(media_data, ''), COALESCE(media_type, '')
|
SELECT COALESCE(media_data, ''), COALESCE(media_type, '')
|
||||||
|
|||||||
+101
-2
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
@@ -240,6 +241,44 @@ func HandleMessages(c echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, messages)
|
return c.JSON(http.StatusOK, messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleMediaItems returns only media (images/videos) for a conversation
|
||||||
|
func HandleMediaItems(c echo.Context) error {
|
||||||
|
userDB, err := getUserDB(c)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error getting user database", "error", err)
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": "Failed to get user database",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
address := c.QueryParam("address")
|
||||||
|
var startDate, endDate *time.Time
|
||||||
|
|
||||||
|
if startStr := c.QueryParam("start"); startStr != "" {
|
||||||
|
t, err := time.Parse(time.RFC3339, startStr)
|
||||||
|
if err == nil {
|
||||||
|
startDate = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if endStr := c.QueryParam("end"); endStr != "" {
|
||||||
|
t, err := time.Parse(time.RFC3339, endStr)
|
||||||
|
if err == nil {
|
||||||
|
endDate = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaItems, err := GetMediaByAddress(userDB, address, startDate, endDate)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error getting media items", "error", err)
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": "Failed to get media items",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, mediaItems)
|
||||||
|
}
|
||||||
|
|
||||||
func HandleActivity(c echo.Context) error {
|
func HandleActivity(c echo.Context) error {
|
||||||
userDB, err := getUserDB(c)
|
userDB, err := getUserDB(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -395,6 +434,9 @@ func HandleMedia(c echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if transcode is requested (for videos that browser can't play)
|
||||||
|
forceTranscode := c.QueryParam("transcode") == "true"
|
||||||
|
|
||||||
// Fetch media from database
|
// Fetch media from database
|
||||||
media, contentType, err := GetMessageMedia(userDB, messageID)
|
media, contentType, err := GetMessageMedia(userDB, messageID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -410,11 +452,68 @@ func HandleMedia(c echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If transcode is requested and this is a video, try to convert it
|
||||||
|
if forceTranscode && strings.HasPrefix(contentType, "video/") {
|
||||||
|
slog.Info("Transcode requested for video", "messageID", messageID, "contentType", contentType)
|
||||||
|
convertedData, err := convertVideoToMP4(media)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to transcode video", "messageID", messageID, "error", err)
|
||||||
|
// Continue with original video if conversion fails
|
||||||
|
} else {
|
||||||
|
slog.Info("Successfully transcoded video", "messageID", messageID)
|
||||||
|
media = convertedData
|
||||||
|
contentType = "video/mp4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("Serving media", "messageID", messageID, "contentType", contentType, "size", len(media))
|
||||||
|
|
||||||
// Set appropriate headers
|
// Set appropriate headers
|
||||||
c.Response().Header().Set("Cache-Control", "public, max-age=31536000") // Cache for 1 year
|
c.Response().Header().Set("Cache-Control", "public, max-age=31536000") // Cache for 1 year
|
||||||
c.Response().Header().Set("Content-Length", fmt.Sprintf("%d", len(media)))
|
c.Response().Header().Set("Accept-Ranges", "bytes") // Enable range requests for video streaming
|
||||||
|
|
||||||
// Write binary data with proper content type
|
// Check for Range header (needed for video playback)
|
||||||
|
rangeHeader := c.Request().Header.Get("Range")
|
||||||
|
if rangeHeader != "" {
|
||||||
|
contentLength := int64(len(media))
|
||||||
|
var start, end int64 = 0, contentLength - 1
|
||||||
|
|
||||||
|
slog.Debug("Range request received", "messageID", messageID, "range", rangeHeader, "contentType", contentType, "contentLength", contentLength)
|
||||||
|
|
||||||
|
// Parse range header (e.g., "bytes=0-1023" or "bytes=0-")
|
||||||
|
n, _ := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end)
|
||||||
|
if n == 1 {
|
||||||
|
// Only start was specified (e.g., "bytes=0-")
|
||||||
|
end = contentLength - 1
|
||||||
|
} else if n == 0 {
|
||||||
|
// Invalid range, return 416 Range Not Satisfiable
|
||||||
|
slog.Warn("Invalid range header", "range", rangeHeader)
|
||||||
|
c.Response().Header().Set("Content-Range", fmt.Sprintf("bytes */%d", contentLength))
|
||||||
|
return c.NoContent(http.StatusRequestedRangeNotSatisfiable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure valid range
|
||||||
|
if start < 0 || start >= contentLength || end >= contentLength || start > end {
|
||||||
|
slog.Warn("Range out of bounds", "start", start, "end", end, "contentLength", contentLength)
|
||||||
|
c.Response().Header().Set("Content-Range", fmt.Sprintf("bytes */%d", contentLength))
|
||||||
|
return c.NoContent(http.StatusRequestedRangeNotSatisfiable)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("Serving range", "start", start, "end", end, "size", end-start+1)
|
||||||
|
|
||||||
|
// Set response headers for partial content
|
||||||
|
c.Response().Header().Set("Content-Type", contentType)
|
||||||
|
c.Response().Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, contentLength))
|
||||||
|
c.Response().Header().Set("Content-Length", fmt.Sprintf("%d", end-start+1))
|
||||||
|
c.Response().WriteHeader(http.StatusPartialContent)
|
||||||
|
|
||||||
|
// Write the requested range
|
||||||
|
_, writeErr := c.Response().Write(media[start : end+1])
|
||||||
|
return writeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// No range request - serve full content
|
||||||
|
c.Response().Header().Set("Content-Length", fmt.Sprintf("%d", len(media)))
|
||||||
return c.Blob(http.StatusOK, contentType, media)
|
return c.Blob(http.StatusOK, contentType, media)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -1,6 +1,5 @@
|
|||||||
package internal
|
package internal
|
||||||
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
@@ -440,10 +439,10 @@ func isHEICContentType(contentType string) bool {
|
|||||||
// needsVideoConversion checks if a video format needs conversion for browser compatibility
|
// needsVideoConversion checks if a video format needs conversion for browser compatibility
|
||||||
func needsVideoConversion(contentType string) bool {
|
func needsVideoConversion(contentType string) bool {
|
||||||
ct := strings.ToLower(strings.TrimSpace(contentType))
|
ct := strings.ToLower(strings.TrimSpace(contentType))
|
||||||
// 3GP, 3G2, and other old mobile formats that browsers don't support
|
|
||||||
unsupportedFormats := []string{
|
unsupportedFormats := []string{
|
||||||
"3gpp", "3gp", "3g2", "3gpp2",
|
"3gpp", "3gp", "3g2", "3gpp2",
|
||||||
"video/3gpp", "video/3gp", "video/3gpp2", "video/3g2",
|
"video/3gpp", "video/3gp", "video/3gpp2", "video/3g2",
|
||||||
|
"video/x-matroska", // MKV container (may have various codecs)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, format := range unsupportedFormats {
|
for _, format := range unsupportedFormats {
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ func main() {
|
|||||||
protected.GET("/daterange", internal.HandleDateRange)
|
protected.GET("/daterange", internal.HandleDateRange)
|
||||||
protected.GET("/progress", internal.HandleProgress)
|
protected.GET("/progress", internal.HandleProgress)
|
||||||
protected.GET("/media", internal.HandleMedia)
|
protected.GET("/media", internal.HandleMedia)
|
||||||
|
protected.GET("/media-items", internal.HandleMediaItems)
|
||||||
protected.GET("/search", internal.HandleSearch)
|
protected.GET("/search", internal.HandleSearch)
|
||||||
protected.GET("/settings", internal.HandleGetSettings)
|
protected.GET("/settings", internal.HandleGetSettings)
|
||||||
protected.PUT("/settings", internal.HandleUpdateSettings)
|
protected.PUT("/settings", internal.HandleUpdateSettings)
|
||||||
|
|||||||
Reference in New Issue
Block a user