From cc164ef2309d06ea181369a54dba3f6e03e83b7c Mon Sep 17 00:00:00 2001 From: lowcarbdev Date: Sat, 29 Nov 2025 21:49:50 -0700 Subject: [PATCH] Initial print/export support --- frontend/src/App.css | 215 ++++++++++++++ frontend/src/components/LazyMedia.jsx | 11 + frontend/src/components/MessageThread.jsx | 87 +++++- frontend/src/components/PrintView.css | 218 +++++++++++++++ frontend/src/components/PrintView.jsx | 327 ++++++++++++++++++++++ frontend/src/main.jsx | 6 + 6 files changed, 863 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/PrintView.css create mode 100644 frontend/src/components/PrintView.jsx diff --git a/frontend/src/App.css b/frontend/src/App.css index b454ab1..d5b78b9 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; + } +} diff --git a/frontend/src/components/LazyMedia.jsx b/frontend/src/components/LazyMedia.jsx index 56680fa..180c428 100644 --- a/frontend/src/components/LazyMedia.jsx +++ b/frontend/src/components/LazyMedia.jsx @@ -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]) diff --git a/frontend/src/components/MessageThread.jsx b/frontend/src/components/MessageThread.jsx index 893910b..35043e3 100644 --- a/frontend/src/components/MessageThread.jsx +++ b/frontend/src/components/MessageThread.jsx @@ -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 (
+ {/* Print preparation overlay */} + {isPreprintingMedia && ( +
+
+
+ Loading media... +
+

Preparing conversation for printing...

+

Loading all images and media

+
+
+ )} + {/* Thread Header */}
@@ -385,12 +458,24 @@ function MessageThread({ conversation, startDate, endDate }) {
) })()} -
+
{items.length} {isCallLog ? 'call' : 'message'}{items.length !== 1 ? 's' : ''}
+
+ +
diff --git a/frontend/src/components/PrintView.css b/frontend/src/components/PrintView.css new file mode 100644 index 0000000..6fc0aa4 --- /dev/null +++ b/frontend/src/components/PrintView.css @@ -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; + } +} diff --git a/frontend/src/components/PrintView.jsx b/frontend/src/components/PrintView.jsx new file mode 100644 index 0000000..2735a23 --- /dev/null +++ b/frontend/src/components/PrintView.jsx @@ -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 ( +
+
+
+ {typeInfo.icon} + {typeInfo.label} Call + {call.duration > 0 && ( + • {formatDuration(call.duration)} + )} +
+
{formatDate(call.date)}
+
+
+ ) + } + + // 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 ( +
+
+ {hasBody && ( +
{message.body}
+ )} + {message.media_type && ( +
+ {message.media_type.startsWith('image/') && ( + Message attachment console.log(`Image ${message.id} loaded`)} + onError={(e) => console.log(`Image ${message.id} failed to load:`, e)} + /> + )} + {message.media_type.startsWith('video/') && ( +
+ )} + {!hasBody && !message.media_type && ( +
+ (Empty message) +
+ )} +
{formatDate(message.date)}
+
+
+ ) + } + + if (loading) { + return ( +
+
+ Loading... +
+

Loading conversation...

+
+ ) + } + + return ( +
+ {!mediaLoaded && totalMedia > 0 && ( +
+
+
+ Loading... +
+

Preparing PDF...

+

Loading media: {loadedCount} of {totalMedia}

+
+
+
+
+
+ )} + +
+

Conversation with {conversation?.contactName}

+

{conversation?.address}

+

+ {messages.length} items + {' • '} + Exported on {format(new Date(), 'MMMM d, yyyy')} +

+
+ +
+ {(() => { + 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) + }) + ) : ( +

No messages to display

+ ) + })()} +
+
+ ) +} + +export default PrintView diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index bf06d3e..dca4301 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -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( } /> + + + + } />