Initial print/export support
This commit is contained in:
@@ -118,3 +118,218 @@
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles for conversation view */
|
||||
@media print {
|
||||
/* Hide all UI elements except the conversation content */
|
||||
header,
|
||||
.nav-tabs,
|
||||
.date-filter-container,
|
||||
.conversation-sidebar,
|
||||
button,
|
||||
.btn,
|
||||
input,
|
||||
.form-control,
|
||||
.position-fixed {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Reset body and root for printing */
|
||||
body,
|
||||
#root {
|
||||
background: white !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
/* Make message thread container take full page */
|
||||
.message-thread-container {
|
||||
position: static !important;
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* Thread header - compact and print-friendly */
|
||||
.message-thread-container .bg-light {
|
||||
background: white !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0.5rem 0 !important;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
.thread-header-title {
|
||||
font-size: 1.25rem !important;
|
||||
color: black !important;
|
||||
margin-bottom: 0.25rem !important;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background-color: white !important;
|
||||
color: black !important;
|
||||
border: 1px solid black !important;
|
||||
font-size: 0.7rem !important;
|
||||
}
|
||||
|
||||
/* Message content area */
|
||||
.message-thread-container .flex-fill.overflow-auto {
|
||||
overflow: visible !important;
|
||||
height: auto !important;
|
||||
background: white !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Message bubbles - print-friendly styling */
|
||||
.card {
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
border: 1px solid #333 !important;
|
||||
box-shadow: none !important;
|
||||
margin-bottom: 0.5rem !important;
|
||||
padding: 0.25rem 0.5rem !important;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
/* Sent messages - distinguish with border style */
|
||||
.bg-primary.text-white {
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
border: 1px solid #333 !important;
|
||||
border-left: 3px solid #333 !important;
|
||||
}
|
||||
|
||||
/* Received messages */
|
||||
.bg-white {
|
||||
background: white !important;
|
||||
border: 1px solid #999 !important;
|
||||
}
|
||||
|
||||
/* Message body text */
|
||||
.card-body {
|
||||
padding: 0.25rem 0.5rem !important;
|
||||
}
|
||||
|
||||
/* Message timestamp */
|
||||
.text-white-50,
|
||||
.text-muted {
|
||||
color: #666 !important;
|
||||
}
|
||||
|
||||
/* SVG icons in messages */
|
||||
svg {
|
||||
color: #666 !important;
|
||||
stroke: #666 !important;
|
||||
}
|
||||
|
||||
/* Call log items */
|
||||
.bg-light.text-dark.border {
|
||||
background: white !important;
|
||||
border: 1px solid #666 !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
/* Images and media - ensure they're visible and sized appropriately */
|
||||
img {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
page-break-inside: avoid;
|
||||
display: block !important;
|
||||
margin: 0.25rem 0 !important;
|
||||
border: 1px solid #ccc !important;
|
||||
}
|
||||
|
||||
/* Video elements - show poster/thumbnail */
|
||||
video {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
page-break-inside: avoid;
|
||||
display: block !important;
|
||||
margin: 0.25rem 0 !important;
|
||||
border: 1px solid #ccc !important;
|
||||
}
|
||||
|
||||
/* LazyMedia wrapper */
|
||||
.lazy-media-wrapper,
|
||||
.img-fluid {
|
||||
max-width: 100% !important;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
/* Ensure message container doesn't break across pages */
|
||||
.d-flex.justify-content-start,
|
||||
.d-flex.justify-content-end {
|
||||
page-break-inside: avoid;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Audio players - show as a simple text indicator */
|
||||
audio {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
audio::after {
|
||||
content: "[Audio attachment]";
|
||||
display: block;
|
||||
font-style: italic;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Flex layout adjustments for print */
|
||||
.d-flex {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.justify-content-end {
|
||||
text-align: right !important;
|
||||
}
|
||||
|
||||
.justify-content-start {
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.flex-fill {
|
||||
flex: none !important;
|
||||
}
|
||||
|
||||
/* Remove shadows and gradients */
|
||||
.shadow,
|
||||
.shadow-sm {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.bg-gradient {
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
/* Sender label for group conversations */
|
||||
.small.text-muted.mb-1 {
|
||||
color: #666 !important;
|
||||
font-size: 0.7rem !important;
|
||||
margin-bottom: 0.1rem !important;
|
||||
}
|
||||
|
||||
/* Phone numbers in header */
|
||||
.thread-header-title + .small.text-muted {
|
||||
color: #666 !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
/* Compact spacing for print */
|
||||
.gap-1,
|
||||
.gap-2,
|
||||
.gap-3 {
|
||||
gap: 0.25rem !important;
|
||||
}
|
||||
|
||||
/* Page breaks */
|
||||
.message-thread-container > div > div {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
/* Ensure all content is visible */
|
||||
* {
|
||||
overflow: visible !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,10 +41,21 @@ function LazyMedia({ messageId, mediaType, className, alt = "MMS attachment" })
|
||||
observerRef.current.observe(imgRef.current)
|
||||
}
|
||||
|
||||
// Load media before printing to ensure all images are available
|
||||
const handleBeforePrint = () => {
|
||||
if (!hasLoadedRef.current) {
|
||||
hasLoadedRef.current = true
|
||||
loadMedia()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('beforeprint', handleBeforePrint)
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect()
|
||||
}
|
||||
window.removeEventListener('beforeprint', handleBeforePrint)
|
||||
}
|
||||
}, [messageId])
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ function MessageThread({ conversation, startDate, endDate }) {
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [highlightedMessageId, setHighlightedMessageId] = useState(null)
|
||||
const [isPreprintingMedia, setIsPreprintingMedia] = useState(false)
|
||||
const messageRefs = useRef({})
|
||||
const printTriggeredRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (conversation) {
|
||||
@@ -181,6 +183,46 @@ function MessageThread({ conversation, startDate, endDate }) {
|
||||
}
|
||||
}, [items, location.search])
|
||||
|
||||
// Handle print: load all media before showing print dialog
|
||||
useEffect(() => {
|
||||
const handleBeforePrint = (e) => {
|
||||
// If we're already loading media for print, let it proceed
|
||||
if (printTriggeredRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent default print dialog
|
||||
e.preventDefault()
|
||||
printTriggeredRef.current = true
|
||||
setIsPreprintingMedia(true)
|
||||
|
||||
// Trigger beforeprint event on all LazyMedia components to load them
|
||||
const printEvent = new Event('beforeprint')
|
||||
window.dispatchEvent(printEvent)
|
||||
|
||||
// Wait a bit for all media to start loading, then open print dialog
|
||||
setTimeout(() => {
|
||||
setIsPreprintingMedia(false)
|
||||
printTriggeredRef.current = false
|
||||
window.print()
|
||||
}, 1500) // Give media 1.5 seconds to load
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
// Intercept Ctrl+P / Cmd+P
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
|
||||
e.preventDefault()
|
||||
handleBeforePrint(e)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchItems = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -200,6 +242,18 @@ function MessageThread({ conversation, startDate, endDate }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportPDF = () => {
|
||||
// Build URL parameters for print view
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.set('start', startDate.toISOString())
|
||||
if (endDate) params.set('end', endDate.toISOString())
|
||||
|
||||
// Open print view in new window
|
||||
const queryString = params.toString()
|
||||
const printUrl = `/conversation/${encodeURIComponent(conversation.address)}/print${queryString ? '?' + queryString : ''}`
|
||||
window.open(printUrl, '_blank', 'width=1024,height=768')
|
||||
}
|
||||
|
||||
const formatTime = (date) => {
|
||||
return format(new Date(date), 'MMM d, yyyy h:mm a')
|
||||
}
|
||||
@@ -349,6 +403,25 @@ function MessageThread({ conversation, startDate, endDate }) {
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column h-100">
|
||||
{/* Print preparation overlay */}
|
||||
{isPreprintingMedia && (
|
||||
<div
|
||||
className="position-fixed top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
zIndex: 10000
|
||||
}}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="spinner-border text-primary mb-3" role="status" style={{width: '3rem', height: '3rem'}}>
|
||||
<span className="visually-hidden">Loading media...</span>
|
||||
</div>
|
||||
<p className="h5 text-dark">Preparing conversation for printing...</p>
|
||||
<p className="text-muted small">Loading all images and media</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thread Header */}
|
||||
<div className="bg-light border-bottom p-2 p-md-4 shadow-sm">
|
||||
<div className="d-flex align-items-center gap-2 gap-md-3">
|
||||
@@ -385,12 +458,24 @@ function MessageThread({ conversation, startDate, endDate }) {
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<div>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<span className="badge bg-primary" style={{fontSize: '0.7rem'}}>
|
||||
{items.length} {isCallLog ? 'call' : 'message'}{items.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={handleExportPDF}
|
||||
className="btn btn-sm btn-outline-primary d-flex align-items-center gap-1"
|
||||
title="Export as PDF"
|
||||
>
|
||||
<svg style={{width: '1rem', height: '1rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="d-none d-md-inline">Export PDF</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/* Print View Styles */
|
||||
|
||||
.print-view {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.print-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.print-loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.print-loading-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.print-header {
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.print-header h1 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.print-address {
|
||||
color: #6c757d;
|
||||
font-size: 14px;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.print-meta {
|
||||
color: #6c757d;
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.print-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.print-message {
|
||||
display: flex;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.print-message.sent {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.print-message.received {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.print-message-bubble {
|
||||
max-width: 70%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.print-message.sent .print-message-bubble {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.print-message.received .print-message-bubble {
|
||||
background-color: #fff;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.print-message-body {
|
||||
margin-bottom: 6px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.print-message-media {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.print-message-media img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.print-message-media video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.print-media-placeholder {
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.print-message-time {
|
||||
font-size: 11px;
|
||||
color: #6c757d;
|
||||
margin-top: 4px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Call-specific styles */
|
||||
.print-call {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.print-call .print-message-bubble {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.print-call-info {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.print-call-icon {
|
||||
font-size: 16px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.print-call-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.print-call-duration {
|
||||
color: #6c757d;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* Print-specific styles */
|
||||
@media print {
|
||||
.print-loading-overlay {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.print-view {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.print-header {
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
.print-message {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.print-message-bubble {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
@page {
|
||||
margin: 0.5in;
|
||||
}
|
||||
|
||||
/* Ensure good contrast for printing */
|
||||
.print-message.sent .print-message-bubble {
|
||||
background-color: #f0f0f0;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
|
||||
.print-message.received .print-message-bubble {
|
||||
background-color: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
.print-message-body {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
/* Screen-only: hide scrollbars during loading */
|
||||
@media screen {
|
||||
body:has(.print-loading-overlay) {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { format } from 'date-fns'
|
||||
import './PrintView.css'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
|
||||
|
||||
function PrintView() {
|
||||
const { address } = useParams()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [messages, setMessages] = useState([])
|
||||
const [conversation, setConversation] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [mediaLoaded, setMediaLoaded] = useState(false)
|
||||
const [loadedCount, setLoadedCount] = useState(0)
|
||||
const [totalMedia, setTotalMedia] = useState(0)
|
||||
const printTriggeredRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const startDate = searchParams.get('start')
|
||||
const endDate = searchParams.get('end')
|
||||
|
||||
console.log('URL params:', { address, startDate, endDate })
|
||||
|
||||
if (!address) {
|
||||
console.error('No address provided')
|
||||
return
|
||||
}
|
||||
|
||||
fetchConversation(address, startDate, endDate)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const fetchConversation = async (address, startDate, endDate) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const params = { address, type: 'conversation' }
|
||||
if (startDate) params.start = startDate
|
||||
if (endDate) params.end = endDate
|
||||
|
||||
console.log('Fetching conversation with params:', params)
|
||||
// Use /messages endpoint with type=conversation to get all types (SMS, MMS, calls)
|
||||
const response = await axios.get(`${API_BASE}/messages`, { params })
|
||||
const items = response.data || []
|
||||
|
||||
console.log('Received items:', items.length)
|
||||
if (items.length > 0) {
|
||||
console.log('First item:', items[0])
|
||||
console.log('First item body:', items[0].body)
|
||||
console.log('First item date:', items[0].date)
|
||||
console.log('First item type:', items[0].type)
|
||||
}
|
||||
|
||||
console.log('Setting messages state with items:', items)
|
||||
setMessages(items)
|
||||
console.log('Messages state set')
|
||||
|
||||
// Get contact name from any message in the list (they should all have the same contact_name)
|
||||
const contactName = items.find(item => item.contact_name)?.contact_name || address
|
||||
console.log('Contact name:', contactName)
|
||||
|
||||
setConversation({
|
||||
address,
|
||||
contactName
|
||||
})
|
||||
|
||||
// Count total media items - need to check nested message for media_type
|
||||
const mediaCount = items.filter(item => {
|
||||
const msg = item.message || item
|
||||
return msg.media_type
|
||||
}).length
|
||||
setTotalMedia(mediaCount)
|
||||
console.log('Total media items:', mediaCount)
|
||||
|
||||
setLoading(false)
|
||||
|
||||
// Wait for all media to load before triggering print
|
||||
if (mediaCount > 0) {
|
||||
waitForAllMedia()
|
||||
} else {
|
||||
// No media, trigger print after short delay
|
||||
setTimeout(() => {
|
||||
if (!printTriggeredRef.current) {
|
||||
printTriggeredRef.current = true
|
||||
setMediaLoaded(true)
|
||||
window.print()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching conversation:', error)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const waitForAllMedia = () => {
|
||||
const checkInterval = setInterval(() => {
|
||||
const images = document.querySelectorAll('.print-message-media img')
|
||||
const videos = document.querySelectorAll('.print-message-media video')
|
||||
const allMedia = [...images, ...videos]
|
||||
|
||||
if (allMedia.length === 0) return
|
||||
|
||||
const loaded = allMedia.filter(el => {
|
||||
if (el.tagName === 'IMG') {
|
||||
return el.complete && el.naturalHeight !== 0
|
||||
} else if (el.tagName === 'VIDEO') {
|
||||
return el.readyState >= 2
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
setLoadedCount(loaded.length)
|
||||
|
||||
// All media loaded
|
||||
if (loaded.length === allMedia.length) {
|
||||
clearInterval(checkInterval)
|
||||
clearTimeout(timeoutId)
|
||||
if (!printTriggeredRef.current) {
|
||||
printTriggeredRef.current = true
|
||||
setMediaLoaded(true)
|
||||
// Give browser a moment to render everything
|
||||
setTimeout(() => {
|
||||
window.print()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
|
||||
// Timeout after 60 seconds
|
||||
const timeoutId = setTimeout(() => {
|
||||
clearInterval(checkInterval)
|
||||
if (!printTriggeredRef.current) {
|
||||
printTriggeredRef.current = true
|
||||
setMediaLoaded(true)
|
||||
window.print()
|
||||
}
|
||||
}, 60000)
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
return format(date, 'MMM d, yyyy h:mm a')
|
||||
} catch (e) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const getCallTypeInfo = (type) => {
|
||||
switch (type) {
|
||||
case 1: return { label: 'Incoming', icon: '↓' }
|
||||
case 2: return { label: 'Outgoing', icon: '↑' }
|
||||
case 3: return { label: 'Missed', icon: '✕' }
|
||||
case 4: return { label: 'Voicemail', icon: '⊙' }
|
||||
case 5: return { label: 'Rejected', icon: '✕' }
|
||||
case 6: return { label: 'Refused', icon: '✕' }
|
||||
default: return { label: 'Call', icon: '☎' }
|
||||
}
|
||||
}
|
||||
|
||||
const renderMessage = (item) => {
|
||||
console.log('Rendering item:', {
|
||||
itemType: item.type,
|
||||
hasMessage: !!item.message,
|
||||
fullItem: item
|
||||
})
|
||||
|
||||
// Check if this is a call at the Activity level
|
||||
if (item.type === 'call' && item.call) {
|
||||
// For calls in Activity items, the call data is nested in item.call
|
||||
const call = item.call
|
||||
const typeInfo = getCallTypeInfo(call.type)
|
||||
console.log('Rendering call:', {
|
||||
callId: call.id,
|
||||
callType: call.type,
|
||||
duration: call.duration,
|
||||
typeInfo
|
||||
})
|
||||
|
||||
return (
|
||||
<div key={call.id} className="print-message print-call">
|
||||
<div className="print-message-bubble">
|
||||
<div className="print-call-info">
|
||||
<span className="print-call-icon">{typeInfo.icon}</span>
|
||||
<span className="print-call-label">{typeInfo.label} Call</span>
|
||||
{call.duration > 0 && (
|
||||
<span className="print-call-duration"> • {formatDuration(call.duration)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="print-message-time">{formatDate(call.date)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// For messages, extract from nested message object
|
||||
const message = item.message || item
|
||||
console.log('Rendering message:', {
|
||||
messageId: message.id,
|
||||
body: message.body,
|
||||
hasBody: !!(message.body && message.body !== ''),
|
||||
hasMedia: !!message.media_type
|
||||
})
|
||||
|
||||
// Regular message rendering
|
||||
const isSent = message.type === 2
|
||||
const messageClass = isSent ? 'print-message sent' : 'print-message received'
|
||||
|
||||
// Check if body has actual content (not null, not undefined, not empty string)
|
||||
const hasBody = message.body != null && message.body !== ''
|
||||
|
||||
return (
|
||||
<div key={message.id} className={messageClass}>
|
||||
<div className="print-message-bubble">
|
||||
{hasBody && (
|
||||
<div className="print-message-body">{message.body}</div>
|
||||
)}
|
||||
{message.media_type && (
|
||||
<div className="print-message-media">
|
||||
{message.media_type.startsWith('image/') && (
|
||||
<img
|
||||
src={`${API_BASE}/media?id=${message.id}`}
|
||||
alt="Message attachment"
|
||||
onLoad={() => console.log(`Image ${message.id} loaded`)}
|
||||
onError={(e) => console.log(`Image ${message.id} failed to load:`, e)}
|
||||
/>
|
||||
)}
|
||||
{message.media_type.startsWith('video/') && (
|
||||
<video
|
||||
src={`${API_BASE}/media?id=${message.id}`}
|
||||
controls
|
||||
onLoadedData={() => console.log(`Video ${message.id} loaded`)}
|
||||
onError={(e) => console.log(`Video ${message.id} failed to load:`, e)}
|
||||
/>
|
||||
)}
|
||||
{message.media_type.startsWith('audio/') && (
|
||||
<div className="print-media-placeholder">
|
||||
🎵 Audio attachment
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!hasBody && !message.media_type && (
|
||||
<div className="print-message-body" style={{color: '#999', fontStyle: 'italic'}}>
|
||||
(Empty message)
|
||||
</div>
|
||||
)}
|
||||
<div className="print-message-time">{formatDate(message.date)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="print-loading">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p>Loading conversation...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="print-view">
|
||||
{!mediaLoaded && totalMedia > 0 && (
|
||||
<div className="print-loading-overlay">
|
||||
<div className="print-loading-content">
|
||||
<div className="spinner-border mb-3" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<h4>Preparing PDF...</h4>
|
||||
<p>Loading media: {loadedCount} of {totalMedia}</p>
|
||||
<div className="progress" style={{ width: '300px' }}>
|
||||
<div
|
||||
className="progress-bar"
|
||||
role="progressbar"
|
||||
style={{ width: `${(loadedCount / totalMedia) * 100}%` }}
|
||||
aria-valuenow={loadedCount}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax={totalMedia}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="print-header">
|
||||
<h1>Conversation with {conversation?.contactName}</h1>
|
||||
<p className="print-address">{conversation?.address}</p>
|
||||
<p className="print-meta">
|
||||
{messages.length} items
|
||||
{' • '}
|
||||
Exported on {format(new Date(), 'MMMM d, yyyy')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="print-messages">
|
||||
{(() => {
|
||||
console.log('About to render messages. Count:', messages.length)
|
||||
console.log('Messages array:', messages)
|
||||
console.log('Is array?', Array.isArray(messages))
|
||||
return messages.length > 0 ? (
|
||||
messages.map((msg, index) => {
|
||||
console.log(`Message ${index}:`, msg)
|
||||
return renderMessage(msg)
|
||||
})
|
||||
) : (
|
||||
<p className="text-center text-muted">No messages to display</p>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PrintView
|
||||
@@ -7,6 +7,7 @@ import './index.css'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import App from './App.jsx'
|
||||
import Login from './components/Login.jsx'
|
||||
import PrintView from './components/PrintView.jsx'
|
||||
import ProtectedRoute from './components/ProtectedRoute.jsx'
|
||||
|
||||
// Configure axios to include credentials with all requests
|
||||
@@ -18,6 +19,11 @@ createRoot(document.getElementById('root')).render(
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/conversation/:address/print" element={
|
||||
<ProtectedRoute>
|
||||
<PrintView />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/*" element={
|
||||
<ProtectedRoute>
|
||||
<App />
|
||||
|
||||
Reference in New Issue
Block a user