diff --git a/client/package-lock.json b/client/package-lock.json index fbba527..193f748 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -11,6 +11,7 @@ "@tailwindcss/vite": "^4.1.16", "axios": "^1.13.2", "chart.js": "^4.5.1", + "date-fns": "^4.4.0", "lucide-react": "^0.548.0", "react": "^19.1.1", "react-chartjs-2": "^5.3.1", @@ -2233,6 +2234,16 @@ "node": ">=12" } }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/client/package.json b/client/package.json index b7675a9..a254012 100644 --- a/client/package.json +++ b/client/package.json @@ -13,6 +13,7 @@ "@tailwindcss/vite": "^4.1.16", "axios": "^1.13.2", "chart.js": "^4.5.1", + "date-fns": "^4.4.0", "lucide-react": "^0.548.0", "react": "^19.1.1", "react-chartjs-2": "^5.3.1", diff --git a/client/src/App.jsx b/client/src/App.jsx index f21bdf5..d4eaa63 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -23,6 +23,7 @@ import Summaries from "./pages/Summaries.jsx"; import Reports from "./pages/Reports.jsx"; import AiSearch from "./pages/AiSearch.jsx"; import MeetingRoom from "./pages/MeetingRoom.jsx"; +import MeetingDetails from "./pages/MeetingDetails.jsx"; // --- Components --- import ProtectedRoute from "./components/ProtectedRoute.jsx"; @@ -210,6 +211,15 @@ const App = () => { } /> + + + + } + /> + {/* ✅ Fallback route — send unknown routes to Home */} } /> diff --git a/client/src/components/meeting-details/MeetingActions.jsx b/client/src/components/meeting-details/MeetingActions.jsx new file mode 100644 index 0000000..ca1d519 --- /dev/null +++ b/client/src/components/meeting-details/MeetingActions.jsx @@ -0,0 +1,261 @@ +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +const MeetingActions = ({ meeting, onDelete, onRename }) => { + const navigate = useNavigate(); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showRenameModal, setShowRenameModal] = useState(false); + const [newTitle, setNewTitle] = useState(""); + + if (!meeting) return null; + + const handleDownloadTranscript = () => { + if (!meeting.transcript) { + alert("No transcript available to download."); + return; + } + + const blob = new Blob([meeting.transcript], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${meeting.title || "meeting"}-transcript.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const handleDownloadSummary = () => { + const content = + meeting.summary || JSON.stringify(meeting.structuredMoM, null, 2); + if (!content) { + alert("No summary available to download."); + return; + } + + const blob = new Blob([content], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${meeting.title || "meeting"}-summary.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const handleRename = () => { + setNewTitle(meeting.title || ""); + setShowRenameModal(true); + }; + + const confirmRename = () => { + if (newTitle.trim()) { + onRename(meeting._id, newTitle.trim()); + setShowRenameModal(false); + } + }; + + const handleDelete = () => { + setShowDeleteModal(true); + }; + + const confirmDelete = () => { + onDelete(meeting._id); + setShowDeleteModal(false); + }; + + const handleBack = () => { + navigate("/summaries"); + }; + + return ( + <> +
+

+ + + + Quick Actions +

+ +
+ + + + + + + +
+ + +
+ + {/* Delete Confirmation Modal */} + {showDeleteModal && ( +
+
+

+ Delete Meeting +

+

+ Are you sure you want to delete this meeting? This action cannot + be undone. +

+
+ + +
+
+
+ )} + + {/* Rename Modal */} + {showRenameModal && ( +
+
+

+ Rename Meeting +

+ setNewTitle(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 mb-4" + placeholder="Enter new title" + autoFocus + /> +
+ + +
+
+
+ )} + + ); +}; + +export default MeetingActions; diff --git a/client/src/components/meeting-details/MeetingHeader.jsx b/client/src/components/meeting-details/MeetingHeader.jsx new file mode 100644 index 0000000..f2ed83a --- /dev/null +++ b/client/src/components/meeting-details/MeetingHeader.jsx @@ -0,0 +1,113 @@ +import React from "react"; +import { format } from "date-fns"; + +const MeetingHeader = ({ meeting }) => { + if (!meeting) return null; + + const formatDate = (dateString) => { + if (!dateString) return "N/A"; + try { + return format(new Date(dateString), "MMM dd, yyyy"); + } catch { + return "N/A"; + } + }; + + const formatDuration = (minutes) => { + if (!minutes) return "N/A"; + if (minutes < 60) return `${minutes} min`; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; + }; + + const getStatusColor = (status) => { + switch (status) { + case "completed": + return "bg-green-100 text-green-800"; + case "processing": + return "bg-yellow-100 text-yellow-800"; + case "uploaded": + return "bg-blue-100 text-blue-800"; + case "failed": + return "bg-red-100 text-red-800"; + default: + return "bg-gray-100 text-gray-800"; + } + }; + + return ( +
+
+
+

+ {meeting.title || "Untitled Meeting"} +

+
+ + + + + {formatDate(meeting.date)} + + + + + + {formatDuration(meeting.duration)} + + + + + + {meeting.meetingType || "conference"} + +
+
+
+ + {meeting.status || "uploaded"} + +
+
+ + {meeting.description && ( +

{meeting.description}

+ )} +
+ ); +}; + +export default MeetingHeader; diff --git a/client/src/components/meeting-details/MeetingMetadata.jsx b/client/src/components/meeting-details/MeetingMetadata.jsx new file mode 100644 index 0000000..50947d3 --- /dev/null +++ b/client/src/components/meeting-details/MeetingMetadata.jsx @@ -0,0 +1,222 @@ +import React from "react"; +import { format } from "date-fns"; + +const MeetingMetadata = ({ meeting }) => { + if (!meeting) return null; + + const formatDate = (dateString) => { + if (!dateString) return "N/A"; + try { + return format(new Date(dateString), "MMM dd, yyyy 'at' h:mm a"); + } catch { + return "N/A"; + } + }; + + const formatDuration = (minutes) => { + if (!minutes) return "N/A"; + if (minutes < 60) return `${minutes} minutes`; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; + }; + + const metadataItems = [ + { + label: "Organization", + value: meeting.organization || "Not specified", + icon: ( + + + + ), + }, + { + label: "Created By", + value: meeting.uploadedBy || "Unknown", + icon: ( + + + + ), + }, + { + label: "Upload Date", + value: formatDate(meeting.createdAt), + icon: ( + + + + ), + }, + { + label: "Meeting Date", + value: formatDate(meeting.date), + icon: ( + + + + ), + }, + { + label: "Duration", + value: formatDuration(meeting.duration), + icon: ( + + + + ), + }, + { + label: "Meeting Type", + value: meeting.meetingType || "conference", + icon: ( + + + + ), + }, + { + label: "Location", + value: meeting.location || "Not specified", + icon: ( + + + + + ), + }, + ]; + + const tags = meeting.tags || []; + + return ( +
+

+ + + + Meeting Metadata +

+ +
+ {metadataItems.map((item, index) => ( +
+
{item.icon}
+
+

+ {item.label} +

+

{item.value}

+
+
+ ))} +
+ + {tags.length > 0 && ( +
+

+ Tags +

+
+ {tags.map((tag, index) => ( + + {tag} + + ))} +
+
+ )} +
+ ); +}; + +export default MeetingMetadata; diff --git a/client/src/components/meeting-details/MeetingParticipants.jsx b/client/src/components/meeting-details/MeetingParticipants.jsx new file mode 100644 index 0000000..de624bc --- /dev/null +++ b/client/src/components/meeting-details/MeetingParticipants.jsx @@ -0,0 +1,82 @@ +import React from "react"; + +const MeetingParticipants = ({ meeting }) => { + if (!meeting) return null; + + const participants = meeting.participants || []; + + if (!participants || participants.length === 0) { + return ( +
+

+ + + + Participants +

+
+

No participants listed.

+
+
+ ); + } + + return ( +
+

+ + + + Participants +

+ +
+ {participants.map((participant, index) => ( +
+
+ {participant.name?.charAt(0).toUpperCase() || "?"} +
+
+

+ {participant.name || "Unknown"} +

+ {participant.role && ( +

{participant.role}

+ )} + {participant.email && ( +

+ {participant.email} +

+ )} +
+
+ ))} +
+
+ ); +}; + +export default MeetingParticipants; diff --git a/client/src/components/meeting-details/MeetingSummary.jsx b/client/src/components/meeting-details/MeetingSummary.jsx new file mode 100644 index 0000000..b86cdeb --- /dev/null +++ b/client/src/components/meeting-details/MeetingSummary.jsx @@ -0,0 +1,182 @@ +import React, { useState } from "react"; + +const MeetingSummary = ({ meeting }) => { + const [isExpanded, setIsExpanded] = useState(false); + + if (!meeting) return null; + + const summary = meeting.summary || meeting.structuredMoM || null; + + if (!summary) { + return ( +
+

+ + + + AI Summary +

+
+

No summary available yet.

+

+ Generate a summary to view AI insights. +

+
+
+ ); + } + + const renderStructuredSummary = (structured) => { + if (!structured) return null; + + return ( +
+ {structured.summary && ( +
+

Overview

+

+ {structured.summary} +

+
+ )} + + {structured.agenda && structured.agenda.length > 0 && ( +
+

Agenda

+
    + {structured.agenda.map((item, index) => ( +
  • {item}
  • + ))} +
+
+ )} + + {structured.key_discussions && + structured.key_discussions.length > 0 && ( +
+

+ Key Discussion Points +

+
    + {structured.key_discussions.map((item, index) => ( +
  • {item}
  • + ))} +
+
+ )} + + {structured.decisions && structured.decisions.length > 0 && ( +
+

Major Outcomes

+
    + {structured.decisions.map((item, index) => ( +
  • {item}
  • + ))} +
+
+ )} + + {structured.action_items && structured.action_items.length > 0 && ( +
+

Action Items

+
    + {structured.action_items.map((item, index) => ( +
  • + {typeof item === "string" + ? item + : `${item.task}${item.owner ? ` - ${item.owner}` : ""}${item.due_date ? ` (Due: ${item.due_date})` : ""}`} +
  • + ))} +
+
+ )} + + {structured.attendees && structured.attendees.length > 0 && ( +
+

Attendees

+

+ {structured.attendees.join(", ")} +

+
+ )} + + {structured.notes && ( +
+

Notes

+

+ {structured.notes} +

+
+ )} +
+ ); + }; + + const summaryText = typeof summary === "string" ? summary : null; + const shouldShowExpandButton = summaryText && summaryText.length > 500; + + return ( +
+

+ + + + AI Summary +

+ +
+ {typeof summary === "object" ? ( + renderStructuredSummary(summary) + ) : ( +
+ {shouldShowExpandButton && !isExpanded ? ( + <> + {summaryText.substring(0, 500)}... + + + ) : ( + <> + {summaryText} + {shouldShowExpandButton && isExpanded && ( + + )} + + )} +
+ )} +
+
+ ); +}; + +export default MeetingSummary; diff --git a/client/src/components/meeting-details/MeetingTranscript.jsx b/client/src/components/meeting-details/MeetingTranscript.jsx new file mode 100644 index 0000000..b43eb9a --- /dev/null +++ b/client/src/components/meeting-details/MeetingTranscript.jsx @@ -0,0 +1,119 @@ +import React, { useState } from "react"; + +const MeetingTranscript = ({ meeting }) => { + const [isExpanded, setIsExpanded] = useState(false); + + if (!meeting) return null; + + const transcript = meeting.transcript || ""; + + if (!transcript) { + return ( +
+

+ + + + Full Transcript +

+
+

No transcript available.

+

Upload audio to generate a transcript.

+
+
+ ); + } + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(transcript); + alert("Transcript copied to clipboard!"); + } catch (err) { + console.error("Failed to copy:", err); + } + }; + + const shouldShowExpandButton = transcript.length > 1000; + + return ( +
+
+

+ + + + Full Transcript +

+ +
+ +
+

+ {shouldShowExpandButton && !isExpanded ? ( + <> + {transcript.substring(0, 1000)}... + + + ) : ( + <> + {transcript} + {shouldShowExpandButton && isExpanded && ( + + )} + + )} +

+
+
+ ); +}; + +export default MeetingTranscript; diff --git a/client/src/pages/MeetingDetails.jsx b/client/src/pages/MeetingDetails.jsx new file mode 100644 index 0000000..aeffff9 --- /dev/null +++ b/client/src/pages/MeetingDetails.jsx @@ -0,0 +1,185 @@ +import React, { useEffect, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import axios from "axios"; +import { toast } from "react-toastify"; +import AppContent from "../context/AppContent.js"; + +import MeetingHeader from "../components/meeting-details/MeetingHeader"; +import MeetingSummary from "../components/meeting-details/MeetingSummary"; +import MeetingTranscript from "../components/meeting-details/MeetingTranscript"; +import MeetingParticipants from "../components/meeting-details/MeetingParticipants"; +import MeetingMetadata from "../components/meeting-details/MeetingMetadata"; +import MeetingActions from "../components/meeting-details/MeetingActions"; + +const MeetingDetails = () => { + const { id } = useParams(); + const navigate = useNavigate(); + const { backendUrl } = React.useContext(AppContent); + + const [meeting, setMeeting] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetchMeetingDetails(); + }, [id, backendUrl]); + + const fetchMeetingDetails = async () => { + try { + setLoading(true); + setError(null); + const { data } = await axios.get(`${backendUrl}/api/meetings/${id}`, { + withCredentials: true, + }); + + if (data.success) { + setMeeting(data.meeting); + } else { + setError(data.message || "Failed to fetch meeting details"); + } + } catch (err) { + console.error("Error fetching meeting details:", err); + setError( + err.response?.data?.message || "Failed to fetch meeting details", + ); + } finally { + setLoading(false); + } + }; + + const handleDelete = async (meetingId) => { + try { + const { data } = await axios.delete( + `${backendUrl}/api/meetings/delete/${meetingId}`, + { + withCredentials: true, + }, + ); + + if (data.success) { + toast.success("Meeting deleted successfully"); + navigate("/summaries"); + } else { + toast.error(data.message || "Failed to delete meeting"); + } + } catch (err) { + console.error("Error deleting meeting:", err); + toast.error(err.response?.data?.message || "Failed to delete meeting"); + } + }; + + const handleRename = async (meetingId, newTitle) => { + try { + const { data } = await axios.patch( + `${backendUrl}/api/meetings/${meetingId}`, + { title: newTitle }, + { withCredentials: true }, + ); + + if (data.success) { + toast.success("Meeting renamed successfully"); + setMeeting({ ...meeting, title: newTitle }); + } else { + toast.error(data.message || "Failed to rename meeting"); + } + } catch (err) { + console.error("Error renaming meeting:", err); + toast.error(err.response?.data?.message || "Failed to rename meeting"); + } + }; + + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+
+ ); + } + + if (error) { + return ( +
+
+
+
+ + + +

+ Error Loading Meeting +

+

{error}

+ +
+
+
+
+ ); + } + + if (!meeting) { + return ( +
+
+
+
+

+ Meeting Not Found +

+

+ The meeting you're looking for doesn't exist. +

+ +
+
+
+
+ ); + } + + return ( +
+
+ + + + + + +
+
+ ); +}; + +export default MeetingDetails; diff --git a/server/controllers/meetingController.js b/server/controllers/meetingController.js index 31166c2..8236afa 100644 --- a/server/controllers/meetingController.js +++ b/server/controllers/meetingController.js @@ -610,6 +610,11 @@ export const deleteMeeting = async (req, res) => { }; /* ========================================================= + 7. GET MEETING BY ID (Meeting Details Page) + - Fetches a single meeting with all details + - Used by: MeetingDetails.jsx + ========================================================= */ +export const getMeetingById = async (req, res) => { 7. NEW: UPDATE MEETING (updateMeeting) - Updates meeting fields like title, description, etc. - Used by: MeetingRepository component @@ -621,11 +626,52 @@ export const updateMeeting = async (req, res) => { return res.status(401).json({ success: false, message: "Unauthorized" }); } + const meeting = await Meeting.findOne({ + _id: req.params.id, + uploadedBy: userId, + }); + const meeting = await Meeting.findById(req.params.id); if (!meeting) { return res.status(404).json({ success: false, message: "Meeting not found" }); } + return res.status(200).json({ success: true, meeting }); + } catch (error) { + console.error("❌ getMeetingById Error:", error.message); + return res.status(500).json({ success: false, message: "Failed to fetch meeting" }); + } +}; + +/* ========================================================= + 8. UPDATE MEETING (Rename) + - Updates meeting title or other fields + - Used by: MeetingDetails.jsx + ========================================================= */ +export const updateMeeting = async (req, res) => { + try { + const userId = req.user?.id || req.user?._id; + if (!userId) { + return res.status(401).json({ success: false, message: "Unauthorized" }); + } + + const meeting = await Meeting.findOne({ + _id: req.params.id, + uploadedBy: userId, + }); + + if (!meeting) { + return res.status(404).json({ success: false, message: "Meeting not found" }); + } + + const { title } = req.body; + if (title) { + meeting.title = title.trim(); + } + + await meeting.save(); + + return res.status(200).json({ success: true, message: "Meeting updated successfully", meeting }); // Check if user owns the meeting if (meeting.uploadedBy.toString() !== userId.toString()) { return res.status(403).json({ success: false, message: "You don't have permission to update this meeting" }); @@ -671,6 +717,7 @@ export const updateMeeting = async (req, res) => { }; /* ========================================================= + 6. NEW: VOICE/TEXT SEARCH (searchMeetingsByText) 8. NEW: VOICE/TEXT SEARCH (searchMeetingsByText) - Accepts either: { query: "text to search" } diff --git a/server/routes/meetingRoutes.js b/server/routes/meetingRoutes.js index 7e225a5..2889e15 100644 --- a/server/routes/meetingRoutes.js +++ b/server/routes/meetingRoutes.js @@ -8,6 +8,8 @@ import { uploadAudioForMeeting, // NEW: Upload audio for existing meeting summarizeMeeting, // EXISTING: Generate AI summary/MOM getAllMeetings, + getMeetingById, // NEW: Get single meeting details + updateMeeting, // NEW: Update meeting (rename) deleteMeeting, // EXISTING: Delete meeting searchMeetingsByText, // 🆕 NEW: Voice/Text Search updateMeeting // 🆕 NEW: Update meeting (rename, etc.) @@ -30,6 +32,12 @@ router.post("/summarize", userAuth, writeLimiter, summarizeMeeting); // ✅ Fetch All Meetings (for Summaries Page) router.get("/all", userAuth, getAllMeetings); +// ✅ Get Single Meeting Details (for Meeting Details Page) +router.get("/:id", userAuth, getMeetingById); + +// ✅ Update Meeting (for Meeting Details Page - rename) +router.patch("/:id", userAuth, updateMeeting); + // ✅ Delete Meeting router.delete("/delete/:id", userAuth, writeLimiter, deleteMeeting);