Initial commit

This commit is contained in:
lowcarbdev
2025-11-11 16:40:10 -07:00
commit b79e599640
57 changed files with 11811 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+29
View File
@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SMS Backup Viewer</title>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="SMS Backup Viewer" />
<link rel="manifest" href="/site.webmanifest" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+3558
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.2",
"bootstrap": "^5.3.8",
"date-fns": "^4.1.0",
"react": "^19.1.1",
"react-bootstrap": "^2.10.10",
"react-datepicker": "^8.8.0",
"react-dom": "^19.1.1",
"react-router-dom": "^7.9.5"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"vite": "^7.1.7"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width: 2.5rem; height: 2.5rem;"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path></svg><style>@media (prefers-color-scheme: light) { :root { filter: contrast(1) brightness(0.6); } }
@media (prefers-color-scheme: dark) { :root { filter: none; } }
</style></svg>

After

Width:  |  Height:  |  Size: 636 B

+21
View File
@@ -0,0 +1,21 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+42
View File
@@ -0,0 +1,42 @@
#root {
width: 100%;
height: 100vh;
margin: 0;
padding: 0;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+338
View File
@@ -0,0 +1,338 @@
import { useState, useEffect } from 'react'
import { useNavigate, useLocation, Routes, Route } from 'react-router-dom'
import { Dropdown } from 'react-bootstrap'
import axios from 'axios'
import { useAuth } from './contexts/AuthContext'
import ConversationList from './components/ConversationList'
import MessageThread from './components/MessageThread'
import Activity from './components/Activity'
import DateFilter from './components/DateFilter'
import Upload from './components/Upload'
import Search from './components/Search'
import ChangePasswordModal from './components/ChangePasswordModal'
import './App.css'
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
function App() {
const navigate = useNavigate()
const location = useLocation()
const { user, logout } = useAuth()
const [conversations, setConversations] = useState([])
const [conversationsLoading, setConversationsLoading] = useState(false)
const [selectedConversation, setSelectedConversation] = useState(null)
const [startDate, setStartDate] = useState(null)
const [endDate, setEndDate] = useState(null)
const [dateRange, setDateRange] = useState({ min: null, max: null })
const [showUpload, setShowUpload] = useState(false)
const [showPasswordModal, setShowPasswordModal] = useState(false)
const [searchFilter, setSearchFilter] = useState('')
// Search state (persisted across tab switches)
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState([])
const [searchLoading, setSearchLoading] = useState(false)
const [searchExecuted, setSearchExecuted] = useState(false)
const [searchScrollPosition, setSearchScrollPosition] = useState(0)
// Derive activeView from URL
const activeView = location.pathname.startsWith('/activity')
? 'activity'
: location.pathname.startsWith('/search')
? 'search'
: 'conversations'
useEffect(() => {
fetchDateRange()
fetchConversations()
}, [])
useEffect(() => {
fetchConversations()
}, [startDate, endDate])
// Sync selected conversation from URL
useEffect(() => {
const match = location.pathname.match(/^\/conversation\/(.+)$/)
if (match) {
const address = decodeURIComponent(match[1])
// Find conversation by address
const conversation = conversations.find(c => c.address === address)
if (conversation) {
setSelectedConversation(conversation)
} else if (conversations.length > 0) {
// If conversation not found in list, create a minimal conversation object
setSelectedConversation({ address, contact_name: address, type: 'message' })
}
} else if (location.pathname === '/' || location.pathname === '/conversations') {
setSelectedConversation(null)
}
}, [location.pathname, conversations])
const fetchDateRange = async () => {
try {
const response = await axios.get(`${API_BASE}/daterange`)
setDateRange({
min: new Date(response.data.min_date),
max: new Date(response.data.max_date)
})
} catch (error) {
console.error('Error fetching date range:', error)
}
}
const fetchConversations = async () => {
setConversationsLoading(true)
try {
const params = {}
if (startDate) params.start = startDate.toISOString()
if (endDate) params.end = endDate.toISOString()
const response = await axios.get(`${API_BASE}/conversations`, { params })
setConversations(response.data || [])
} catch (error) {
console.error('Error fetching conversations:', error)
} finally {
setConversationsLoading(false)
}
}
const handleUploadSuccess = () => {
setShowUpload(false)
fetchDateRange()
fetchConversations()
}
const handleSelectConversation = (conversation) => {
if (conversation) {
navigate(`/conversation/${encodeURIComponent(conversation.address)}`)
}
}
const handleViewChange = (view) => {
if (view === 'activity') {
navigate('/activity')
} else if (view === 'search') {
navigate('/search')
} else {
navigate('/')
}
}
// Filter conversations based on search text
const filteredConversations = conversations.filter(conv => {
if (!searchFilter) return true
const searchLower = searchFilter.toLowerCase()
const nameMatch = conv.contact_name && conv.contact_name.toLowerCase().includes(searchLower)
const addressMatch = conv.address && conv.address.toLowerCase().includes(searchLower)
return nameMatch || addressMatch
})
return (
<div className="vh-100 d-flex flex-column bg-light">
{/* Header */}
<header className="bg-primary bg-gradient text-white p-2 shadow-lg">
<div className="d-flex justify-content-between align-items-center">
<div className="d-flex align-items-center gap-3">
<svg style={{width: '2.5rem', height: '2.5rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<div>
<h1 className="h2 mb-1 fw-bold">SMS Backup Viewer</h1>
<p className="mb-0 small opacity-75">View and browse your message history</p>
</div>
</div>
<div className="d-flex align-items-center gap-3">
<div className="text-end">
<div className="small opacity-75">Logged in as</div>
<div className="fw-bold">{user?.username}</div>
</div>
<button
onClick={() => setShowUpload(true)}
className="btn btn-light btn-lg shadow d-flex align-items-center gap-2"
>
<svg style={{width: '1.25rem', height: '1.25rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
Upload Backup
</button>
<Dropdown align="end">
<Dropdown.Toggle
variant="outline-light"
className="d-flex align-items-center gap-2"
style={{ backgroundColor: 'transparent', borderColor: 'rgba(255, 255, 255, 0.5)' }}
>
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item onClick={() => setShowPasswordModal(true)}>
<svg style={{width: '1rem', height: '1rem'}} className="me-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
Change Password
</Dropdown.Item>
<Dropdown.Divider />
<Dropdown.Item onClick={logout}>
<svg style={{width: '1rem', height: '1rem'}} className="me-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Logout
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</div>
</div>
</header>
{/* View Switcher */}
<div className="bg-white border-bottom shadow-sm">
<div className="container-fluid">
<ul className="nav nav-tabs border-0">
<li className="nav-item">
<button
className={`nav-link ${activeView === 'conversations' ? 'active' : ''}`}
onClick={() => handleViewChange('conversations')}
>
<svg style={{width: '1rem', height: '1rem'}} className="me-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
Conversations
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeView === 'search' ? 'active' : ''}`}
onClick={() => handleViewChange('search')}
>
<svg style={{width: '1rem', height: '1rem'}} className="me-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Search
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeView === 'activity' ? 'active' : ''}`}
onClick={() => handleViewChange('activity')}
>
<svg style={{width: '1rem', height: '1rem'}} className="me-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Activity
</button>
</li>
</ul>
</div>
</div>
{/* Date Filter */}
<div className="bg-white border-bottom shadow-sm">
<DateFilter
startDate={startDate}
endDate={endDate}
minDate={dateRange.min}
maxDate={dateRange.max}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
</div>
{/* Main Content */}
<div className="flex-fill d-flex overflow-hidden gap-2 p-2">
{activeView === 'conversations' ? (
<>
{/* Conversation List */}
<div style={{width: '380px', minWidth: '380px', maxWidth: '380px', flexShrink: 0}} className="bg-white rounded-3 shadow overflow-hidden border">
<div className="bg-light border-bottom p-2">
<h2 className="h5 mb-2 d-flex align-items-center gap-2">
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
Conversations
</h2>
<div className="position-relative">
<svg style={{width: '1rem', height: '1rem', position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)'}} className="text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
className="form-control form-control-sm ps-5"
placeholder="Search by name or number..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
/>
</div>
</div>
<div className="overflow-auto" style={{height: 'calc(100% - 7rem)'}}>
<ConversationList
conversations={filteredConversations}
selectedConversation={selectedConversation}
onSelectConversation={handleSelectConversation}
loading={conversationsLoading}
/>
</div>
</div>
{/* Message Thread */}
<div className="flex-fill bg-white rounded-3 shadow overflow-hidden border" style={{minWidth: 0}}>
<MessageThread
conversation={selectedConversation}
startDate={startDate}
endDate={endDate}
/>
</div>
</>
) : activeView === 'search' ? (
/* Search View */
<div className="flex-fill bg-white rounded-3 shadow overflow-hidden border" style={{minWidth: 0}}>
<Search
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
results={searchResults}
setResults={setSearchResults}
loading={searchLoading}
setLoading={setSearchLoading}
searched={searchExecuted}
setSearched={setSearchExecuted}
scrollPosition={searchScrollPosition}
setScrollPosition={setSearchScrollPosition}
/>
</div>
) : (
/* Activity View */
<div className="flex-fill bg-white rounded-3 shadow overflow-hidden border" style={{minWidth: 0}}>
<Activity
startDate={startDate}
endDate={endDate}
/>
</div>
)}
</div>
{/* Upload Modal */}
{showUpload && (
<Upload
onClose={() => setShowUpload(false)}
onSuccess={handleUploadSuccess}
/>
)}
{/* Change Password Modal */}
{showPasswordModal && (
<ChangePasswordModal
onClose={() => setShowPasswordModal(false)}
onSuccess={() => {
// Password changed successfully
console.log('Password changed successfully')
}}
/>
)}
</div>
)
}
export default App
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+437
View File
@@ -0,0 +1,437 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import axios from 'axios'
import LazyMedia from './LazyMedia'
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
const PAGE_SIZE = 50
function Activity({ startDate, endDate }) {
const [activities, setActivities] = useState([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [offset, setOffset] = useState(0)
const observerTarget = useRef(null)
const scrollContainerRef = useRef(null)
// Reset when date range changes
useEffect(() => {
setActivities([])
setOffset(0)
setHasMore(true)
fetchActivity(0, false)
}, [startDate, endDate])
const fetchActivity = async (currentOffset, append = false) => {
if (append) {
setLoadingMore(true)
} else {
setLoading(true)
}
try {
const params = {
limit: PAGE_SIZE,
offset: currentOffset
}
if (startDate) params.start = startDate.toISOString()
if (endDate) params.end = endDate.toISOString()
const response = await axios.get(`${API_BASE}/activity`, { params })
const newActivities = response.data || []
// If we got fewer items than the page size, we've reached the end
if (newActivities.length < PAGE_SIZE) {
setHasMore(false)
}
if (append) {
setActivities(prev => [...prev, ...newActivities])
} else {
setActivities(newActivities)
}
} catch (error) {
console.error('Error fetching activity:', error)
} finally {
setLoading(false)
setLoadingMore(false)
}
}
const loadMore = useCallback(() => {
console.log('loadMore called:', { loadingMore, hasMore, offset })
if (!loadingMore && hasMore) {
const newOffset = offset + PAGE_SIZE
setOffset(newOffset)
fetchActivity(newOffset, true)
}
}, [offset, loadingMore, hasMore])
// Set up intersection observer for infinite scroll
useEffect(() => {
// Make sure both refs are available
if (!scrollContainerRef.current || !observerTarget.current) {
console.log('Refs not ready:', { scroll: !!scrollContainerRef.current, target: !!observerTarget.current })
return
}
console.log('Setting up IntersectionObserver', { hasMore, loadingMore, activitiesCount: activities.length })
const observer = new IntersectionObserver(
(entries) => {
console.log('Observer callback fired', {
isIntersecting: entries[0].isIntersecting,
hasMore,
loadingMore
})
if (entries[0].isIntersecting && hasMore && !loadingMore) {
console.log('Intersection detected, loading more...')
loadMore()
}
},
{
root: scrollContainerRef.current,
rootMargin: '100px',
threshold: 0.1
}
)
observer.observe(observerTarget.current)
return () => {
observer.disconnect()
}
}, [loadMore, hasMore, loadingMore, activities])
const formatCallType = (type) => {
switch (type) {
case 1: return { label: 'Incoming call', icon: '📞', color: 'success' }
case 2: return { label: 'Outgoing call', icon: '📱', color: 'primary' }
case 3: return { label: 'Missed call', icon: '📵', color: 'danger' }
case 4: return { label: 'Voicemail', icon: '🎙️', color: 'info' }
case 5: return { label: 'Rejected call', icon: '🚫', color: 'warning' }
case 6: return { label: 'Refused call', icon: '❌', color: 'danger' }
default: return { label: 'Call', icon: '📞', color: 'secondary' }
}
}
const formatDuration = (seconds) => {
if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
return `${minutes}m ${secs}s`
}
const formatPhoneNumber = (phoneNumber) => {
if (!phoneNumber) return ''
// Handle comma-separated numbers (group conversations)
if (phoneNumber.includes(',')) {
const numbers = phoneNumber.split(',').map(n => n.trim())
return numbers.map(n => formatSinglePhoneNumber(n)).join(', ')
}
return formatSinglePhoneNumber(phoneNumber)
}
const formatSinglePhoneNumber = (phoneNumber) => {
if (!phoneNumber) return ''
// Remove any non-numeric characters except leading +
let cleaned = phoneNumber.replace(/[^\d+]/g, '')
// Handle +1 prefix (US numbers)
if (cleaned.startsWith('+1') && cleaned.length === 12) {
// Format as +1 (XXX) XXX-XXXX
return `+1 (${cleaned.slice(2, 5)}) ${cleaned.slice(5, 8)}-${cleaned.slice(8)}`
}
// Handle numbers with + country code
if (cleaned.startsWith('+')) {
return cleaned // Return international numbers as-is
}
// Handle 11-digit numbers starting with 1 (US numbers without +)
if (cleaned.length === 11 && cleaned.startsWith('1')) {
return `+1 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
}
// Handle 10-digit US numbers
if (cleaned.length === 10) {
return `+1 (${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`
}
// Return as-is if format doesn't match
return phoneNumber
}
const formatDate = (dateStr) => {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now - date
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
const timeStr = date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})
if (diffDays === 0) return `Today at ${timeStr}`
if (diffDays === 1) return `Yesterday at ${timeStr}`
if (diffDays < 7) return date.toLocaleDateString('en-US', {
weekday: 'long',
hour: 'numeric',
minute: '2-digit',
hour12: true
})
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
hour: 'numeric',
minute: '2-digit',
hour12: true
})
}
const getMessageTypeLabel = (type) => {
switch (type) {
case 1: return { label: 'Received', color: 'primary' }
case 2: return { label: 'Sent', color: 'success' }
case 3: return { label: 'Draft', color: 'secondary' }
case 4: return { label: 'Outbox', color: 'warning' }
case 5: return { label: 'Failed', color: 'danger' }
case 6: return { label: 'Queued', color: 'info' }
default: return { label: 'Message', color: 'secondary' }
}
}
const shouldDisplaySubject = (subject) => {
if (!subject) return false
// Filter out protocol buffer/RCS subjects
if (subject.startsWith('proto:')) return false
return true
}
// Get sender display name for a message in group conversations
const getSenderDisplayName = (message) => {
// For received messages, use the sender field if available
let senderPhone = message.sender
// If sender is empty, try to extract from addresses array
if (!senderPhone && message.addresses && message.addresses.length > 0) {
// Use the first address as the sender
senderPhone = message.addresses[0]
}
// If sender contains comma-separated numbers (shouldn't happen, but handle it),
// extract only the first one
if (senderPhone && senderPhone.includes(',')) {
senderPhone = senderPhone.split(',')[0].trim()
}
if (!senderPhone) return 'Unknown'
// Format as a single phone number (not as a group)
return formatSinglePhoneNumber(senderPhone)
}
if (loading) {
return (
<div className="h-100 d-flex align-items-center justify-content-center">
<div className="text-center">
<div className="spinner-border text-primary mb-3" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<p className="text-muted">Loading activity...</p>
</div>
</div>
)
}
if (activities.length === 0) {
return (
<div className="h-100 d-flex align-items-center justify-content-center">
<div className="text-center">
<svg style={{width: '4rem', height: '4rem'}} className="text-muted mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-muted">No activity found</p>
</div>
</div>
)
}
return (
<div className="h-100 d-flex flex-column">
<div className="bg-light border-bottom p-3">
<h2 className="h5 mb-0 d-flex align-items-center gap-2">
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Activity Timeline
<span className="badge bg-primary ms-auto">{activities.length} items</span>
</h2>
</div>
<div ref={scrollContainerRef} className="flex-fill overflow-auto p-3">
<div className="container-fluid">
{activities.map((activity, index) => {
if (activity.type === 'message' && activity.message) {
const msg = activity.message
const msgType = getMessageTypeLabel(msg.type)
// For MMS with multiple recipients, use the addresses array; otherwise use the single address
let displayAddress
if (msg.addresses && msg.addresses.length > 0) {
// Format each address and join with commas
displayAddress = msg.addresses.map(addr => formatPhoneNumber(addr)).join(', ')
} else {
// Fall back to the single address field
displayAddress = formatPhoneNumber(activity.address)
}
const displayName = activity.contact_name || displayAddress
// Check if this is a group conversation
const isGroupConversation = msg.addresses && msg.addresses.length > 1
const isSent = msg.type === 2
const showSenderLabel = isGroupConversation && !isSent
// Debug logging for ALL messages to understand what we're receiving
console.log('Message received:', {
id: msg.id,
addresses: msg.addresses,
addressesType: typeof msg.addresses,
addressesLength: msg.addresses?.length,
sender: msg.sender,
address: msg.address,
type: msg.type,
isSent,
isGroupConversation,
showSenderLabel,
body: msg.body?.substring(0, 30)
})
return (
<div key={`msg-${msg.id}`} className="card mb-2 shadow-sm" style={{padding: '0.5rem'}}>
<div className="card-body py-2 px-3">
<div className="d-flex justify-content-between align-items-start mb-1">
<div className="d-flex align-items-center gap-2">
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<div>
<h6 className="mb-0">
{displayName}
</h6>
<small className="text-muted">{displayAddress}</small>
</div>
</div>
<div className="text-end">
<span className={`badge bg-${msgType.color}`}>{msgType.label}</span>
<br />
<small className="text-muted">{formatDate(activity.date)}</small>
</div>
</div>
{/* Sender label for received messages in group conversations */}
{showSenderLabel && (
<div className="mb-1 ps-2">
<small className="text-muted fw-semibold">
From: {getSenderDisplayName(msg)}
</small>
</div>
)}
{shouldDisplaySubject(msg.subject) && (
<div className="mb-1">
<strong>Subject:</strong> {msg.subject}
</div>
)}
{msg.body && (
<p className="card-text mb-1">{msg.body}</p>
)}
{msg.media_type && (
<LazyMedia
messageId={msg.id}
mediaType={msg.media_type}
className="mt-1"
alt="MMS attachment"
/>
)}
</div>
</div>
)
} else if (activity.type === 'call' && activity.call) {
const call = activity.call
const callType = formatCallType(call.type)
const formattedAddress = formatPhoneNumber(activity.address)
const displayName = activity.contact_name || formattedAddress
return (
<div key={`call-${call.id}`} className="card mb-2 shadow-sm border-start border-4" style={{borderLeftColor: `var(--bs-${callType.color})`}}>
<div className="card-body py-2 px-3">
<div className="d-flex justify-content-between align-items-start">
<div className="d-flex align-items-center gap-2">
<div style={{fontSize: '1.25rem'}}>{callType.icon}</div>
<div>
<h6 className="mb-0">
{displayName}
</h6>
<small className="text-muted">{formattedAddress}</small>
</div>
</div>
<div className="text-end">
<span className={`badge bg-${callType.color}`}>{callType.label}</span>
<br />
<small className="text-muted">{formatDate(activity.date)}</small>
</div>
</div>
{call.duration > 0 && (
<div className="mt-1">
<small className="text-muted">
<svg style={{width: '1rem', height: '1rem'}} className="me-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Duration: {formatDuration(call.duration)}
</small>
</div>
)}
</div>
</div>
)
}
return null
})}
{/* Infinite scroll trigger */}
<div ref={observerTarget} style={{ height: '20px' }} />
{/* Loading more indicator */}
{loadingMore && (
<div className="text-center py-3">
<div className="spinner-border spinner-border-sm text-primary" role="status">
<span className="visually-hidden">Loading more...</span>
</div>
<p className="small text-muted mt-2 mb-0">Loading more activities...</p>
</div>
)}
{/* End of results indicator */}
{!hasMore && activities.length > 0 && (
<div className="text-center py-3">
<small className="text-muted">No more activities to load</small>
</div>
)}
</div>
</div>
</div>
)
}
export default Activity
@@ -0,0 +1,119 @@
import { useState } from 'react'
import { Modal, Button, Form, Alert } from 'react-bootstrap'
import { useAuth } from '../contexts/AuthContext'
function ChangePasswordModal({ onClose, onSuccess }) {
const { changePassword } = useAuth()
const [oldPassword, setOldPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const handleSubmit = async (e) => {
e.preventDefault()
setError(null)
// Validate fields
if (!oldPassword || !newPassword || !confirmPassword) {
setError('All fields are required')
return
}
if (newPassword !== confirmPassword) {
setError('New passwords do not match')
return
}
if (newPassword.length < 6) {
setError('New password must be at least 6 characters')
return
}
setLoading(true)
try {
const result = await changePassword(oldPassword, newPassword, confirmPassword)
if (result.success) {
if (onSuccess) {
onSuccess()
}
onClose()
} else {
setError(result.error || 'Failed to change password')
}
} catch (err) {
setError('Failed to change password. Please try again.')
} finally {
setLoading(false)
}
}
return (
<Modal show={true} onHide={onClose} centered backdrop="static" keyboard={!loading}>
<Modal.Header closeButton={!loading}>
<Modal.Title className="h4 fw-bold">Change Password</Modal.Title>
</Modal.Header>
<Modal.Body>
{error && (
<Alert variant="danger" dismissible onClose={() => setError(null)}>
{error}
</Alert>
)}
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>Current Password</Form.Label>
<Form.Control
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
disabled={loading}
autoFocus
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>New Password</Form.Label>
<Form.Control
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={loading}
required
minLength={6}
/>
<Form.Text className="text-muted">
Must be at least 6 characters
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Confirm New Password</Form.Label>
<Form.Control
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={loading}
required
minLength={6}
/>
</Form.Group>
</Form>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSubmit}
disabled={loading || !oldPassword || !newPassword || !confirmPassword}
>
{loading ? 'Changing...' : 'Change Password'}
</Button>
</Modal.Footer>
</Modal>
)
}
export default ChangePasswordModal
@@ -0,0 +1,170 @@
import { formatDistanceToNow } from 'date-fns'
function ConversationList({ conversations, selectedConversation, onSelectConversation, loading }) {
const formatDate = (date) => {
try {
return formatDistanceToNow(new Date(date), { addSuffix: true })
} catch {
return ''
}
}
const truncateMessage = (message, maxLength = 50) => {
if (!message) return ''
if (message.length <= maxLength) return message
return message.substring(0, maxLength).trim() + '...'
}
const formatPhoneNumber = (number) => {
if (!number) return 'Unknown'
// Handle comma-separated numbers (group conversations)
if (number.includes(',')) {
const numbers = number.split(',').map(n => n.trim())
return numbers.map(n => formatSinglePhoneNumber(n)).join(', ')
}
return formatSinglePhoneNumber(number)
}
const formatSinglePhoneNumber = (number) => {
if (!number) return 'Unknown'
// Remove all non-digit characters
const cleaned = number.replace(/\D/g, '')
// Handle 11-digit numbers (e.g., +1 country code)
if (cleaned.length === 11 && cleaned.startsWith('1')) {
return `+1 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
}
// Handle 10-digit numbers (US format)
if (cleaned.length === 10) {
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`
}
// Handle other formats - try to format with spaces
if (cleaned.length > 10) {
// International format: +XX XXX XXX XXXX
return `+${cleaned.slice(0, cleaned.length - 10)} ${cleaned.slice(cleaned.length - 10, cleaned.length - 7)} ${cleaned.slice(cleaned.length - 7, cleaned.length - 4)} ${cleaned.slice(cleaned.length - 4)}`
}
// Return original if we can't format it nicely
return number
}
const shouldDisplaySubject = (subject) => {
if (!subject) return false
// Filter out protocol buffer/RCS subjects
if (subject.startsWith('proto:')) return false
return true
}
const getDisplayName = (conv) => {
// If we have a valid subject, use it when contact_name is empty, "(Unknown)", or looks like an 8-digit number
if (conv.subject && shouldDisplaySubject(conv.subject)) {
if (!conv.contact_name || conv.contact_name === '(Unknown)' || /^\d{8}$/.test(conv.contact_name)) {
return conv.subject
}
}
// If contact_name is empty, null, or "(Unknown)", use formatted phone number
if (!conv.contact_name || conv.contact_name === '(Unknown)') {
return formatPhoneNumber(conv.address)
}
return conv.contact_name
}
const getConversationIcon = (type) => {
if (type === 'call') {
return (
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
)
}
return (
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
)
}
if (loading) {
return (
<div className="d-flex align-items-center justify-content-center h-100">
<div className="text-center">
<div className="spinner-border text-primary mb-3" role="status" style={{width: '3rem', height: '3rem'}}>
<span className="visually-hidden">Loading...</span>
</div>
<p className="text-muted fw-medium">Loading conversations...</p>
</div>
</div>
)
}
if (conversations.length === 0) {
return (
<div className="d-flex align-items-center justify-content-center h-100 text-muted p-4">
<div className="text-center">
<svg style={{width: '4rem', height: '4rem'}} className="mx-auto mb-3 text-secondary opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<p className="fw-medium text-dark">No conversations found</p>
<p className="small mt-2">Upload a backup file to get started</p>
</div>
</div>
)
}
return (
<div className="list-group list-group-flush">
{conversations.map((conv, index) => {
const isSelected = selectedConversation &&
selectedConversation.address === conv.address &&
selectedConversation.type === conv.type
return (
<div
key={`${conv.type}-${conv.address}-${index}`}
onClick={() => onSelectConversation(conv)}
className={`list-group-item list-group-item-action ${
isSelected ? 'active' : ''
}`}
style={{cursor: 'pointer'}}
>
<div className="d-flex align-items-start gap-2">
<div className="flex-shrink-0 mt-1 p-2 rounded-circle bg-white shadow-sm">
{getConversationIcon(conv.type)}
</div>
<div className="flex-fill min-w-0" style={{overflow: 'hidden'}}>
<div className="d-flex justify-content-between align-items-baseline mb-1 gap-2">
<h6 className="fw-semibold mb-0 text-truncate" style={{flex: '1 1 auto', minWidth: 0}}>
{getDisplayName(conv)}
</h6>
<small className="text-nowrap flex-shrink-0" style={{fontSize: '0.75rem'}}>
{formatDate(conv.last_date)}
</small>
</div>
<p className="small mb-1 text-muted" style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: '0.85rem'
}}>
{truncateMessage(conv.last_message, 50)}
</p>
<div className="d-flex align-items-center gap-1">
<span className="badge bg-secondary" style={{fontSize: '0.7rem'}}>
{conv.message_count} {conv.type === 'call' ? 'call' : 'message'}{conv.message_count !== 1 ? 's' : ''}
</span>
</div>
</div>
</div>
</div>
)
})}
</div>
)
}
export default ConversationList
+67
View File
@@ -0,0 +1,67 @@
import DatePicker from 'react-datepicker'
import 'react-datepicker/dist/react-datepicker.css'
function DateFilter({ startDate, endDate, minDate, maxDate, onStartDateChange, onEndDateChange }) {
const clearDates = () => {
onStartDateChange(null)
onEndDateChange(null)
}
return (
<div className="p-2 bg-light">
<div className="d-flex align-items-center gap-4 flex-wrap">
<div className="d-flex align-items-center gap-2">
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<label className="small fw-semibold mb-0">From:</label>
<DatePicker
selected={startDate}
onChange={onStartDateChange}
selectsStart
startDate={startDate}
endDate={endDate}
minDate={minDate}
maxDate={maxDate}
dateFormat="MMM d, yyyy"
className="form-control form-control-sm"
placeholderText="Select start date"
/>
</div>
<div className="d-flex align-items-center gap-2">
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<label className="small fw-semibold mb-0">To:</label>
<DatePicker
selected={endDate}
onChange={onEndDateChange}
selectsEnd
startDate={startDate}
endDate={endDate}
minDate={startDate || minDate}
maxDate={maxDate}
dateFormat="MMM d, yyyy"
className="form-control form-control-sm"
placeholderText="Select end date"
/>
</div>
{(startDate || endDate) && (
<button
onClick={clearDates}
className="btn btn-sm btn-outline-primary d-flex align-items-center gap-2"
>
<svg style={{width: '1rem', height: '1rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Clear dates
</button>
)}
</div>
</div>
)
}
export default DateFilter
+332
View File
@@ -0,0 +1,332 @@
import { useState, useEffect, useRef } from 'react'
import axios from 'axios'
import VCardPreview from './VCardPreview'
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
function LazyMedia({ messageId, mediaType, className, alt = "MMS attachment" }) {
const [src, setSrc] = useState(null)
const [vcfData, setVcfData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
const [showModal, setShowModal] = useState(false)
const imgRef = useRef(null)
const videoRef = useRef(null)
const observerRef = useRef(null)
const hasLoadedRef = useRef(false)
useEffect(() => {
// Reset loaded state when messageId changes
hasLoadedRef.current = false
// Set up Intersection Observer for lazy loading
observerRef.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !hasLoadedRef.current) {
hasLoadedRef.current = true
loadMedia()
}
})
},
{
// Only load images below viewport (not above) to prevent scroll jump
// rootMargin: top right bottom left
rootMargin: '50px 0px 200px 0px'
}
)
if (imgRef.current) {
observerRef.current.observe(imgRef.current)
}
return () => {
if (observerRef.current) {
observerRef.current.disconnect()
}
}
}, [messageId])
const loadMedia = async () => {
setLoading(true)
try {
// Check if this is a VCF file - fetch as text instead of blob
const isVCard = mediaType === 'text/x-vcard' ||
mediaType === 'text/vcard' ||
mediaType === 'text/directory'
if (isVCard) {
// Fetch VCF as text
const response = await axios.get(`${API_BASE}/media`, {
params: { id: messageId },
responseType: 'text'
})
setVcfData(response.data)
} else {
// Fetch other media as blob
const response = await axios.get(`${API_BASE}/media`, {
params: { id: messageId },
responseType: 'blob'
})
const blob = response.data
const objectUrl = URL.createObjectURL(blob)
setSrc(objectUrl)
}
// Stop observing once loaded - we don't need to track this element anymore
if (observerRef.current && imgRef.current) {
observerRef.current.unobserve(imgRef.current)
}
} catch (err) {
console.error('Failed to load media:', err)
setError(true)
} finally {
setLoading(false)
}
}
// Cleanup object URL when component unmounts
useEffect(() => {
return () => {
if (src) {
URL.revokeObjectURL(src)
}
}
}, [src])
// Handle ESC key to close modal
useEffect(() => {
const handleEscape = (e) => {
if (e.key === 'Escape' && showModal) {
setShowModal(false)
}
}
if (showModal) {
document.addEventListener('keydown', handleEscape)
// Prevent body scroll when modal is open
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = 'unset'
}
}, [showModal])
// Pause original video when modal opens
useEffect(() => {
if (showModal && videoRef.current) {
videoRef.current.pause()
}
}, [showModal])
if (!mediaType) {
return null
}
const isImage = mediaType.startsWith('image/')
const isVideo = mediaType.startsWith('video/')
const isVCard = mediaType === 'text/x-vcard' ||
mediaType === 'text/vcard' ||
mediaType === 'text/directory'
return (
<>
<div ref={imgRef} className={className}>
{/* Placeholder shown before loading or while loading */}
{!src && !vcfData && !error && (
<div
className="bg-light rounded d-flex align-items-center justify-content-center position-relative overflow-hidden"
style={{
width: '100%',
aspectRatio: isVideo ? '16/9' : '3/4', // Common phone camera ratio
minHeight: isVideo ? '200px' : '300px', // Larger to prevent layout shift
maxHeight: '400px',
backgroundColor: '#f8f9fa',
backgroundImage: 'linear-gradient(45deg, #e9ecef 25%, transparent 25%, transparent 75%, #e9ecef 75%, #e9ecef), linear-gradient(45deg, #e9ecef 25%, transparent 25%, transparent 75%, #e9ecef 75%, #e9ecef)',
backgroundSize: '20px 20px',
backgroundPosition: '0 0, 10px 10px'
}}
>
<div className="text-center">
{loading ? (
<>
<div className="spinner-border spinner-border-sm text-secondary mb-2" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<div className="small text-muted">Loading {isImage ? 'image' : isVideo ? 'video' : isVCard ? 'contact' : 'media'}...</div>
</>
) : (
<div className="text-muted d-flex flex-column align-items-center">
{isImage && (
<svg style={{width: '2.5rem', height: '2.5rem'}} className="mb-2 text-secondary 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>
)}
{isVideo && (
<svg style={{width: '2.5rem', height: '2.5rem'}} className="mb-2 text-secondary opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
{isVCard && (
<svg style={{width: '2.5rem', height: '2.5rem'}} className="mb-2 text-secondary opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
)}
{!isImage && !isVideo && !isVCard && (
<svg style={{width: '2.5rem', height: '2.5rem'}} className="mb-2 text-secondary opacity-50" 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>
)}
<small className="text-muted">
{isImage ? 'Image' : isVideo ? 'Video' : isVCard ? 'Contact' : 'Attachment'}
</small>
</div>
)}
</div>
</div>
)}
{error && (
<div className="alert alert-warning mb-0 small">
<div className="d-flex align-items-center gap-2">
<svg style={{width: '1.25rem', height: '1.25rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Failed to load media
</div>
</div>
)}
{(src || vcfData) && !loading && !error && (
<>
{isImage && src && (
<img
src={src}
alt={alt}
className="img-fluid rounded shadow"
loading="lazy"
onClick={() => setShowModal(true)}
style={{
display: 'block',
maxWidth: '100%',
maxHeight: '400px',
objectFit: 'contain',
animation: 'fadeIn 0.3s ease-in',
cursor: 'pointer'
}}
/>
)}
{isVideo && src && (
<video
ref={videoRef}
controls
className="img-fluid rounded shadow"
src={src}
onClick={(e) => {
e.preventDefault()
if (videoRef.current) {
videoRef.current.pause()
}
setShowModal(true)
}}
style={{
display: 'block',
maxWidth: '100%',
maxHeight: '400px',
objectFit: 'contain',
animation: 'fadeIn 0.3s ease-in',
cursor: 'pointer'
}}
/>
)}
{isVCard && vcfData && (
<VCardPreview vcfText={vcfData} messageId={messageId} />
)}
{!isImage && !isVideo && !isVCard && (
<div className="small p-2 rounded bg-light d-flex align-items-center gap-1">
<svg style={{width: '1rem', height: '1rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
Attachment: {mediaType}
</div>
)}
</>
)}
</div>
{/* Full-screen modal */}
{showModal && (isImage || isVideo) && src && (
<div
className="position-fixed top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center"
style={{
backgroundColor: 'rgba(0, 0, 0, 0.9)',
zIndex: 9999,
padding: '2rem'
}}
onClick={() => setShowModal(false)}
>
{/* Close button */}
<button
className="btn btn-light position-absolute top-0 end-0 m-3"
onClick={() => setShowModal(false)}
style={{
zIndex: 10000,
borderRadius: '50%',
width: '40px',
height: '40px',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<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>
{/* Media content - stop propagation to prevent closing when clicking on media */}
<div
onClick={(e) => e.stopPropagation()}
className="d-flex align-items-center justify-content-center"
style={{
maxWidth: '95vw',
maxHeight: '95vh'
}}
>
{isImage && (
<img
src={src}
alt={alt}
className="rounded shadow-lg"
style={{
maxWidth: '100%',
maxHeight: '95vh',
objectFit: 'contain'
}}
/>
)}
{isVideo && (
<video
controls
autoPlay
className="rounded shadow-lg"
src={src}
style={{
maxWidth: '100%',
maxHeight: '95vh',
objectFit: 'contain'
}}
/>
)}
</div>
</div>
)}
</>
)
}
export default LazyMedia
+179
View File
@@ -0,0 +1,179 @@
import { useState } from 'react'
import { useAuth } from '../contexts/AuthContext'
import { useNavigate } from 'react-router-dom'
function Login() {
const [isLogin, setIsLogin] = useState(true)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login, register } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
// Validation
if (!username.trim() || !password) {
setError('Username and password are required')
return
}
if (!isLogin) {
if (username.trim().length < 3) {
setError('Username must be at least 3 characters')
return
}
if (password.length < 6) {
setError('Password must be at least 6 characters')
return
}
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
}
setLoading(true)
try {
const result = isLogin
? await login(username.trim(), password)
: await register(username.trim(), password)
if (result.success) {
navigate('/')
} else {
setError(result.error || 'Authentication failed')
}
} catch (err) {
setError('An unexpected error occurred')
} finally {
setLoading(false)
}
}
const toggleMode = () => {
setIsLogin(!isLogin)
setError('')
setPassword('')
setConfirmPassword('')
}
return (
<div className="min-vh-100 d-flex align-items-center justify-content-center bg-light">
<div className="container">
<div className="row justify-content-center">
<div className="col-md-6 col-lg-4">
<div className="card shadow">
<div className="card-body p-4">
<div className="text-center mb-4">
<h2 className="h4 mb-2">
<svg style={{width: '2rem', height: '2rem'}} className="text-primary me-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
SMS Backup Viewer
</h2>
<p className="text-muted mb-0">
{isLogin ? 'Sign in to your account' : 'Create a new account'}
</p>
</div>
<form onSubmit={handleSubmit}>
{error && (
<div className="alert alert-danger" role="alert">
{error}
</div>
)}
<div className="mb-3">
<label htmlFor="username" className="form-label">
Username
</label>
<input
type="text"
className="form-control"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="username"
disabled={loading}
/>
</div>
<div className="mb-3">
<label htmlFor="password" className="form-label">
Password
</label>
<input
type="password"
className="form-control"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete={isLogin ? 'current-password' : 'new-password'}
disabled={loading}
/>
</div>
{!isLogin && (
<div className="mb-3">
<label htmlFor="confirmPassword" className="form-label">
Confirm Password
</label>
<input
type="password"
className="form-control"
id="confirmPassword"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
autoComplete="new-password"
disabled={loading}
/>
</div>
)}
<button
type="submit"
className="btn btn-primary w-100 mb-3"
disabled={loading}
>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
{isLogin ? 'Signing in...' : 'Creating account...'}
</>
) : (
<>{isLogin ? 'Sign In' : 'Create Account'}</>
)}
</button>
<div className="text-center">
<button
type="button"
className="btn btn-link text-decoration-none"
onClick={toggleMode}
disabled={loading}
>
{isLogin
? "Don't have an account? Sign up"
: 'Already have an account? Sign in'}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export default Login
+542
View File
@@ -0,0 +1,542 @@
import { useState, useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import axios from 'axios'
import { format } from 'date-fns'
import LazyMedia from './LazyMedia'
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
function MessageThread({ conversation, startDate, endDate }) {
const location = useLocation()
const [items, setItems] = useState([])
const [loading, setLoading] = useState(false)
const [highlightedMessageId, setHighlightedMessageId] = useState(null)
const messageRefs = useRef({})
useEffect(() => {
if (conversation) {
fetchItems()
} else {
setItems([])
}
}, [conversation, startDate, endDate])
// Scroll to specific message if messageId is in URL
useEffect(() => {
if (items.length > 0) {
const params = new URLSearchParams(location.search)
const messageId = params.get('messageId')
if (messageId) {
setHighlightedMessageId(messageId)
if (messageRefs.current[messageId]) {
const element = messageRefs.current[messageId]
// Function to wait for media in an element to load
const waitForMediaInElement = (elem) => {
const images = Array.from(elem.querySelectorAll('img'))
const videos = Array.from(elem.querySelectorAll('video'))
const media = [...images, ...videos]
if (media.length === 0) {
return Promise.resolve()
}
const mediaPromises = media.map(mediaElement => {
if (mediaElement.complete || mediaElement.readyState >= 2) {
return Promise.resolve()
}
return new Promise((resolve) => {
mediaElement.addEventListener('load', resolve, { once: true })
mediaElement.addEventListener('loadeddata', resolve, { once: true })
mediaElement.addEventListener('error', resolve, { once: true })
setTimeout(resolve, 3000)
})
})
return Promise.all(mediaPromises)
}
// Function to perform the scroll
const scrollToElement = () => {
element.scrollIntoView({
behavior: 'smooth',
block: 'center'
})
}
// Multi-stage scroll approach:
// 1. Initial scroll to get element near viewport (triggers lazy loading)
// 2. Wait for lazy-loaded media
// 3. Final scroll to correct position
setTimeout(() => {
// First scroll - instant to trigger lazy loading
element.scrollIntoView({
behavior: 'instant',
block: 'center'
})
// Wait a bit for lazy loading to trigger
setTimeout(() => {
// Wait for media to load
waitForMediaInElement(element).then(() => {
// Final smooth scroll to correct position
scrollToElement()
// Re-scroll after a delay to handle any late-loading media
setTimeout(scrollToElement, 500)
setTimeout(scrollToElement, 1500)
})
}, 200)
}, 100)
}
} else {
setHighlightedMessageId(null)
}
}
}, [items, location.search])
// Automatically scroll to the last message when opening a conversation
useEffect(() => {
if (items.length > 0) {
const params = new URLSearchParams(location.search)
const messageId = params.get('messageId')
// Only auto-scroll if there's no specific messageId in the URL
if (!messageId) {
// Find the last message (not a call) to scroll to
const lastItem = items[items.length - 1]
let lastMessageId = null
// Handle ActivityItem format vs direct Message format
if (lastItem.type === 'message' && lastItem.message) {
lastMessageId = lastItem.message.id
} else if (lastItem.type === 'call') {
// If last item is a call, find the last message before it
for (let i = items.length - 1; i >= 0; i--) {
if (items[i].type === 'message' && items[i].message) {
lastMessageId = items[i].message.id
break
}
}
} else if (lastItem.id) {
// Direct message format
lastMessageId = lastItem.id
}
if (lastMessageId && messageRefs.current[lastMessageId]) {
const element = messageRefs.current[lastMessageId]
// Function to wait for media in an element to load
const waitForMediaInElement = (elem) => {
const images = Array.from(elem.querySelectorAll('img'))
const videos = Array.from(elem.querySelectorAll('video'))
const media = [...images, ...videos]
if (media.length === 0) {
return Promise.resolve()
}
const mediaPromises = media.map(mediaElement => {
if (mediaElement.complete || mediaElement.readyState >= 2) {
return Promise.resolve()
}
return new Promise((resolve) => {
mediaElement.addEventListener('load', resolve, { once: true })
mediaElement.addEventListener('loadeddata', resolve, { once: true })
mediaElement.addEventListener('error', resolve, { once: true })
setTimeout(resolve, 3000)
})
})
return Promise.all(mediaPromises)
}
// Function to perform the scroll
const scrollToElement = () => {
element.scrollIntoView({
behavior: 'instant',
block: 'end'
})
}
// Scroll to last message after a short delay to ensure rendering is complete
setTimeout(() => {
// First scroll to trigger lazy loading if needed
scrollToElement()
// Wait for media to load, then scroll again
setTimeout(() => {
waitForMediaInElement(element).then(() => {
scrollToElement()
// Re-scroll after a delay to handle any late-loading media
setTimeout(scrollToElement, 300)
})
}, 100)
}, 100)
}
}
}
}, [items, location.search])
const fetchItems = async () => {
setLoading(true)
try {
const params = {
address: conversation.address,
type: conversation.type
}
if (startDate) params.start = startDate.toISOString()
if (endDate) params.end = endDate.toISOString()
const response = await axios.get(`${API_BASE}/messages`, { params })
setItems(response.data || [])
} catch (error) {
console.error('Error fetching items:', error)
} finally {
setLoading(false)
}
}
const formatTime = (date) => {
return format(new Date(date), 'MMM d, yyyy h:mm a')
}
const formatDuration = (seconds) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
const formatPhoneNumber = (number) => {
if (!number) return 'Unknown'
// Handle comma-separated numbers (group conversations)
if (number.includes(',')) {
const numbers = number.split(',').map(n => n.trim())
return numbers.map(n => formatSinglePhoneNumber(n)).join(', ')
}
return formatSinglePhoneNumber(number)
}
const formatSinglePhoneNumber = (number) => {
if (!number) return 'Unknown'
// Remove all non-digit characters
const cleaned = number.replace(/\D/g, '')
// Handle 11-digit numbers (e.g., +1 country code)
if (cleaned.length === 11 && cleaned.startsWith('1')) {
return `+1 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
}
// Handle 10-digit numbers (US format)
if (cleaned.length === 10) {
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`
}
// Handle other formats - try to format with spaces
if (cleaned.length > 10) {
// International format: +XX XXX XXX XXXX
return `+${cleaned.slice(0, cleaned.length - 10)} ${cleaned.slice(cleaned.length - 10, cleaned.length - 7)} ${cleaned.slice(cleaned.length - 7, cleaned.length - 4)} ${cleaned.slice(cleaned.length - 4)}`
}
// Return original if we can't format it nicely
return number
}
const getDisplayName = (conv) => {
// If we have a valid subject, use it when contact_name is empty, "(Unknown)", or looks like an 8-digit number
if (conv.subject && shouldDisplaySubject(conv.subject)) {
if (!conv.contact_name || conv.contact_name === '(Unknown)' || /^\d{8}$/.test(conv.contact_name)) {
return conv.subject
}
}
// If contact_name is empty, null, or "(Unknown)", use formatted phone number
if (!conv.contact_name || conv.contact_name === '(Unknown)') {
return formatPhoneNumber(conv.address)
}
return conv.contact_name
}
const shouldDisplaySubject = (subject) => {
if (!subject) return false
// Filter out protocol buffer/RCS subjects
if (subject.startsWith('proto:')) return false
return true
}
const getCallTypeInfo = (type) => {
switch (type) {
case 1: return { label: 'Incoming', color: 'text-success', bgColor: 'bg-success', icon: '↓' }
case 2: return { label: 'Outgoing', color: 'text-primary', bgColor: 'bg-primary', icon: '↑' }
case 3: return { label: 'Missed', color: 'text-danger', bgColor: 'bg-danger', icon: '✕' }
case 4: return { label: 'Voicemail', color: 'text-info', bgColor: 'bg-info', icon: '⊙' }
case 5: return { label: 'Rejected', color: 'text-warning', bgColor: 'bg-warning', icon: '✕' }
case 6: return { label: 'Refused', color: 'text-secondary', bgColor: 'bg-secondary', icon: '✕' }
default: return { label: 'Call', color: 'text-secondary', bgColor: 'bg-secondary', icon: '○' }
}
}
// Check if conversation is a group conversation
// Handle both ActivityItem format (items[0].message) and direct Message format (items[0])
const isGroupConversation = items.length > 0 && (() => {
const firstItem = items[0]
// ActivityItem format: check message.addresses
if (firstItem.type === 'message' && firstItem.message) {
return firstItem.message.addresses && firstItem.message.addresses.length > 1
}
// Direct Message format: check addresses directly
return firstItem.addresses && firstItem.addresses.length > 1
})()
// Get sender display name for a message
const getSenderDisplayName = (message) => {
// For received messages, use the sender field if available
let senderPhone = message.sender
// If sender is empty, try to extract from addresses array
// (exclude any number that might be "me" - this is a received message so sender is someone else)
if (!senderPhone && message.addresses && message.addresses.length > 0) {
// For now, use the first address as the sender
// In the future, we could exclude the current user's number
senderPhone = message.addresses[0]
}
// If sender contains comma-separated numbers (shouldn't happen, but handle it),
// extract only the first one
if (senderPhone && senderPhone.includes(',')) {
senderPhone = senderPhone.split(',')[0].trim()
}
if (!senderPhone) return 'Unknown'
// Format as a single phone number (not as a group)
return formatSinglePhoneNumber(senderPhone)
}
if (!conversation) {
return (
<div className="d-flex align-items-center justify-content-center h-100 text-muted">
<div className="text-center">
<svg style={{width: '5rem', height: '5rem'}} className="mx-auto mb-3 text-secondary opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<p className="h5 text-dark">Select a conversation</p>
<p className="small mt-2">Choose a conversation from the list to view messages</p>
</div>
</div>
)
}
if (loading) {
return (
<div className="d-flex align-items-center justify-content-center h-100">
<div className="text-center">
<div className="spinner-border text-primary mb-3" role="status" style={{width: '3rem', height: '3rem'}}>
<span className="visually-hidden">Loading...</span>
</div>
<p className="text-muted fw-medium">Loading messages...</p>
</div>
</div>
)
}
const isCallLog = conversation.type === 'call'
return (
<div className="d-flex flex-column h-100">
{/* Thread Header */}
<div className="bg-light border-bottom p-4 shadow-sm">
<div className="d-flex align-items-center gap-3">
<div className="p-3 rounded-circle bg-primary bg-gradient shadow">
{isCallLog ? (
<svg style={{width: '1.5rem', height: '1.5rem'}} className="text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
) : (
<svg style={{width: '1.5rem', height: '1.5rem'}} className="text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
)}
</div>
<div>
<h2 className="h4 fw-bold mb-1">
{getDisplayName(conversation)}
</h2>
{/* Display phone numbers for conversations with addresses */}
{!isCallLog && items.length > 0 && (() => {
const firstItem = items[0]
// Get addresses from either ActivityItem.message or direct Message
const addresses = (firstItem.type === 'message' && firstItem.message)
? firstItem.message.addresses
: firstItem.addresses
return addresses && addresses.length > 0 && (
<div className="small text-muted mb-2">
{addresses.map((addr, idx) => (
<span key={idx}>
{formatPhoneNumber(addr)}
{idx < addresses.length - 1 ? ', ' : ''}
</span>
))}
</div>
)
})()}
<div>
<span className="badge bg-primary">
{items.length} {isCallLog ? 'call' : 'message'}{items.length !== 1 ? 's' : ''}
</span>
</div>
</div>
</div>
</div>
{/* Content */}
<div className="flex-fill overflow-auto p-4 bg-light">
{isCallLog ? (
// Call Log View
<div className="d-flex flex-column gap-3">
{items.map((call) => {
const typeInfo = getCallTypeInfo(call.type)
return (
<div
key={call.id}
className="card shadow-sm border-2"
>
<div className="card-body">
<div className="d-flex align-items-center justify-content-between">
<div className="d-flex align-items-center gap-3">
<div className={`p-3 rounded-circle ${typeInfo.bgColor} bg-opacity-10`}>
<span className={`fs-4 ${typeInfo.color}`}>
{typeInfo.icon}
</span>
</div>
<div>
<div className={`fw-semibold ${typeInfo.color}`}>
{typeInfo.label} Call
</div>
<div className="small text-muted mt-1 d-flex align-items-center gap-1">
<svg style={{width: '1rem', height: '1rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{formatTime(call.date)}
</div>
</div>
</div>
<div className="text-end">
<div className="h5 fw-bold mb-0">
{formatDuration(call.duration)}
</div>
</div>
</div>
</div>
</div>
)
})}
</div>
) : (
// Unified Message and Call View
<div className="d-flex flex-column gap-1">
{items.map((item) => {
// Check if this is an ActivityItem (has type field) or a direct Message
const isActivityItem = item.type === 'message' || item.type === 'call'
const isCall = isActivityItem && item.type === 'call'
const message = isActivityItem ? item.message : item
const call = isActivityItem ? item.call : null
if (isCall && call) {
// Compact call representation - inline with messages
const typeInfo = getCallTypeInfo(call.type)
return (
<div key={`call-${call.id}`} className="d-flex justify-content-center my-1">
<div className="badge bg-light text-dark border px-3 py-2 d-flex align-items-center gap-2" style={{fontSize: '0.75rem'}}>
<span className={typeInfo.color} style={{fontSize: '1rem'}}>{typeInfo.icon}</span>
<span className={`fw-semibold ${typeInfo.color}`}>{typeInfo.label} call</span>
<span className="text-muted">·</span>
<span className="text-muted">{formatTime(call.date)}</span>
{call.duration > 0 && (
<>
<span className="text-muted">·</span>
<span className="text-muted">{formatDuration(call.duration)}</span>
</>
)}
</div>
</div>
)
}
// Message rendering
if (!message) return null
const isSent = message.type === 2
const isHighlighted = highlightedMessageId === String(message.id)
const showSenderLabel = isGroupConversation && !isSent
return (
<div
key={message.id}
className={`d-flex ${isSent ? 'justify-content-end' : 'justify-content-start'}`}
>
<div style={{ maxWidth: '70%' }}>
{/* Sender label for received messages in group conversations */}
{showSenderLabel && (
<div className="small text-muted mb-1 ms-2" style={{ fontSize: '0.7rem' }}>
{getSenderDisplayName(message)}
</div>
)}
<div
ref={(el) => (messageRefs.current[message.id] = el)}
className={`card shadow-sm ${
isSent
? 'bg-primary text-white'
: 'bg-white'
} ${
isHighlighted
? 'border-warning border-3'
: 'border-2'
}`}
style={{
padding: '0.5em',
position: 'relative'
}}
>
<div className="card-body py-1 px-2">
{message.body && (
<div style={{whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: '0.875rem', lineHeight: '1.3'}}>
{message.body}
</div>
)}
{message.media_type && (
<LazyMedia
messageId={message.id}
mediaType={message.media_type}
className="mt-1"
alt="MMS attachment"
/>
)}
<div
className={`mt-1 d-flex align-items-center gap-1 ${
isSent ? 'text-white-50' : 'text-muted'
}`}
style={{fontSize: '0.75rem'}}
>
<svg style={{width: '0.7rem', height: '0.7rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{formatTime(message.date)}
</div>
</div>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
</div>
)
}
export default MessageThread
@@ -0,0 +1,27 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
function ProtectedRoute({ children }) {
const { isAuthenticated, loading } = useAuth()
if (loading) {
return (
<div className="min-vh-100 d-flex align-items-center justify-content-center">
<div className="text-center">
<div className="spinner-border text-primary mb-3" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<p className="text-muted">Loading...</p>
</div>
</div>
)
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return children
}
export default ProtectedRoute
+206
View File
@@ -0,0 +1,206 @@
import { useNavigate } from 'react-router-dom'
import { useEffect, useRef } from 'react'
import axios from 'axios'
import { format } from 'date-fns'
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
function Search({ searchQuery, setSearchQuery, results, setResults, loading, setLoading, searched, setSearched, scrollPosition, setScrollPosition }) {
const navigate = useNavigate()
const scrollContainerRef = useRef(null)
const handleSearch = async (e) => {
e.preventDefault()
if (!searchQuery.trim()) return
setLoading(true)
setSearched(true)
try {
const response = await axios.get(`${API_BASE}/search`, {
params: { q: searchQuery, limit: 1000 }
})
setResults(response.data || [])
} catch (error) {
console.error('Error searching:', error)
setResults([])
} finally {
setLoading(false)
}
}
const handleResultClick = (result) => {
// Navigate to the conversation with the message ID as a query parameter
navigate(`/conversation/${encodeURIComponent(result.address)}?messageId=${result.message_id}`)
}
const formatPhoneNumber = (phoneNumber) => {
if (!phoneNumber) return ''
// Handle comma-separated numbers (group conversations)
if (phoneNumber.includes(',')) {
const numbers = phoneNumber.split(',').map(n => n.trim())
return numbers.map(n => formatSinglePhoneNumber(n)).join(', ')
}
return formatSinglePhoneNumber(phoneNumber)
}
const formatSinglePhoneNumber = (phoneNumber) => {
if (!phoneNumber) return ''
// Remove +1 prefix if present
const cleaned = phoneNumber.replace(/^\+1/, '')
// Format as (XXX) XXX-XXXX
if (cleaned.length === 10) {
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`
}
return phoneNumber
}
// Restore scroll position when component mounts or results change
useEffect(() => {
if (scrollContainerRef.current && scrollPosition > 0) {
scrollContainerRef.current.scrollTop = scrollPosition
}
}, [scrollPosition])
// Save scroll position when user scrolls
const handleScroll = (e) => {
if (e.target.scrollTop !== scrollPosition) {
setScrollPosition(e.target.scrollTop)
}
}
return (
<div className="h-100 d-flex flex-column">
{/* Header */}
<div className="bg-light border-bottom p-3">
<h2 className="h5 mb-3 d-flex align-items-center gap-2">
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Search Messages
</h2>
<form onSubmit={handleSearch}>
<div className="input-group">
<input
type="text"
className="form-control"
placeholder="Search message contents..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoFocus
/>
<button
type="submit"
className="btn btn-primary"
disabled={loading || !searchQuery.trim()}
>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Searching...
</>
) : (
'Search'
)}
</button>
</div>
</form>
{searched && !loading && (
<div className="mt-2 small text-muted">
{results.length > 0 ? (
<>
Found <strong>{results.length.toLocaleString()}</strong> result{results.length !== 1 ? 's' : ''}
{results.length >= 1000 && ' (limited to first 1000)'}
</>
) : (
'No results found'
)}
</div>
)}
</div>
{/* Results */}
<div ref={scrollContainerRef} onScroll={handleScroll} className="flex-fill overflow-auto p-3">
{!searched ? (
<div className="text-center text-muted py-5">
<svg style={{width: '4rem', height: '4rem'}} className="mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<p className="lead">Search for messages</p>
<p className="small">Enter a search term to find messages across all conversations</p>
</div>
) : loading ? (
<div className="text-center py-5">
<div className="spinner-border text-primary mb-3" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<p className="text-muted">Searching...</p>
</div>
) : results.length === 0 ? (
<div className="text-center text-muted py-5">
<svg style={{width: '4rem', height: '4rem'}} className="mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="lead">No results found</p>
<p className="small">Try a different search term</p>
</div>
) : (
<div className="row g-2">
{results.map((result) => (
<div key={result.message_id} className="col-12">
<div
className="card h-100 shadow-sm"
style={{ cursor: 'pointer', transition: 'all 0.2s' }}
onClick={() => handleResultClick(result)}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = '0 0.5rem 1rem rgba(0,0,0,0.15)'
e.currentTarget.style.transform = 'translateY(-2px)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = ''
e.currentTarget.style.transform = ''
}}
>
<div className="card-body">
<div className="d-flex justify-content-between align-items-start mb-2">
<div className="flex-fill">
<h6 className="card-title mb-1 fw-bold">
{result.contact_name || formatPhoneNumber(result.address) || 'Unknown'}
</h6>
{result.contact_name && (
<div className="small text-muted">
{formatPhoneNumber(result.address)}
</div>
)}
</div>
<small className="text-muted text-nowrap ms-2">
{format(new Date(result.date), 'MMM d, yyyy')}
</small>
</div>
<div
className="card-text small text-muted"
dangerouslySetInnerHTML={{ __html: result.snippet }}
style={{
wordBreak: 'break-word',
lineHeight: '1.5'
}}
/>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
export default Search
+358
View File
@@ -0,0 +1,358 @@
import { useState } from 'react'
import axios from 'axios'
import { Modal, Button, Form, Alert, Spinner, ProgressBar } from 'react-bootstrap'
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
function Upload({ onClose, onSuccess }) {
const [files, setFiles] = useState([])
const [uploading, setUploading] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(null)
const [progress, setProgress] = useState(null)
const [uploadProgress, setUploadProgress] = useState(0)
const [currentStep, setCurrentStep] = useState(1) // 1 = upload, 2 = processing
const [currentFileIndex, setCurrentFileIndex] = useState(0)
const [totalFiles, setTotalFiles] = useState(0)
const [isDragging, setIsDragging] = useState(false)
const handleFileChange = (e) => {
const selectedFiles = Array.from(e.target.files)
setFiles(selectedFiles)
setError(null)
setSuccess(null)
}
const handleDragEnter = (e) => {
e.preventDefault()
e.stopPropagation()
if (!uploading) {
setIsDragging(true)
}
}
const handleDragLeave = (e) => {
e.preventDefault()
e.stopPropagation()
// Only set dragging to false if we're leaving the drop zone itself
if (e.currentTarget === e.target) {
setIsDragging(false)
}
}
const handleDragOver = (e) => {
e.preventDefault()
e.stopPropagation()
}
const handleDrop = (e) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (uploading) return
const droppedFiles = Array.from(e.dataTransfer.files)
// Filter to only accept XML files
const xmlFiles = droppedFiles.filter(file => file.name.toLowerCase().endsWith('.xml'))
if (xmlFiles.length === 0) {
setError('Please drop only XML files')
return
}
if (xmlFiles.length < droppedFiles.length) {
setError(`Only ${xmlFiles.length} of ${droppedFiles.length} files are XML files. Non-XML files were ignored.`)
}
setFiles(xmlFiles)
setSuccess(null)
if (xmlFiles.length === droppedFiles.length) {
setError(null)
}
}
const handleUpload = async () => {
if (files.length === 0) {
setError('Please select at least one file')
return
}
setUploading(true)
setError(null)
setProgress(null)
setTotalFiles(files.length)
setCurrentFileIndex(0)
try {
// Process each file sequentially
for (let i = 0; i < files.length; i++) {
const file = files[i]
setCurrentFileIndex(i + 1)
await uploadSingleFile(file)
// If this was the last file, show success and close
if (i === files.length - 1) {
setSuccess(`Successfully imported all ${files.length} file${files.length !== 1 ? 's' : ''}`)
setTimeout(() => {
setUploading(false)
onSuccess()
}, 1500)
}
}
} catch (err) {
console.error('Upload error:', err)
if (err.code === 'ECONNABORTED') {
setError('Upload timeout. The file may be too large.')
} else {
setError(err.response?.data?.error || err.message || 'Upload failed')
}
setUploading(false)
}
}
const uploadSingleFile = async (file) => {
const formData = new FormData()
formData.append('file', file)
setUploadProgress(0)
setCurrentStep(1)
// Step 1: Upload file to server
const response = await axios.post(`${API_BASE}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
timeout: 300000, // 5 minute timeout for file upload
onUploadProgress: (progressEvent) => {
// This tracks the HTTP upload progress (file transfer to disk)
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
setUploadProgress(percentCompleted) // Show actual upload progress 0-100%
},
})
if (!response.data.success) {
throw new Error(response.data.error || 'Upload failed')
}
// File uploaded successfully, move to step 2
setUploadProgress(0) // Reset for processing step
setCurrentStep(2)
// Wait for processing to complete
await waitForProcessingComplete()
}
const waitForProcessingComplete = () => {
return new Promise((resolve, reject) => {
const checkProgress = setInterval(async () => {
try {
const response = await axios.get(`${API_BASE}/progress`)
const data = response.data
if (!data || data.status === 'no_upload') {
clearInterval(checkProgress)
// Reject instead of resolve so the error is caught by handleUpload
reject(new Error('Processing status unavailable'))
return
}
setProgress(data)
// Calculate processing progress (0-100% for step 2)
const total = data.total_messages || 1
const processed = data.processed_messages || 0
const processingPercent = Math.min(Math.round((processed / total) * 100), 100)
setUploadProgress(processingPercent)
// Check if completed
if (data.status === 'completed') {
clearInterval(checkProgress)
setUploadProgress(100)
// Just resolve - don't call onSuccess() here since we're processing multiple files
// The main handleUpload() function will handle success after all files are done
resolve()
} else if (data.status === 'error') {
clearInterval(checkProgress)
// Reject instead of resolve so the error is caught by handleUpload
reject(new Error(data.error_message || 'Processing failed'))
}
} catch (err) {
console.error('Error checking progress:', err)
}
}, 500) // Check every 500ms for more responsive updates
})
}
return (
<Modal show={true} onHide={onClose} centered backdrop="static" keyboard={!uploading}>
<Modal.Header closeButton={!uploading}>
<Modal.Title className="h4 fw-bold">Upload Backup</Modal.Title>
</Modal.Header>
<Modal.Body>
<div className="mb-3">
<div className="d-flex align-items-center gap-2 text-muted mb-3">
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<small>Select or drag and drop one or more XML files from SMS Backup & Restore app</small>
</div>
<Form.Group>
<div
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{
border: isDragging ? '2px dashed #0d6efd' : '2px dashed #dee2e6',
borderRadius: '0.375rem',
padding: '2rem 1rem',
textAlign: 'center',
backgroundColor: isDragging ? '#f0f7ff' : '#f8f9fa',
transition: 'all 0.2s ease',
cursor: uploading ? 'not-allowed' : 'pointer',
opacity: uploading ? 0.6 : 1
}}
>
<div className="mb-3">
<svg
style={{width: '3rem', height: '3rem'}}
className={isDragging ? "text-primary" : "text-muted"}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
<div className={isDragging ? "text-primary fw-semibold" : "text-muted"}>
{isDragging ? (
<div>Drop XML files here</div>
) : (
<div>
<div className="mb-2">Drag and drop XML files here</div>
<div className="text-muted small">or</div>
</div>
)}
</div>
<div className="mt-3">
<Form.Control
type="file"
accept=".xml"
onChange={handleFileChange}
disabled={uploading}
multiple
style={{
maxWidth: '250px',
margin: '0 auto'
}}
/>
</div>
</div>
{files.length > 0 && !uploading && (
<div className="mt-3">
<Form.Text className="text-success d-flex align-items-center gap-1">
<svg style={{width: '1rem', height: '1rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{files.length} file{files.length !== 1 ? 's' : ''} selected
</Form.Text>
<div className="mt-2" style={{maxHeight: '150px', overflowY: 'auto'}}>
{files.map((file, index) => (
<div key={index} className="small text-muted">
{index + 1}. {file.name} ({(file.size / (1024 * 1024)).toFixed(2)} MB)
</div>
))}
</div>
</div>
)}
</Form.Group>
</div>
{uploading && (
<div className="mb-3">
{totalFiles > 1 && (
<div className="mb-2">
<small className="text-muted fw-semibold">
Processing file {currentFileIndex} of {totalFiles}
</small>
</div>
)}
<div className="d-flex justify-content-between align-items-center mb-2">
<small className="text-muted fw-semibold">
Step {currentStep} of 2: {currentStep === 1 ? 'Uploading file' : 'Processing messages'}
</small>
<small className="text-muted fw-bold">{uploadProgress}%</small>
</div>
<ProgressBar
now={uploadProgress}
variant={uploadProgress === 100 && currentStep === 2 ? "success" : "primary"}
striped={!(uploadProgress === 100 && currentStep === 2)}
animated={!(uploadProgress === 100 && currentStep === 2)}
/>
{currentStep === 1 && files[currentFileIndex - 1] && (
<small className="text-muted mt-2 d-block">
Uploading {files[currentFileIndex - 1].name} ({(files[currentFileIndex - 1].size / (1024 * 1024)).toFixed(2)} MB) to server...
</small>
)}
{currentStep === 2 && progress && (
<small className="text-muted mt-2 d-block">
{progress.processed_messages?.toLocaleString() || 0} / {progress.total_messages?.toLocaleString() || '?'} messages imported
{progress.processed_calls > 0 && `, ${progress.processed_calls?.toLocaleString()} calls`}
</small>
)}
{currentStep === 2 && !progress && (
<small className="text-muted mt-2 d-block">
Starting import process...
</small>
)}
</div>
)}
{error && (
<Alert variant="danger" className="d-flex align-items-center gap-2">
<svg style={{width: '1.25rem', height: '1.25rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{error}
</Alert>
)}
{success && (
<Alert variant="success" className="d-flex align-items-center gap-2">
<svg style={{width: '1.25rem', height: '1.25rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{success}
</Alert>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose} disabled={uploading}>
Cancel
</Button>
<Button variant="primary" onClick={handleUpload} disabled={uploading || files.length === 0}>
{uploading ? (
<>
<Spinner
as="span"
animation="border"
size="sm"
role="status"
aria-hidden="true"
className="me-2"
/>
Uploading...
</>
) : 'Upload'}
</Button>
</Modal.Footer>
</Modal>
)
}
export default Upload
+239
View File
@@ -0,0 +1,239 @@
import React, { useState, useEffect } from 'react'
import { parseVCard, formatAddress, formatBirthday } from '../utils/vcfParser'
/**
* VCardPreview component for displaying vCard (contact) files
*/
function VCardPreview({ vcfText, messageId }) {
const [contact, setContact] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
const loadVCard = () => {
try {
setLoading(true)
setError(null)
if (!vcfText) {
throw new Error('No VCF data provided')
}
const parsedContact = parseVCard(vcfText)
setContact(parsedContact)
} catch (err) {
console.error('Error loading vCard:', err)
setError(err.message)
} finally {
setLoading(false)
}
}
if (vcfText) {
loadVCard()
}
}, [vcfText])
const handleDownload = () => {
try {
// Create blob from VCF text
const blob = new Blob([vcfText], { type: 'text/vcard' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${contact?.name || 'contact'}.vcf`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (err) {
console.error('Error downloading vCard:', err)
}
}
if (loading) {
return (
<div className="card shadow-sm" style={{ maxWidth: '400px' }}>
<div className="card-body text-center">
<div className="spinner-border spinner-border-sm text-primary" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<p className="text-muted small mb-0 mt-2">Loading contact...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="card shadow-sm border-danger" style={{ maxWidth: '400px' }}>
<div className="card-body">
<div className="text-danger small">
<svg className="bi me-1" width="16" height="16" fill="currentColor">
<use xlinkHref="#exclamation-triangle-fill" />
</svg>
Error loading contact: {error}
</div>
</div>
</div>
)
}
if (!contact) {
return null
}
return (
<div className="card shadow-sm" style={{ maxWidth: '400px' }}>
<div className="card-body">
{/* Header with photo and name */}
<div className="d-flex align-items-center mb-3">
{contact.photo ? (
<img
src={contact.photo}
alt={contact.name}
className="rounded-circle me-3"
style={{ width: '64px', height: '64px', objectFit: 'cover' }}
/>
) : (
<div
className="rounded-circle bg-primary text-white d-flex align-items-center justify-content-center me-3"
style={{ width: '64px', height: '64px', fontSize: '24px', fontWeight: 'bold' }}
>
{contact.name ? contact.name.charAt(0).toUpperCase() : '?'}
</div>
)}
<div className="flex-grow-1">
<h5 className="card-title mb-1">{contact.name || contact.formattedName || 'Unknown Contact'}</h5>
{contact.title && <p className="text-muted small mb-0">{contact.title}</p>}
{contact.organization && <p className="text-muted small mb-0">{contact.organization}</p>}
</div>
</div>
{/* Contact Details */}
<div className="vcard-details">
{/* Phone Numbers */}
{contact.phoneNumbers.length > 0 && (
<div className="mb-3">
<div className="small text-muted fw-bold mb-1">
<svg className="bi me-1" width="14" height="14" fill="currentColor">
<path d="M3.654 1.328a.678.678 0 0 0-1.015-.063L1.605 2.3c-.483.484-.661 1.169-.45 1.77a17.568 17.568 0 0 0 4.168 6.608 17.569 17.569 0 0 0 6.608 4.168c.601.211 1.286.033 1.77-.45l1.034-1.034a.678.678 0 0 0-.063-1.015l-2.307-1.794a.678.678 0 0 0-.58-.122l-2.19.547a1.745 1.745 0 0 1-1.657-.459L5.482 8.062a1.745 1.745 0 0 1-.46-1.657l.548-2.19a.678.678 0 0 0-.122-.58L3.654 1.328zM1.884.511a1.745 1.745 0 0 1 2.612.163L6.29 2.98c.329.423.445.974.315 1.494l-.547 2.19a.678.678 0 0 0 .178.643l2.457 2.457a.678.678 0 0 0 .644.178l2.189-.547a1.745 1.745 0 0 1 1.494.315l2.306 1.794c.829.645.905 1.87.163 2.611l-1.034 1.034c-.74.74-1.846 1.065-2.877.702a18.634 18.634 0 0 1-7.01-4.42 18.634 18.634 0 0 1-4.42-7.009c-.362-1.03-.037-2.137.703-2.877L1.885.511z"/>
</svg>
Phone
</div>
{contact.phoneNumbers.map((phone, index) => (
<div key={index} className="small mb-1">
<span className="text-muted">{phone.type}:</span>{' '}
<a href={`tel:${phone.number}`} className="text-decoration-none">
{phone.number}
</a>
</div>
))}
</div>
)}
{/* Email Addresses */}
{contact.emails.length > 0 && (
<div className="mb-3">
<div className="small text-muted fw-bold mb-1">
<svg className="bi me-1" width="14" height="14" fill="currentColor">
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4Zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2Zm13 2.383-4.708 2.825L15 11.105V5.383Zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741ZM1 11.105l4.708-2.897L1 5.383v5.722Z"/>
</svg>
Email
</div>
{contact.emails.map((email, index) => (
<div key={index} className="small mb-1">
<span className="text-muted">{email.type}:</span>{' '}
<a href={`mailto:${email.address}`} className="text-decoration-none">
{email.address}
</a>
</div>
))}
</div>
)}
{/* Addresses */}
{contact.addresses.length > 0 && (
<div className="mb-3">
<div className="small text-muted fw-bold mb-1">
<svg className="bi me-1" width="14" height="14" fill="currentColor">
<path d="M8.707 1.5a1 1 0 0 0-1.414 0L.646 8.146a.5.5 0 0 0 .708.708L2 8.207V13.5A1.5 1.5 0 0 0 3.5 15h9a1.5 1.5 0 0 0 1.5-1.5V8.207l.646.647a.5.5 0 0 0 .708-.708L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.707 1.5ZM13 7.207V13.5a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5V7.207l5-5 5 5Z"/>
</svg>
Address
</div>
{contact.addresses.map((addr, index) => {
const formatted = formatAddress(addr)
return formatted ? (
<div key={index} className="small mb-1">
<span className="text-muted">{addr.type}:</span> {formatted}
</div>
) : null
})}
</div>
)}
{/* Birthday */}
{contact.birthday && (
<div className="mb-3">
<div className="small text-muted fw-bold mb-1">
<svg className="bi me-1" width="14" height="14" fill="currentColor">
<path d="M4 .5a.5.5 0 0 0-1 0V1H2a2 2 0 0 0-2 2v1h16V3a2 2 0 0 0-2-2h-1V.5a.5.5 0 0 0-1 0V1H4V.5zM16 14V5H0v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2zm-3.5-7h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5z"/>
</svg>
Birthday
</div>
<div className="small">{formatBirthday(contact.birthday)}</div>
</div>
)}
{/* URL */}
{contact.url && (
<div className="mb-3">
<div className="small text-muted fw-bold mb-1">
<svg className="bi me-1" width="14" height="14" fill="currentColor">
<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/>
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243L6.586 4.672z"/>
</svg>
Website
</div>
<div className="small">
<a href={contact.url} target="_blank" rel="noopener noreferrer" className="text-decoration-none">
{contact.url}
</a>
</div>
</div>
)}
{/* Note */}
{contact.note && (
<div className="mb-3">
<div className="small text-muted fw-bold mb-1">
<svg className="bi me-1" width="14" height="14" fill="currentColor">
<path d="M5 0h8a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2 2 2 0 0 1-2 2H3a2 2 0 0 1-2-2h1a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1H1a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v9a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1H3a2 2 0 0 1 2-2z"/>
<path d="M1 6v-.5a.5.5 0 0 1 1 0V6h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1H1zm0 3v-.5a.5.5 0 0 1 1 0V9h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1H1zm0 2.5v.5H.5a.5.5 0 0 0 0 1h2a.5.5 0 0 0 0-1H2v-.5a.5.5 0 0 0-1 0z"/>
</svg>
Note
</div>
<div className="small text-muted">{contact.note}</div>
</div>
)}
</div>
{/* Download Button */}
<div className="d-grid">
<button
className="btn btn-sm btn-outline-primary"
onClick={handleDownload}
>
<svg className="bi me-1" width="14" height="14" fill="currentColor">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
</svg>
Download Contact
</button>
</div>
</div>
</div>
)
}
export default VCardPreview
+132
View File
@@ -0,0 +1,132 @@
import { createContext, useContext, useState, useEffect } from 'react'
import axios from 'axios'
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
// Check if user is already authenticated on mount
useEffect(() => {
checkAuth()
}, [])
const checkAuth = async () => {
try {
const response = await axios.get(`${API_BASE}/auth/me`, {
withCredentials: true
})
if (response.data.success) {
setUser(response.data.user)
}
} catch (error) {
// Not authenticated, that's okay
setUser(null)
} finally {
setLoading(false)
}
}
const login = async (username, password) => {
setError(null)
try {
const response = await axios.post(
`${API_BASE}/auth/login`,
{ username, password },
{ withCredentials: true }
)
if (response.data.success) {
setUser(response.data.user)
return { success: true }
} else {
setError(response.data.error || 'Login failed')
return { success: false, error: response.data.error }
}
} catch (error) {
const errorMsg = error.response?.data?.error || 'Login failed. Please try again.'
setError(errorMsg)
return { success: false, error: errorMsg }
}
}
const register = async (username, password) => {
setError(null)
try {
const response = await axios.post(
`${API_BASE}/auth/register`,
{ username, password },
{ withCredentials: true }
)
if (response.data.success) {
setUser(response.data.user)
return { success: true }
} else {
setError(response.data.error || 'Registration failed')
return { success: false, error: response.data.error }
}
} catch (error) {
const errorMsg = error.response?.data?.error || 'Registration failed. Please try again.'
setError(errorMsg)
return { success: false, error: errorMsg }
}
}
const logout = async () => {
try {
await axios.post(`${API_BASE}/auth/logout`, {}, { withCredentials: true })
} catch (error) {
console.error('Logout error:', error)
} finally {
setUser(null)
}
}
const changePassword = async (oldPassword, newPassword, confirmPassword) => {
setError(null)
try {
const response = await axios.post(
`${API_BASE}/auth/change-password`,
{ old_password: oldPassword, new_password: newPassword, confirm_password: confirmPassword },
{ withCredentials: true }
)
if (response.data.success) {
return { success: true }
} else {
setError(response.data.error || 'Password change failed')
return { success: false, error: response.data.error }
}
} catch (error) {
const errorMsg = error.response?.data?.error || 'Password change failed. Please try again.'
setError(errorMsg)
return { success: false, error: errorMsg }
}
}
const value = {
user,
loading,
error,
login,
register,
logout,
changePassword,
isAuthenticated: !!user
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
+13
View File
@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
+30
View File
@@ -0,0 +1,30 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import axios from 'axios'
import 'bootstrap/dist/css/bootstrap.min.css'
import './index.css'
import { AuthProvider } from './contexts/AuthContext'
import App from './App.jsx'
import Login from './components/Login.jsx'
import ProtectedRoute from './components/ProtectedRoute.jsx'
// Configure axios to include credentials with all requests
axios.defaults.withCredentials = true
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/*" element={
<ProtectedRoute>
<App />
</ProtectedRoute>
} />
</Routes>
</AuthProvider>
</BrowserRouter>
</StrictMode>,
)
+313
View File
@@ -0,0 +1,313 @@
/**
* Parse vCard (VCF) format and extract contact information
* Supports vCard 2.1, 3.0, and 4.0 formats
*/
/**
* Parse a vCard file and return structured contact data
* @param {string} vcfText - The raw vCard text content
* @returns {Object} Parsed contact information
*/
export function parseVCard(vcfText) {
const contact = {
version: '',
name: '',
formattedName: '',
phoneNumbers: [],
emails: [],
addresses: [],
organization: '',
title: '',
photo: null,
birthday: '',
url: '',
note: ''
}
// Split into lines and handle line folding (continuation lines starting with space/tab)
const lines = unfoldLines(vcfText)
for (const line of lines) {
const [property, value] = parseVCardLine(line)
if (!property) continue
const { name, params } = parseProperty(property)
switch (name.toUpperCase()) {
case 'VERSION':
contact.version = value
break
case 'FN':
contact.formattedName = decodeValue(value, params)
break
case 'N':
// N:LastName;FirstName;MiddleName;Prefix;Suffix
const nameParts = value.split(';').map(p => decodeValue(p, params))
if (!contact.name) {
contact.name = [nameParts[3], nameParts[1], nameParts[2], nameParts[0], nameParts[4]]
.filter(p => p)
.join(' ')
}
break
case 'TEL':
contact.phoneNumbers.push({
type: getTypeLabel(params, 'phone'),
number: value
})
break
case 'EMAIL':
contact.emails.push({
type: getTypeLabel(params, 'email'),
address: value
})
break
case 'ADR':
// ADR:;;Street;City;State;ZIP;Country
const adrParts = value.split(';').map(p => decodeValue(p, params))
const address = {
type: getTypeLabel(params, 'address'),
street: adrParts[2],
city: adrParts[3],
state: adrParts[4],
zip: adrParts[5],
country: adrParts[6]
}
contact.addresses.push(address)
break
case 'ORG':
contact.organization = decodeValue(value, params)
break
case 'TITLE':
contact.title = decodeValue(value, params)
break
case 'PHOTO':
contact.photo = parsePhoto(value, params)
break
case 'BDAY':
contact.birthday = value
break
case 'URL':
contact.url = value
break
case 'NOTE':
contact.note = decodeValue(value, params)
break
}
}
// Use formatted name if name is empty
if (!contact.name && contact.formattedName) {
contact.name = contact.formattedName
}
return contact
}
/**
* Unfold lines (handle line continuation in vCard format)
*/
function unfoldLines(text) {
const lines = text.split(/\r?\n/)
const unfolded = []
let current = ''
for (const line of lines) {
// Line continuation: starts with space or tab
if (line.startsWith(' ') || line.startsWith('\t')) {
current += line.substring(1)
} else {
if (current) {
unfolded.push(current)
}
current = line
}
}
if (current) {
unfolded.push(current)
}
return unfolded
}
/**
* Parse a vCard line into property and value
*/
function parseVCardLine(line) {
const colonIndex = line.indexOf(':')
if (colonIndex === -1) return [null, null]
const property = line.substring(0, colonIndex)
const value = line.substring(colonIndex + 1)
return [property, value]
}
/**
* Parse property name and parameters
* Example: "TEL;TYPE=CELL;PREF=1" => { name: "TEL", params: { TYPE: "CELL", PREF: "1" } }
*/
function parseProperty(property) {
const parts = property.split(';')
const name = parts[0]
const params = {}
for (let i = 1; i < parts.length; i++) {
const param = parts[i]
const eqIndex = param.indexOf('=')
if (eqIndex === -1) {
// vCard 2.1 style: TYPE without =
params['TYPE'] = params['TYPE'] ? params['TYPE'] + ',' + param : param
} else {
const paramName = param.substring(0, eqIndex)
const paramValue = param.substring(eqIndex + 1).replace(/^"(.*)"$/, '$1')
params[paramName.toUpperCase()] = paramValue
}
}
return { name, params }
}
/**
* Get human-readable type label from parameters
*/
function getTypeLabel(params, context) {
if (!params.TYPE) {
return context === 'phone' ? 'Phone' : context === 'email' ? 'Email' : 'Address'
}
const types = params.TYPE.split(',').map(t => t.toUpperCase())
// Common type mappings
const typeMap = {
'CELL': 'Mobile',
'HOME': 'Home',
'WORK': 'Work',
'VOICE': 'Phone',
'FAX': 'Fax',
'PAGER': 'Pager',
'MSG': 'Message',
'PREF': 'Preferred',
'INTERNET': 'Email'
}
const labels = types
.map(t => typeMap[t] || t.charAt(0) + t.substring(1).toLowerCase())
.filter(l => l !== 'Internet') // Remove generic Internet label
return labels.join(', ') || (context === 'phone' ? 'Phone' : context === 'email' ? 'Email' : 'Address')
}
/**
* Decode value based on encoding parameter
*/
function decodeValue(value, params) {
if (!params.ENCODING) return value
const encoding = params.ENCODING.toUpperCase()
if (encoding === 'QUOTED-PRINTABLE') {
return decodeQuotedPrintable(value)
}
return value
}
/**
* Decode quoted-printable encoding
*/
function decodeQuotedPrintable(str) {
return str
.replace(/=\r?\n/g, '') // Remove soft line breaks
.replace(/=([0-9A-F]{2})/gi, (match, hex) => String.fromCharCode(parseInt(hex, 16)))
}
/**
* Parse photo data from vCard
*/
function parsePhoto(value, params) {
const encoding = params.ENCODING ? params.ENCODING.toUpperCase() : ''
const type = params.TYPE || params.MEDIATYPE || 'JPEG'
if (encoding === 'BASE64' || encoding === 'B') {
// Remove whitespace from base64 data
const base64Data = value.replace(/\s/g, '')
// Determine MIME type
let mimeType = 'image/jpeg'
const typeUpper = type.toUpperCase()
if (typeUpper.includes('PNG')) {
mimeType = 'image/png'
} else if (typeUpper.includes('GIF')) {
mimeType = 'image/gif'
} else if (typeUpper.includes('BMP')) {
mimeType = 'image/bmp'
}
return `data:${mimeType};base64,${base64Data}`
}
// URL reference
if (value.startsWith('http://') || value.startsWith('https://')) {
return value
}
return null
}
/**
* Format address object to string
*/
export function formatAddress(address) {
const parts = [
address.street,
address.city,
address.state && address.zip ? `${address.state} ${address.zip}` : address.state || address.zip,
address.country
].filter(p => p)
return parts.join(', ')
}
/**
* Format birthday to readable format
*/
export function formatBirthday(birthday) {
if (!birthday) return ''
// Handle different date formats
// YYYYMMDD, YYYY-MM-DD, or --MMDD (no year)
if (birthday.startsWith('--')) {
const month = birthday.substring(2, 4)
const day = birthday.substring(4, 6)
return `${month}/${day}`
}
if (birthday.includes('-')) {
const [year, month, day] = birthday.split('-')
return `${month}/${day}/${year}`
}
if (birthday.length === 8) {
const year = birthday.substring(0, 4)
const month = birthday.substring(4, 6)
const day = birthday.substring(6, 8)
return `${month}/${day}/${year}`
}
return birthday
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})