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 df94801..a1cdd71 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -123,6 +123,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);
}
@@ -154,3 +241,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 (
+
+
+
+ );
+ }
+
+ 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 */}
+
+
+
handlePanelDragStart(0, e)} />
+
+ {/* Center: Code Editor */}
+
+
+
+
+
+ handlePanelDragStart(1, e)} />
+
+ {/* Right: 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
+
+
+
+ );
+}
+
+export default function RoomsHubPage() {
+ const { user } = useAuth();
+ const router = useRouter();
+ const [rooms, setRooms] = useState([]);
+ const [showModal, setShowModal] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const unsubRef = useRef(null);
+
+ useEffect(() => {
+ if (!user) return;
+ const q = query(collection(db, "rooms"), orderBy("lastActivity", "desc"));
+ unsubRef.current = onSnapshot(q, (snap) => {
+ const list = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
+ setRooms(list);
+ });
+ return () => {
+ if (unsubRef.current) unsubRef.current();
+ };
+ }, [user]);
+
+ const handleCreate = async ({ title, description, language, isPrivate }) => {
+ setSubmitting(true);
+ try {
+ const roomId = await createRoom({ title, description, language, isPrivate });
+ setShowModal(false);
+ if (roomId) router.push(`/rooms/${roomId}`);
+ } catch (err) {
+ console.error("Failed to create room:", err);
+ alert("Failed to create room. Please try again.");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+ {/*
*/}
+
+
+
+
+
Collaboration Rooms
+
Create a room to code, chat, and collaborate in real time.
+
+
+
+
+ {rooms.length === 0 ? (
+
+
+
No rooms yet
+
+ Create the first room and start collaborating with other developers in real time.
+
+
+ ) : (
+
+ {rooms.map((room) => (
+
router.push(`/rooms/${room.id}`)}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.borderColor = "var(--accent-primary)";
+ e.currentTarget.style.boxShadow = "0 0 0 1px var(--accent-primary)";
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.borderColor = "var(--border-color)";
+ e.currentTarget.style.boxShadow = "none";
+ }}
+ >
+ {room.createdBy?.uid === user?.uid && (
+
+ )}
+
{room.title}
+ {room.description && (
+
{room.description}
+ )}
+
+ {room.language}
+
+ {room.members?.length || 1} member{(room.members?.length || 1) !== 1 ? "s" : ""}
+
+ {timeAgo(room.lastActivity)}
+ {room.isPrivate && (
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+ {showModal && (
+
setShowModal(false)}
+ onSubmit={handleCreate}
+ submitting={submitting}
+ />
+ )}
+
+
+ );
+}
diff --git a/components/dashboard/LeftSidebar.js b/components/dashboard/LeftSidebar.js
index d4cedae..1704fa9 100644
--- a/components/dashboard/LeftSidebar.js
+++ b/components/dashboard/LeftSidebar.js
@@ -1,5 +1,7 @@
"use client";
+import { useRouter } from "next/navigation";
+
const FEED_TABS = [
{ id: "latest", label: "Latest Feed", icon: "▦" },
{ id: "trending", label: "Trending", icon: "📈" },
@@ -113,6 +115,8 @@ export default function LeftSidebar({
onShowFeatureTour,
streak = 0,
}) {
+ const router = useRouter();
+
return (
+ {/* Rooms promo widget */}
+
+
{/* Trending Tags widget */}
Trending Tags
diff --git a/components/rooms/RoomCallControls.js b/components/rooms/RoomCallControls.js
new file mode 100644
index 0000000..744d014
--- /dev/null
+++ b/components/rooms/RoomCallControls.js
@@ -0,0 +1,748 @@
+"use client";
+
+import { useState, useEffect, useRef, useCallback } from "react";
+import {
+ PhoneOff,
+ Phone,
+ Mic,
+ MicOff,
+ Video,
+ VideoOff,
+ Monitor,
+ Loader2,
+ X,
+ AlertTriangle,
+ XCircle,
+ UserMinus,
+} from "lucide-react";
+import {
+ joinCall,
+ leaveCall,
+ toggleMicrophone,
+ toggleCamera,
+ startScreenShare,
+ stopScreenShare,
+ getLocalStream,
+ getScreenStream,
+ onCallEvent,
+} from "../../lib/webrtc";
+
+/* ─── Styles ─── */
+
+const S = {
+ container: {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 10,
+ padding: "10px 14px",
+ borderTop: "1px solid var(--border-color)",
+ backgroundColor: "var(--bg-secondary)",
+ flexShrink: 0,
+ position: "relative",
+ },
+ btn: {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ width: 36,
+ height: 36,
+ borderRadius: "50%",
+ border: "1px solid var(--border-color)",
+ backgroundColor: "var(--bg-primary)",
+ color: "var(--text-secondary)",
+ cursor: "pointer",
+ fontSize: "1rem",
+ transition: "background-color 0.15s, color 0.15s",
+ },
+ btnActive: {
+ backgroundColor: "var(--accent-primary)",
+ color: "#fff",
+ border: "none",
+ },
+ btnDanger: {
+ backgroundColor: "#ef4444",
+ color: "#fff",
+ border: "none",
+ },
+ btnJoin: {
+ backgroundColor: "#22c55e",
+ color: "#fff",
+ border: "none",
+ },
+ statusBadge: {
+ fontSize: "0.7rem",
+ fontWeight: 600,
+ padding: "2px 8px",
+ borderRadius: "var(--radius-sm)",
+ backgroundColor: "#22c55e22",
+ color: "#22c55e",
+ marginRight: 8,
+ },
+ statusBadgeConnecting: {
+ backgroundColor: "#f59e0b22",
+ color: "#f59e0b",
+ },
+ /* Floating panel (shared by self-view, remote video, screen share) */
+ floatingPanel: {
+ position: "fixed",
+ borderRadius: "var(--radius-md)",
+ overflow: "hidden",
+ border: "2px solid var(--border-color)",
+ backgroundColor: "#000",
+ zIndex: 50,
+ boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
+ display: "flex",
+ flexDirection: "column",
+ },
+ titleBar: {
+ height: 28,
+ backgroundColor: "rgba(0,0,0,0.7)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ padding: "0 8px",
+ fontSize: "0.65rem",
+ fontWeight: 600,
+ color: "#fff",
+ cursor: "move",
+ userSelect: "none",
+ flexShrink: 0,
+ },
+ titleBarLabel: {
+ display: "flex",
+ alignItems: "center",
+ gap: 4,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ },
+ video: {
+ width: "100%",
+ flex: 1,
+ objectFit: "cover",
+ display: "block",
+ backgroundColor: "#111",
+ },
+ videoContain: {
+ width: "100%",
+ flex: 1,
+ objectFit: "contain",
+ display: "block",
+ backgroundColor: "#111",
+ },
+ peerLabel: {
+ position: "absolute",
+ bottom: 4,
+ left: 6,
+ fontSize: "0.65rem",
+ fontWeight: 600,
+ color: "#fff",
+ backgroundColor: "rgba(0,0,0,0.5)",
+ padding: "1px 6px",
+ borderRadius: 4,
+ },
+ noVideoPlaceholder: {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ width: "100%",
+ flex: 1,
+ color: "var(--text-muted)",
+ fontSize: "2rem",
+ },
+ smallBtn: {
+ background: "none",
+ border: "none",
+ color: "#fff",
+ cursor: "pointer",
+ fontSize: "0.7rem",
+ padding: "2px 4px",
+ lineHeight: 1,
+ borderRadius: 3,
+ flexShrink: 0,
+ },
+ smallBtnDanger: {
+ color: "#ef4444",
+ },
+ /* Toast styles */
+ toastContainer: {
+ position: "fixed",
+ top: 16,
+ right: 16,
+ zIndex: 200,
+ display: "flex",
+ flexDirection: "column",
+ gap: 8,
+ pointerEvents: "none",
+ },
+ toast: {
+ pointerEvents: "auto",
+ padding: "10px 16px",
+ borderRadius: "var(--radius-md)",
+ fontSize: "0.82rem",
+ fontWeight: 500,
+ color: "#fff",
+ boxShadow: "0 4px 12px rgba(0,0,0,0.25)",
+ maxWidth: 340,
+ },
+ toastInfo: { backgroundColor: "#3b82f6" },
+ toastSuccess: { backgroundColor: "#22c55e" },
+ toastWarning: { backgroundColor: "#f59e0b" },
+ toastError: { backgroundColor: "#ef4444" },
+};
+
+/* ─── Toast system ─── */
+
+let toastIdCounter = 0;
+
+function ToastContainer({ toasts, onDismiss }) {
+ return (
+
+ {toasts.map((t) => (
+
onDismiss(t.id)}
+ >
+ {t.icon && {t.icon}}
+ {t.message}
+
+ ))}
+
+ );
+}
+
+/* ─── Pointer-based drag hook (works for mouse + touch) ─── */
+
+function useDraggable(initialPos, { boundsRef } = {}) {
+ const [pos, setPos] = useState(initialPos);
+ const dragRef = useRef(null);
+
+ // Clamp inside viewport
+ const clamp = useCallback((x, y, w, h) => {
+ const vw = typeof window !== "undefined" ? window.innerWidth : 1200;
+ const vh = typeof window !== "undefined" ? window.innerHeight : 800;
+ return {
+ x: Math.max(0, Math.min(x, vw - w)),
+ y: Math.max(0, Math.min(y, vh - h)),
+ };
+ }, []);
+
+ const onPointerDown = useCallback((e) => {
+ // Only drag from the handle (check dataset or parent)
+ const target = e.currentTarget;
+ target.setPointerCapture(e.pointerId);
+ dragRef.current = {
+ sx: e.clientX,
+ sy: e.clientY,
+ sp: { ...pos },
+ };
+ const onMove = (ev) => {
+ const d = dragRef.current;
+ if (!d) return;
+ const dx = ev.clientX - d.sx;
+ const dy = ev.clientY - d.sy;
+ setPos({ x: d.sp.x + dx, y: d.sp.y + dy });
+ };
+ const onUp = () => {
+ dragRef.current = null;
+ target.releasePointerCapture(e.pointerId);
+ target.removeEventListener("pointermove", onMove);
+ target.removeEventListener("pointerup", onUp);
+ };
+ target.addEventListener("pointermove", onMove);
+ target.addEventListener("pointerup", onUp);
+ }, [pos]);
+
+ return { pos, setPos, onPointerDown, clamp };
+}
+
+/* ─── Draggable Camera Preview (shows remote camera by default, or local) ─── */
+
+function CameraPreview({ stream, label, mirrored, videoEnabled, micEnabled }) {
+ const videoRef = useRef(null);
+ const audioRef = useRef(null);
+ const [pos, setPos] = useState({ x: 16, y: 16 });
+ const dragRef = useRef(null);
+
+ useEffect(() => {
+ const el = videoRef.current;
+ if (!el || !stream) return;
+ el.srcObject = stream;
+ el.play().catch(() => {});
+ }, [stream]);
+
+ // Play remote audio through hidden