From 3b29b9dca256453e71cb4d710fd9890f4ba26984 Mon Sep 17 00:00:00 2001 From: rocord01 Date: Sun, 10 Aug 2025 01:39:04 -0400 Subject: [PATCH] VVM update (real) --- src/components/dashboard/dashboard-client.jsx | 2 + src/components/dashboard/voicemail.jsx | 439 ++++++++++++++++++ 2 files changed, 441 insertions(+) create mode 100644 src/components/dashboard/voicemail.jsx diff --git a/src/components/dashboard/dashboard-client.jsx b/src/components/dashboard/dashboard-client.jsx index 18e837f..3a570dc 100644 --- a/src/components/dashboard/dashboard-client.jsx +++ b/src/components/dashboard/dashboard-client.jsx @@ -12,6 +12,7 @@ import { ActiveCalls } from './active-calls'; import { SipCredentialsCard } from './SipCredentialsCard'; import { QuickActionsCard } from './QuickActionsCard'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { VoicemailPanel } from './voicemail'; function OverviewCard({ details, deviceStatus, loading }) { const Stat = ({ icon, label, value, loading }) => ( @@ -229,6 +230,7 @@ export default function DashboardClient() {
+
diff --git a/src/components/dashboard/voicemail.jsx b/src/components/dashboard/voicemail.jsx new file mode 100644 index 0000000..de4565b --- /dev/null +++ b/src/components/dashboard/voicemail.jsx @@ -0,0 +1,439 @@ +"use client"; + +import { useEffect, useState, useCallback, useMemo } from "react"; +import { useAuth } from "@/contexts/AuthContext"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"; +import { RefreshCw, Inbox, Folder, Download, Play, Pause, Loader2, PhoneIncoming, PhoneCall, Trash2 } from "lucide-react"; +import { format } from "date-fns"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from "@/components/ui/alert-dialog"; + +const FOLDERS = ['INBOX', 'Family', 'Friends', 'Old', 'Work', 'Urgent']; +const FRIENDLY_FOLDER = (f) => (f === 'INBOX' ? 'New' : f); + +function formatDuration(sec) { + if (isNaN(sec)) return '0:00'; + const m = Math.floor(sec / 60); + const s = Math.floor(sec % 60).toString().padStart(2, '0'); + return `${m}:${s}`; +} + +function formatBytes(bytes) { + if (!bytes && bytes !== 0) return '—'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; +} + +function formatVoicemailDate(dateStr) { + try { + return format(new Date(dateStr), "MMM d, yyyy 'at' h:mm a"); + } catch { + return dateStr; + } +} + +export function VoicemailPanel({ extensionId: extProp }) { + const { token } = useAuth(); + const [extensionId, setExtensionId] = useState(extProp || null); + const [loadingExt, setLoadingExt] = useState(!extProp); + const [loading, setLoading] = useState(true); + const [voicemails, setVoicemails] = useState([]); + const [activeFolder, setActiveFolder] = useState('INBOX'); + const [search, setSearch] = useState(''); + const [expandedId, setExpandedId] = useState(null); + const [audioMap, setAudioMap] = useState({}); // messageId -> { url, loading, error } + const [movingId, setMovingId] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [vmToDelete, setVmToDelete] = useState(null); + + useEffect(() => { + if (extProp || !token) return; + const load = async () => { + setLoadingExt(true); + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/me`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (res.ok) { + const data = await res.json(); + setExtensionId(data.extensionId); + } + } catch (e) { + // silent + } finally { + setLoadingExt(false); + } + }; + load(); + }, [token, extProp]); + + const fetchVoicemails = useCallback(async () => { + if (!token || !extensionId) return; + setRefreshing(true); + if (voicemails.length === 0) setLoading(true); + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/${extensionId}/voicemails`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (res.ok) { + const data = await res.json(); + setVoicemails(Array.isArray(data) ? data : []); + } else { + setVoicemails([]); + } + } catch (e) { + setVoicemails([]); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [token, extensionId, voicemails.length]); + + useEffect(() => { + if (extensionId) fetchVoicemails(); + }, [extensionId, fetchVoicemails]); + + const filtered = useMemo(() => { + return voicemails + .filter(v => v.folder === activeFolder) + .filter(v => { + if (!search) return true; + const t = search.toLowerCase(); + return ( + v.callerIdName?.toLowerCase().includes(t) || + v.callerIdNum?.toLowerCase().includes(t) || + v.messageId?.toLowerCase().includes(t) + ); + }) + .sort((a, b) => new Date(b.date) - new Date(a.date)); + }, [voicemails, activeFolder, search]); + + const toggleExpand = async (vm) => { + const isOpening = expandedId !== vm.messageId; + setExpandedId(isOpening ? vm.messageId : null); + if (isOpening && vm.hasAudio && !audioMap[vm.messageId]?.url) { + setAudioMap(m => ({ ...m, [vm.messageId]: { url: null, loading: true } })); + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/${extensionId}/voicemails/${vm.messageId}/download`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (!res.ok) throw new Error('Download failed'); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + setAudioMap(m => ({ ...m, [vm.messageId]: { url, loading: false } })); + } catch { + setAudioMap(m => ({ ...m, [vm.messageId]: { url: null, loading: false, error: true } })); + } + } + }; + + const moveVoicemail = async (vm, target) => { + if (target === vm.folder) return; + setMovingId(vm.messageId); + try { + await fetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/${extensionId}/voicemails/${vm.messageId}/move`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ targetFolder: target }) + }); + setVoicemails(list => list.map(x => x.messageId === vm.messageId ? { ...x, folder: target } : x)); + if (target !== activeFolder) { + setExpandedId(null); + } + } catch { + // silent fail + } finally { + setMovingId(null); + } + }; + + const downloadVoicemail = async (vm) => { + if (!vm.hasAudio) return; + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/${extensionId}/voicemails/${vm.messageId}/download`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (!res.ok) return; + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${vm.messageId}.wav`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch { + // ignore + } + }; + + const handleDelete = async () => { + if (!vmToDelete || !token || !extensionId) return; + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/${extensionId}/voicemails/${vmToDelete.messageId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }); + if (res.ok) { + setVoicemails(list => list.filter(v => v.messageId !== vmToDelete.messageId)); + if (expandedId === vmToDelete.messageId) setExpandedId(null); + } + } catch { + // silent + } finally { + setVmToDelete(null); + } + }; + + const loadingState = loading || loadingExt; + + return ( + <> + + +
+
+ Voicemail (VVM) + + {extensionId ? `Extension ${extensionId} visual voicemail` : 'Loading extension...'} + +
+
+ +
+
+
+ {FOLDERS.map(folder => { + const count = voicemails.filter(v => v.folder === folder).length; + const active = folder === activeFolder; + return ( + + ); + })} +
+
+ setSearch(e.target.value)} + className="bg-gray-900 border-gray-700" + disabled={loadingState} + /> +
+
+ + {loadingState ? ( +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ ) : filtered.length === 0 ? ( +
+ No voicemails in {FRIENDLY_FOLDER(activeFolder)}{search && ' matching your search'}. +
+ ) : ( +
+ {filtered.map(vm => { + const expanded = expandedId === vm.messageId; + const audioInfo = audioMap[vm.messageId]; + return ( +
+
+ + {/* Actions */} +
+ + + + + {movingId === vm.messageId && ( + + )} +
+
+ {expanded && ( +
+ {/* Audio / status */} + {vm.hasAudio ? ( + audioInfo?.loading ? ( +
+ + Loading audio... +
+ ) : audioInfo?.error ? ( +
+ Failed to load audio. +
+ ) : audioInfo?.url ? ( +
+ )} +
+ ); + })} +
+ )} +
+
+ !open && setVmToDelete(null)}> + + + Delete voicemail? + + This permanently deletes the voicemail from{' '} + + {vmToDelete?.callerIdName || 'Unknown Caller'} + {' '} + ({vmToDelete?.callerIdNum || 'No Number'}). This action cannot be undone. + + + + + Cancel + + + Delete + + + + + + ); +} + +export default VoicemailPanel;