From 13eaffc5d2e57bde34e13e59cd773b4298349cb1 Mon Sep 17 00:00:00 2001 From: Rakshit Sharma Date: Sat, 4 Jul 2026 19:17:51 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20real-time=20collaboration=20rooms=20?= =?UTF-8?q?=E2=80=94=20chat,=20notes,=20editor,=20voice/video,=20AI=20summ?= =?UTF-8?q?aries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 69 +- app/api/rooms/[roomId]/summary/route.js | 151 ++ app/globals.css | 93 + app/rooms/[roomId]/layout.js | 3 + .../[roomId]/messages/[messageId]/layout.js | 3 + .../[roomId]/messages/[messageId]/page.js | 13 + app/rooms/[roomId]/notes/[noteId]/layout.js | 3 + app/rooms/[roomId]/notes/[noteId]/page.js | 13 + app/rooms/[roomId]/page.js | 461 ++++ app/rooms/[roomId]/presence/[uid]/layout.js | 3 + app/rooms/[roomId]/presence/[uid]/page.js | 13 + app/rooms/page.js | 507 ++++ components/dashboard/LeftSidebar.js | 10 + components/dashboard/MobileView.js | 11 +- components/dashboard/RightSidebar.js | 23 + components/rooms/RoomCallControls.js | 748 ++++++ components/rooms/RoomChat.js | 146 + components/rooms/RoomCursorLayer.js | 91 + components/rooms/RoomEditor.js | 196 ++ components/rooms/RoomLanguagePicker.js | 28 + components/rooms/RoomMembers.js | 104 + components/rooms/RoomNotes.js | 92 + components/rooms/RoomSummaryButton.js | 53 + components/rooms/RoomSummaryModal.js | 102 + lib/firebase-admin.js | 22 + lib/rooms.js | 107 + lib/webrtc.js | 707 +++++ package-lock.json | 2344 +++++++++++++++-- package.json | 5 +- postcss.config.mjs | 2 +- 30 files changed, 5876 insertions(+), 247 deletions(-) create mode 100644 app/api/rooms/[roomId]/summary/route.js create mode 100644 app/rooms/[roomId]/layout.js create mode 100644 app/rooms/[roomId]/messages/[messageId]/layout.js create mode 100644 app/rooms/[roomId]/messages/[messageId]/page.js create mode 100644 app/rooms/[roomId]/notes/[noteId]/layout.js create mode 100644 app/rooms/[roomId]/notes/[noteId]/page.js create mode 100644 app/rooms/[roomId]/page.js create mode 100644 app/rooms/[roomId]/presence/[uid]/layout.js create mode 100644 app/rooms/[roomId]/presence/[uid]/page.js create mode 100644 app/rooms/page.js create mode 100644 components/rooms/RoomCallControls.js create mode 100644 components/rooms/RoomChat.js create mode 100644 components/rooms/RoomCursorLayer.js create mode 100644 components/rooms/RoomEditor.js create mode 100644 components/rooms/RoomLanguagePicker.js create mode 100644 components/rooms/RoomMembers.js create mode 100644 components/rooms/RoomNotes.js create mode 100644 components/rooms/RoomSummaryButton.js create mode 100644 components/rooms/RoomSummaryModal.js create mode 100644 lib/firebase-admin.js create mode 100644 lib/rooms.js create mode 100644 lib/webrtc.js diff --git a/README.md b/README.md index e58f0cb..7c8d53c 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,17 @@ There's no single platform that combines all of this with built-in AI assistance - Powered by the **Sarvam AI** chat completions API via a secure server-side Next.js API route (`/api/ai-draft`). - Code Review Copilot (UI ready, backend in progress) +### 🚪 Collaboration Rooms +- Real-time collaboration rooms with chat, shared notes, code editor, and voice/video +- Room creation with title, description, language, and private/public visibility +- **Chat**: live messages via Firestore `onSnapshot`, send-on-enter, timestamp display +- **Shared notes**: markdown notes with auto-save on debounce +- **Code editor**: syntax-highlighted collaborative editing with debounced Firestore sync +- **Voice/video**: WebRTC peer-to-peer calls with mic, camera, and screen-share controls +- **Presence**: real-time member list, cursor tracking, heartbeat-based online status +- **AI Room Summaries**: generate meeting summaries from chat and notes via Sarvam AI +- Summary modal with markdown rendering (react-markdown + remark-gfm) + --- ## 📁 Project Structure @@ -90,16 +101,24 @@ DevConnect-AI/ ├── app/ │ ├── layout.js # Root layout with AuthProvider │ ├── page.js # Landing page (features tour) -│ ├── globals.css # Global styles +│ ├── globals.css # Global styles + markdown styles │ ├── api/ │ │ ├── ai-draft/ │ │ │ └── route.js # Sarvam AI-powered "Draft with AI" endpoint -│ │ └── code-review/ -│ │ └── route.js # Code review endpoint (in progress) +│ │ ├── code-review/ +│ │ │ └── route.js # Code review endpoint (in progress) +│ │ └── rooms/ +│ │ └── [roomId]/ +│ │ └── summary/ +│ │ └── route.js # AI room summary endpoint │ ├── dashboard/ │ │ └── page.js # Main community feed (protected) │ ├── login/ │ │ └── page.js # Login with Google / GitHub +│ ├── rooms/ +│ │ ├── page.js # Rooms hub — list/create rooms (protected) +│ │ └── [roomId]/ +│ │ └── page.js # Room workspace — chat, notes, editor, call │ ├── signup/ │ │ └── page.js # Signup with Google / GitHub │ ├── profile/ @@ -107,6 +126,18 @@ DevConnect-AI/ │ └── settings/ │ └── page.js # Settings page ├── components/ +│ ├── rooms/ +│ │ ├── RoomCallControls.js # Voice/video/screen-share controls + camera preview +│ │ ├── RoomChat.js # Real-time chat panel +│ │ ├── RoomCursorLayer.js # Remote cursor/presence overlay +│ │ ├── RoomEditor.js # Collaborative code editor +│ │ ├── RoomMembers.js # Online member list +│ │ ├── RoomNotes.js # Shared markdown notes +│ │ ├── RoomSummaryButton.js # Generate/view summary button +│ │ └── RoomSummaryModal.js # Markdown summary modal with backdrop blur +│ ├── dashboard/ +│ │ ├── LeftSidebar.js # Feed tabs + rooms nav +│ │ └── RightSidebar.js # AI copilot + rooms promo widgets │ ├── AIDraftAssistant.js # "Draft with AI" prompt box + Sarvam AI call │ ├── CodeEditorModal.js # Modal for inserting formatted code blocks │ ├── CodeReview.js # Code review UI component @@ -117,7 +148,10 @@ DevConnect-AI/ ├── context/ │ └── AuthContext.js # Firebase auth state (Google + GitHub login/logout) ├── lib/ -│ └── firebase.js # Firebase app initialization +│ ├── firebase.js # Firebase client initialization +│ ├── firebase-admin.js # Firebase server-side initialization (for API routes) +│ ├── rooms.js # Room CRUD + presence + summary helpers +│ └── webrtc.js # WebRTC signaling + media stream management ├── .env.local # Environment variables (not committed) ├── .gitignore ├── package.json @@ -259,6 +293,21 @@ All Firebase values are available in **Firebase Console → Project Settings → --- +## 🔥 Required Firestore Collections + +The rooms feature uses these Firestore collections (created automatically on first use): + +| Collection | Purpose | +|---|---| +| `rooms/{roomId}` | Room metadata — title, description, language, members, createdBy, createdAt | +| `rooms/{roomId}/messages/{messageId}` | Real-time chat messages | +| `rooms/{roomId}/notes/{noteId}` | Shared markdown notes (one active note per room) | +| `rooms/{roomId}/presence/{uid}` | Live presence — last heartbeat, cursor position, media state | + +No manual collection setup is required — Firestore creates these on first write. + +--- + ## ▶️ Running the App | Command | Description | @@ -278,9 +327,12 @@ All Firebase values are available in **Firebase Console → Project Settings → | `/login` | Login with Google or GitHub | No | | `/signup` | Signup with Google or GitHub | No | | `/dashboard` | Main community feed | ✅ Yes | +| `/rooms` | Collaboration rooms hub — list/create rooms | ✅ Yes | +| `/rooms/[roomId]` | Room workspace — chat, notes, editor, voice/video | ✅ Yes | | `/profile` | User profile page | ✅ Yes | | `/settings` | Settings page | ✅ Yes | | `/api/ai-draft` | Server-side Sarvam AI draft endpoint | N/A (API route) | +| `/api/rooms/[roomId]/summary` | AI room summary generation endpoint | N/A (API route) | > Protected routes automatically redirect unauthenticated users to `/login`. @@ -305,9 +357,14 @@ This project is under active development. Here's what's done and what's still in | Markdown rendering for posts (headings, lists, code blocks) | ✅ Done | | In-post code editor / inserter | ✅ Done | | **Draft with AI Assistant** (Sarvam AI, question-only mode) | ✅ Done | +| Collaboration Rooms — hub + workspace shell | ✅ Done | +| Room chat (real-time, Firestore onSnapshot) | ✅ Done | +| Room shared notes (markdown, auto-save) | ✅ Done | +| Room collaborative code editor | ✅ Done | +| Room voice/video/screen-share (WebRTC) | ✅ Done | +| Room presence (members, cursors, heartbeat) | ✅ Done | +| AI Room Summaries (Sarvam AI + markdown modal) | ✅ Done | | AI Code Review (Copilot) | 🔄 In Progress | -| User profile page | 🔜 Planned | -| Trending / Questions / Collaborations / Saved Posts (functional) | 🔜 Planned | --- diff --git a/app/api/rooms/[roomId]/summary/route.js b/app/api/rooms/[roomId]/summary/route.js new file mode 100644 index 0000000..d417e84 --- /dev/null +++ b/app/api/rooms/[roomId]/summary/route.js @@ -0,0 +1,151 @@ +import { collection, query, orderBy, getDocs, doc, getDoc } from "firebase/firestore"; +import { getServerDb } from "../../../../../lib/firebase-admin"; + +export async function POST(req) { + try { + const { roomId } = await req.json(); + const db = getServerDb(); + + if (!roomId) { + return Response.json( + { error: "roomId is required" }, + { status: 400 } + ); + } + + // Fetch room data + const roomDoc = await getDoc(doc(db, "rooms", roomId)); + const roomData = roomDoc.exists ? roomDoc.data() : {}; + const title = roomData.title || "Collaboration Room"; + const language = roomData.language || "Not specified"; + + // Fetch messages + const messagesRef = collection(db, "rooms", roomId, "messages"); + const messagesQuery = query(messagesRef, orderBy("createdAt", "asc")); + const messagesSnap = await getDocs(messagesQuery); + const messages = messagesSnap.docs.map(d => ({ id: d.id, ...d.data() })); + + // Fetch notes + const noteContent = roomData.noteContent || ""; + + const messagesText = messages.length > 0 + ? messages.map(m => `- ${m.senderName || "Anonymous"} (${m.createdAt ? new Date(m.createdAt.toDate()).toLocaleDateString() : "recent"}): ${m.text}`).join("\n") + : "No messages yet."; + + const notesText = noteContent ? noteContent : "No notes yet."; + + const sarvamApiKey = process.env.SARVAM_API_KEY; + + if (!sarvamApiKey) { + const fallbackSummary = [ + `## ${title || "Collaboration Room"} Summary`, + "", + `**Room ID**: ${roomId || "N/A"}`, + `**Language**: ${language || "Not specified"}`, + `**Generated**: ${new Date().toLocaleDateString()} at ${new Date().toLocaleTimeString()}`, + "", + "---", + "", + "### Recent Messages", + messagesText, + "", + "### Recent Notes", + notesText, + "", + "---", + "", + `_Generated by DevConnect AI • ${new Date().toLocaleDateString()}_`, + ].join("\n"); + + return Response.json({ + summary: fallbackSummary, + hasApiKey: false, + suggestion: "Configure SARVAM_API_KEY for AI-powered summaries", + messageCount: messages.length, + noteCount: noteContent ? 1 : 0, + }); + } + + const systemPrompt = [ + "You are an AI assistant that creates concise collaboration room summaries.", + "Analyze the conversation and notes provided and generate a summary that:", + "- Captures key discussion points and decisions", + "- Highlights important code snippets or technical discoveries", + "- Lists action items or next steps", + "- Uses markdown formatting for readability", + "Keep the summary focused and actionable. Do not add fluff.", + ].join(" "); + + const userPrompt = [ + `Room: ${title || "Untitled"}`, + `Language: ${language || "Not specified"}`, + "", + "Recent Messages:", + messagesText, + "", + "Recent Notes:", + notesText, + "", + "Please provide a comprehensive room summary.", + ].join("\n"); + + const sarvamResponse = await fetch("https://api.sarvam.ai/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + "api-subscription-key": sarvamApiKey, + }, + body: JSON.stringify({ + model: "sarvam-30b", + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + temperature: 0.3, + max_tokens: 1500, + }), + }); + + if (!sarvamResponse.ok) { + const errText = await sarvamResponse.text(); + console.error("Sarvam AI API error:", errText); + return Response.json( + { error: "Failed to generate AI summary", details: errText }, + { status: 500 } + ); + } + + const data = await sarvamResponse.json(); + const summaryBody = data.choices?.[0]?.message?.content || ""; + + const formattedSummary = [ + `## ${title || "Collaboration Room"} Summary`, + "", + `**Room ID**: ${roomId || "N/A"}`, + `**Language**: ${language || "Not specified"}`, + `**Generated**: ${new Date().toLocaleDateString()} at ${new Date().toLocaleTimeString()}`, + "", + "---", + "", + summaryBody, + "", + "---", + "", + `_Generated by DevConnect AI • ${new Date().toLocaleDateString()}_`, + ].join("\n"); + + return Response.json({ + summary: formattedSummary, + hasApiKey: true, + messageCount: messages.length, + noteCount: noteContent ? 1 : 0, + }); + + } catch (error) { + console.error("Error generating room summary:", error); + return Response.json( + { error: "Failed to generate room summary", details: error.message }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/globals.css b/app/globals.css index a1968fb..b05bd20 100644 --- a/app/globals.css +++ b/app/globals.css @@ -107,6 +107,93 @@ a { transition: color var(--transition-fast); } +.markdown { + line-height: 1.7; + color: var(--text-primary); +} + +/* Markdown content styles */ +.markdown h1, .markdown h2, .markdown h3, .markdown h4 { + color: var(--text-primary); + margin-top: 1rem; + margin-bottom: 0.5rem; + font-weight: 600; +} + +.markdown h1 { font-size: 1.25rem; } +.markdown h2 { font-size: 1.1rem; } +.markdown h3 { font-size: 1rem; } + +.markdown p { + margin-bottom: 0.75rem; + line-height: 1.6; +} + +.markdown ul, .markdown ol { + padding-left: 1.5rem; + margin-bottom: 0.75rem; +} + +.markdown li { + margin-bottom: 0.25rem; + line-height: 1.5; +} + +.markdown code { + background: var(--bg-primary); + padding: 0.15rem 0.4rem; + border-radius: 4px; + font-size: 0.85rem; + font-family: monospace; +} + +.markdown pre { + background: var(--bg-primary); + padding: 0.75rem; + border-radius: 6px; + overflow-x: auto; + margin-bottom: 0.75rem; +} + +.markdown pre code { + padding: 0; + background: transparent; +} + +.markdown blockquote { + border-left: 3px solid var(--accent-primary); + padding-left: 0.75rem; + margin: 0.75rem 0; + color: var(--text-muted); +} + +.markdown table { + border-collapse: collapse; + margin-bottom: 0.75rem; + width: 100%; +} + +.markdown th, .markdown td { + border: 1px solid var(--border-color); + padding: 0.5rem 0.75rem; + text-align: left; +} + +.markdown th { + background: var(--bg-primary); + font-weight: 600; +} + +.markdown strong { + color: var(--text-primary); +} + +.markdown hr { + border: none; + border-top: 1px solid var(--border-color); + margin: 1rem 0; +} + a:hover { color: var(--accent-primary-hover); } @@ -138,3 +225,9 @@ input, textarea, button, div, a, span, p, h1, h2, h3, h4, h5, h6 { 33% { transform: translate(30px, -20px) scale(1.05); } 66% { transform: translate(-20px, 10px) scale(0.95); } } + +/* Spin animation for loader */ +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/app/rooms/[roomId]/layout.js b/app/rooms/[roomId]/layout.js new file mode 100644 index 0000000..5d49e5e --- /dev/null +++ b/app/rooms/[roomId]/layout.js @@ -0,0 +1,3 @@ +export default function RoomLayout({ children }) { + return <>{children}; +} diff --git a/app/rooms/[roomId]/messages/[messageId]/layout.js b/app/rooms/[roomId]/messages/[messageId]/layout.js new file mode 100644 index 0000000..96959a6 --- /dev/null +++ b/app/rooms/[roomId]/messages/[messageId]/layout.js @@ -0,0 +1,3 @@ +export default function MessageLayout({ children }) { + return <>{children}; +} diff --git a/app/rooms/[roomId]/messages/[messageId]/page.js b/app/rooms/[roomId]/messages/[messageId]/page.js new file mode 100644 index 0000000..0d3568d --- /dev/null +++ b/app/rooms/[roomId]/messages/[messageId]/page.js @@ -0,0 +1,13 @@ +"use client"; + +import { useParams } from "next/navigation"; + +export default function MessagePage() { + const { roomId, messageId } = useParams(); + + return ( +
+

Message {messageId} in room {roomId}

+
+ ); +} diff --git a/app/rooms/[roomId]/notes/[noteId]/layout.js b/app/rooms/[roomId]/notes/[noteId]/layout.js new file mode 100644 index 0000000..6d2c3ec --- /dev/null +++ b/app/rooms/[roomId]/notes/[noteId]/layout.js @@ -0,0 +1,3 @@ +export default function NoteLayout({ children }) { + return <>{children}; +} diff --git a/app/rooms/[roomId]/notes/[noteId]/page.js b/app/rooms/[roomId]/notes/[noteId]/page.js new file mode 100644 index 0000000..687562f --- /dev/null +++ b/app/rooms/[roomId]/notes/[noteId]/page.js @@ -0,0 +1,13 @@ +"use client"; + +import { useParams } from "next/navigation"; + +export default function NotePage() { + const { roomId, noteId } = useParams(); + + return ( +
+

Note {noteId} in room {roomId}

+
+ ); +} diff --git a/app/rooms/[roomId]/page.js b/app/rooms/[roomId]/page.js new file mode 100644 index 0000000..b1c87ea --- /dev/null +++ b/app/rooms/[roomId]/page.js @@ -0,0 +1,461 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { doc, onSnapshot } from "firebase/firestore"; +import { db } from "../../../lib/firebase"; +import { useAuth } from "../../../context/AuthContext"; +import RoomSummaryButton from "../../../components/rooms/RoomSummaryButton"; +import RoomMembers from "../../../components/rooms/RoomMembers"; +import RoomChat from "../../../components/rooms/RoomChat"; +import RoomNotes from "../../../components/rooms/RoomNotes"; +import RoomEditor from "../../../components/rooms/RoomEditor"; +import RoomCursorLayer from "../../../components/rooms/RoomCursorLayer"; +import RoomCallControls from "../../../components/rooms/RoomCallControls"; +import ProtectedRoute from "../../../components/ProtectedRoute"; +import RoomSummaryModal from "../../../components/rooms/RoomSummaryModal"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { toast } from "sonner"; +import { heartbeatPresence } from "../../../lib/rooms"; + +const LANG_COLORS = { + JavaScript: "#f7df1e", + TypeScript: "#3178c6", + Python: "#377ab", + Java: "#ed8b00", + "C++": "#00599c", + Go: "#00add8", + Rust: "#dea584", + React: "#61dafb", + "Node.js": "#339933", + Other: "#888", +}; + +/* ─── Local camera preview (renders in the header) ─── */ + +/* ─── Camera/mic status indicator (in the header — full preview is floating) ─── */ + +function HeaderCameraPreview({ stream, videoEnabled }) { + const videoRef = useRef(null); + const hasStream = !!stream; + const hasVideo = hasStream && stream.getVideoTracks().length > 0 && videoEnabled; + + useEffect(() => { + const el = videoRef.current; + if (!el || !stream || !videoEnabled) { + if (el) el.srcObject = null; + return; + } + el.srcObject = stream; + el.play().catch(() => {}); + }, [stream, videoEnabled]); + + if (!hasStream) return null; + + return ( +
+ {hasVideo && ( +
+
+ )} +
+ ); +} + +/* ─── Drag handle between panels ─── */ + +function DragHandle({ onDragStart }) { + return ( +
{ + e.currentTarget.style.backgroundColor = "var(--accent-primary)"; + e.currentTarget.style.opacity = "0.5"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = "transparent"; + e.currentTarget.style.opacity = "1"; + }} + /> + ); +} + +/* ─── Styles ─── */ + +const S = { + page: { + display: "flex", + flexDirection: "column", + height: "100vh", + backgroundColor: "var(--bg-primary)", + overflow: "hidden", + }, + header: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "12px 20px", + borderBottom: "1px solid var(--border-color)", + backgroundColor: "var(--bg-secondary)", + flexShrink: 0, + }, + headerLeft: { + display: "flex", + alignItems: "center", + gap: 12, + minWidth: 0, + }, + backBtn: { + display: "flex", + alignItems: "center", + justifyContent: "center", + width: 32, + height: 32, + border: "none", + borderRadius: "var(--radius-md)", + backgroundColor: "transparent", + color: "var(--text-secondary)", + cursor: "pointer", + fontSize: "1.1rem", + flexShrink: 0, + }, + roomTitle: { + fontSize: "1.05rem", + fontWeight: 700, + color: "var(--text-primary)", + margin: 0, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + langBadge: { + display: "inline-block", + padding: "2px 8px", + borderRadius: "var(--radius-sm)", + fontSize: "0.7rem", + fontWeight: 600, + flexShrink: 0, + }, + headerRight: { + display: "flex", + alignItems: "center", + gap: 10, + flexShrink: 0, + }, + body: { + display: "flex", + flex: 1, + overflow: "hidden", + }, + panel: { + display: "flex", + flexDirection: "column", + overflow: "hidden", + minWidth: 0, + }, + panelHeader: { + display: "flex", + alignItems: "center", + gap: 8, + padding: "10px 14px", + borderBottom: "1px solid var(--border-color)", + backgroundColor: "var(--bg-secondary)", + flexShrink: 0, + }, + panelTitle: { + fontSize: "0.8rem", + fontWeight: 600, + color: "var(--text-secondary)", + textTransform: "uppercase", + letterSpacing: "0.03em", + margin: 0, + }, +}; + +export default function RoomWorkspacePage() { + const { roomId } = useParams(); + const { user, loading: authLoading } = useAuth(); + const router = useRouter(); + const [room, setRoom] = useState(null); + const [notFound, setNotFound] = useState(false); + const [summary, setSummary] = useState(null); + const [isGeneratingSummary, setIsGeneratingSummary] = useState(false); + const [showSummaryModal, setShowSummaryModal] = useState(false); + const [hasSummary, setHasSummary] = useState(false); + const hasSummaryRef = useRef(false); + const presenceInterval = useRef(null); + + // Reset summary when workspace content changes + const handleContentChange = useCallback(() => { + if (hasSummaryRef.current) { + hasSummaryRef.current = false; + setHasSummary(false); + } + }, []); + + const [localStream, setLocalStream] = useState(null); + const [panelFlex, setPanelFlex] = useState([1, 2, 1]); + const dragState = useRef(null); + const bodyRef = useRef(null); + + // Subscribe to room document + useEffect(() => { + if (!roomId) return; + const unsub = onSnapshot(doc(db, "rooms", roomId), (snap) => { + if (snap.exists()) { + setRoom({ id: snap.id, ...snap.data() }); + setNotFound(false); + } else { + setRoom(null); + setNotFound(true); + } + }, (err) => { + console.error("Room subscription error:", err); + }); + return () => unsub(); + }, [roomId]); + + // Generate summary handler + const handleSummaryGenerated = useCallback((generatedSummary) => { + setSummary(generatedSummary); + setIsGeneratingSummary(false); + hasSummaryRef.current = true; + setHasSummary(true); + setShowSummaryModal(true); + }, []); + + const generateSummary = async () => { + if (!user) { + toast.error("You must be logged in to generate room summaries"); + return; + } + + setIsGeneratingSummary(true); + setShowSummaryHistory(false); + + try { + const response = await fetch(`/api/rooms/${roomId}/summary`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to generate summary"); + } + + const data = await response.json(); + setSummary(data.summary); + hasSummaryRef.current = true; + setHasSummary(true); + setShowSummaryModal(true); + } catch (error) { + console.error("Error generating summary:", error); + toast.error(error.message || "Failed to generate summary"); + } finally { + setIsGeneratingSummary(false); + } + }; + + // Heartbeat presence + useEffect(() => { + if (!user || !roomId) return; + heartbeatPresence(roomId, user); + presenceInterval.current = setInterval(() => { + heartbeatPresence(roomId, user); + }, 30000); + return () => { clearInterval(presenceInterval.current); }; + }, [user, roomId]); + + // Panel resize logic + const handlePanelDragStart = useCallback((index, e) => { + e.preventDefault(); + const bodyEl = bodyRef.current; + if (!bodyEl) return; + const bodyWidth = bodyEl.getBoundingClientRect().width; + const totalFlex = panelFlex[0] + panelFlex[1] + panelFlex[2]; + dragState.current = { + index, + startX: e.clientX, + startFlex: [...panelFlex], + bodyWidth, + totalFlex, + }; + const onMove = (ev) => { + const d = dragState.current; + if (!d) return; + const dx = ev.clientX - d.startX; + const dFlex = (dx / d.bodyWidth) * d.totalFlex; + const newFlex = [...d.startFlex]; + newFlex[d.index] = Math.max(0.3, d.startFlex[d.index] + dFlex); + newFlex[d.index + 1] = Math.max(0.3, d.startFlex[d.index + 1] - dFlex); + setPanelFlex(newFlex); + }; + const onUp = () => { + dragState.current = null; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, [panelFlex]); + + if (authLoading || (!room && !notFound)) { + return ( + +
+

Loading room...

+
+
+ ); + } + + if (notFound) { + return ( + +
+
+

🔍

+

Room not found

+ +
+
+
+ ); + } + + const langColor = LANG_COLORS[room?.language] || "#888"; + + return ( + +
+ {/* Header */} +
+
+ +

{room?.title}

+ + {room?.language} + + +
+
+ + setShowSummaryModal(true)} + /> + +
+
+ + {/* Summary modal */} + setShowSummaryModal(false)} + /> + + {/* Body: 3-panel layout */} +
+ {/* Left: Chat */} +
+
+ 💬 +

Chat

+
+
+ +
+
+ + handlePanelDragStart(0, e)} /> + + {/* Center: Code Editor */} +
+ + +
+ + handlePanelDragStart(1, e)} /> + + {/* Right: Notes */} +
+
+ 📝 +

Notes

+
+
+ +
+
+
+ + {/* Bottom controls bar */} + +
+
+ ); +} \ No newline at end of file diff --git a/app/rooms/[roomId]/presence/[uid]/layout.js b/app/rooms/[roomId]/presence/[uid]/layout.js new file mode 100644 index 0000000..ff3c533 --- /dev/null +++ b/app/rooms/[roomId]/presence/[uid]/layout.js @@ -0,0 +1,3 @@ +export default function PresenceLayout({ children }) { + return <>{children}; +} diff --git a/app/rooms/[roomId]/presence/[uid]/page.js b/app/rooms/[roomId]/presence/[uid]/page.js new file mode 100644 index 0000000..bac5592 --- /dev/null +++ b/app/rooms/[roomId]/presence/[uid]/page.js @@ -0,0 +1,13 @@ +"use client"; + +import { useParams } from "next/navigation"; + +export default function PresencePage() { + const { roomId, uid } = useParams(); + + return ( +
+

Presence for {uid} in room {roomId}

+
+ ); +} diff --git a/app/rooms/page.js b/app/rooms/page.js new file mode 100644 index 0000000..fcd0057 --- /dev/null +++ b/app/rooms/page.js @@ -0,0 +1,507 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { collection, onSnapshot, orderBy, query } from "firebase/firestore"; +import { db } from "../../lib/firebase"; +import { useAuth } from "../../context/AuthContext"; +import ProtectedRoute from "../../components/ProtectedRoute"; +import Navbar from "../../components/Navbar"; +import { createRoom, deleteRoom } from "../../lib/rooms"; +import { Trash2, DoorOpen, Lock } from "lucide-react"; + +const LANGUAGES = [ + "JavaScript", + "TypeScript", + "Python", + "Java", + "C++", + "Go", + "Rust", + "React", + "Node.js", + "Other", +]; + +const S = { + page: { + display: "flex", + flexDirection: "column", + minHeight: "100vh", + backgroundColor: "var(--bg-primary)", + }, + container: { + maxWidth: 960, + width: "100%", + margin: "0 auto", + padding: "24px 24px 64px", + display: "flex", + flexDirection: "column", + gap: 24, + }, + headerRow: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 16, + flexWrap: "wrap", + }, + title: { + fontSize: "1.6rem", + fontWeight: 700, + color: "var(--text-primary)", + margin: 0, + }, + subtitle: { + fontSize: "0.9rem", + color: "var(--text-muted)", + margin: "4px 0 0 0", + }, + createBtn: { + display: "flex", + alignItems: "center", + gap: 8, + padding: "10px 20px", + backgroundColor: "var(--accent-primary)", + color: "#fff", + border: "none", + borderRadius: "var(--radius-md)", + fontWeight: 600, + fontSize: "0.9rem", + cursor: "pointer", + fontFamily: "inherit", + }, + createBtnDisabled: { + opacity: 0.5, + cursor: "not-allowed", + }, + modalOverlay: { + position: "fixed", + inset: 0, + backgroundColor: "rgba(0,0,0,0.6)", + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: 1000, + padding: 16, + }, + modal: { + backgroundColor: "var(--bg-secondary)", + border: "1px solid var(--border-color)", + borderRadius: "var(--radius-lg)", + padding: 28, + width: "100%", + maxWidth: 480, + display: "flex", + flexDirection: "column", + gap: 20, + }, + modalTitle: { + fontSize: "1.2rem", + fontWeight: 700, + color: "var(--text-primary)", + margin: 0, + }, + formGroup: { + display: "flex", + flexDirection: "column", + gap: 6, + }, + label: { + fontSize: "0.82rem", + fontWeight: 600, + color: "var(--text-secondary)", + }, + input: { + padding: "10px 12px", + backgroundColor: "var(--bg-primary)", + border: "1px solid var(--border-color)", + borderRadius: "var(--radius-md)", + color: "var(--text-primary)", + fontSize: "0.9rem", + fontFamily: "inherit", + outline: "none", + }, + textarea: { + padding: "10px 12px", + backgroundColor: "var(--bg-primary)", + border: "1px solid var(--border-color)", + borderRadius: "var(--radius-md)", + color: "var(--text-primary)", + fontSize: "0.9rem", + fontFamily: "inherit", + outline: "none", + resize: "vertical", + minHeight: 60, + }, + select: { + padding: "10px 12px", + backgroundColor: "var(--bg-primary)", + border: "1px solid var(--border-color)", + borderRadius: "var(--radius-md)", + color: "var(--text-primary)", + fontSize: "0.9rem", + fontFamily: "inherit", + outline: "none", + cursor: "pointer", + }, + checkboxRow: { + display: "flex", + alignItems: "center", + gap: 8, + fontSize: "0.85rem", + color: "var(--text-secondary)", + cursor: "pointer", + }, + modalActions: { + display: "flex", + gap: 12, + justifyContent: "flex-end", + }, + cancelBtn: { + padding: "10px 18px", + backgroundColor: "transparent", + border: "1px solid var(--border-color)", + borderRadius: "var(--radius-md)", + color: "var(--text-secondary)", + fontWeight: 500, + fontSize: "0.85rem", + cursor: "pointer", + fontFamily: "inherit", + }, + submitBtn: { + padding: "10px 18px", + backgroundColor: "var(--accent-primary)", + color: "#fff", + border: "none", + borderRadius: "var(--radius-md)", + fontWeight: 600, + fontSize: "0.85rem", + cursor: "pointer", + fontFamily: "inherit", + }, + roomGrid: { + display: "grid", + gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", + gap: 16, + }, + roomCard: { + display: "flex", + flexDirection: "column", + gap: 10, + padding: 20, + backgroundColor: "var(--bg-secondary)", + border: "1px solid var(--border-color)", + borderRadius: "var(--radius-lg)", + cursor: "pointer", + transition: "border-color 0.15s, box-shadow 0.15s", + textDecoration: "none", + }, + roomCardTitle: { + fontSize: "1rem", + fontWeight: 600, + color: "var(--text-primary)", + margin: 0, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + roomCardDesc: { + fontSize: "0.82rem", + color: "var(--text-muted)", + margin: 0, + display: "-webkit-box", + WebkitLineClamp: 2, + WebkitBoxOrient: "vertical", + overflow: "hidden", + }, + roomCardMeta: { + display: "flex", + alignItems: "center", + gap: 8, + flexWrap: "wrap", + marginTop: "auto", + }, + langBadge: { + display: "inline-block", + padding: "3px 8px", + backgroundColor: "var(--accent-primary-alpha)", + color: "var(--accent-primary)", + borderRadius: "var(--radius-sm)", + fontSize: "0.72rem", + fontWeight: 600, + }, + metaText: { + fontSize: "0.75rem", + color: "var(--text-muted)", + }, + emptyState: { + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: 12, + padding: "64px 24px", + color: "var(--text-muted)", + textAlign: "center", + }, + emptyIcon: { + fontSize: "3rem", + }, + emptyTitle: { + fontSize: "1rem", + fontWeight: 600, + color: "var(--text-secondary)", + margin: 0, + }, + emptyDesc: { + fontSize: "0.85rem", + color: "var(--text-muted)", + margin: 0, + maxWidth: 360, + }, +}; + +function timeAgo(timestamp) { + if (!timestamp) return ""; + const now = Date.now(); + const then = timestamp.toMillis ? timestamp.toMillis() : new Date(timestamp).getTime(); + const diffMs = now - then; + const diffMin = Math.floor(diffMs / 60000); + if (diffMin < 1) return "just now"; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + return `${diffDay}d ago`; +} + +function CreateRoomModal({ onClose, onSubmit, submitting }) { + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [language, setLanguage] = useState("JavaScript"); + const [isPrivate, setIsPrivate] = useState(false); + + const handleSubmit = (e) => { + e.preventDefault(); + if (!title.trim()) return; + onSubmit({ title: title.trim(), description: description.trim(), language, isPrivate }); + }; + + return ( +
+
e.stopPropagation()}> +

Create a Room

+
+
+ + setTitle(e.target.value)} + autoFocus + required + /> +
+
+ +