diff --git a/README.md b/README.md index d4d04bd..fdd7d32 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,30 @@ -# Blogify πŸ“ +# OG Docs πŸ“ -**A modern, collaborative blogging platform built for speed, security, and developer-centric content control.** +**A modern, real-time collaborative documentation platform built for speed, security, and developer-centric content control.** ## About The Project -**Blogify** is a full-stack web application designed to streamline the way developers write and share articles. The project focuses on structured content delivery, moving away from standard plain-text storage to a robust, custom-tailored data architecture. +**OG Docs** is a full-stack web application designed to streamline the way teams and developers write, edit, and share documentation together. The platform emphasizes structured content delivery and real-time collaboration, moving beyond traditional plain-text storage toward a robust, custom-tailored data architecture. -Built as a high-performance **MERN** application, Blogify provides a seamless writing experience while giving authors granular control over their content's metadata and presentation. +Built as a high-performance **MERN** application, OG Docs enables multiple collaborators to work simultaneously in a shared workspace, with changes reflected instantly for everyoneβ€”ensuring smooth, efficient, and transparent collaboration. --- ## πŸš€ Key Features -* **Custom Slate.js Engine:** Uses a custom-built implementation to structure content into JSON format, ensuring consistent styling and portability. +* **Real-Time Collaborative Workspace:** A dedicated editing area where multiple collaborators can make changes simultaneously, with updates reflected in real time. +* **Custom Slate.js Engine:** A custom-built implementation that structures content into JSON format for consistent styling and long-term portability. * **Modern UI/UX:** A clean, responsive interface built with **React 19**, **Tailwind CSS 4.0**, and **CoreUI** components. -* **Secure Authentication:** Integrated **Google OAuth** for quick and secure user access. +* **Secure Authentication:** Integrated **Google OAuth** for fast and secure user access. * **Developer-Friendly Build:** Optimized with **Vite** for near-instant hot module replacement (HMR). -* **Robust Security:** Hardened backend using **Helmet**, **HPP**, **Rate Limiting**, and **Zod** for schema validation. +* **Robust Security:** Hardened backend using **Helmet**, **HPP**, **Rate Limiting**, and **Zod** for strict schema validation. --- ## πŸ›  The Tech Stack ### Frontend + * **Library:** React 19 * **Styling:** Tailwind CSS 4.0 & CoreUI * **Editor:** Slate.js (Custom JSON Content Engine) @@ -30,12 +32,14 @@ Built as a high-performance **MERN** application, Blogify provides a seamless wr * **Build Tool:** Vite ### Backend + * **Runtime:** Node.js * **Framework:** Express.js (v5) * **Authentication:** JWT & Google Auth Library * **Validation:** Zod ### Database + * **Database:** MongoDB * **ODM:** Mongoose @@ -43,51 +47,56 @@ Built as a high-performance **MERN** application, Blogify provides a seamless wr ## βš™οΈ How It Works -The core logic of Blogify revolves around a structured data pipeline: - -1. **The Input:** Authors use a custom editor powered by **Slate.js**. Instead of generating messy HTML, it outputs a clean **JSON tree** representing the document structure. -2. **The Storage:** This JSON object is validated via **Zod** and stored in **MongoDB** through **Mongoose** models. -3. **The Rendering:** On the frontend, our custom engine traverses the JSON tree and dynamically "paints" the content using Tailwind-styled React components. - +The core logic of OG Docs revolves around a structured, collaborative data pipeline: +1. **The Input:** Contributors write using a custom editor powered by **Slate.js**. Instead of generating raw HTML, the editor produces a clean **JSON tree** representing the document structure. +2. **The Collaboration Layer:** Multiple users can edit the same document simultaneously, with real-time updates synchronized across all active sessions. +3. **The Storage:** The JSON structure is validated using **Zod** and securely stored in **MongoDB** via **Mongoose** models. +4. **The Rendering:** On the frontend, a custom rendering engine traverses the JSON tree and dynamically paints the content using Tailwind-styled React components. --- ## πŸ“¦ Installation & Setup -Follow these steps to get a copy of Blogify running locally. +Follow these steps to run OG Docs locally. ### Prerequisites + * [Node.js](https://nodejs.org/) (Latest LTS) -* [MongoDB](https://www.mongodb.com/) account or local instance. +* [MongoDB](https://www.mongodb.com/) account or local instance ### Steps -1. **Clone the Repository** - ```bash - git clone [https://github.com/shamil-tp/Blogify.git](https://github.com/shamil-tp/Blogify.git) - cd blogify - ``` +1. **Clone the Repository** + + ```bash + git clone https://github.com/shamil-tp/Blogify.git + cd blogify + ``` + +2. **Setup Backend** + + ```bash + cd backend + npm install + ``` + + Create a `.env` file in the `backend` folder and add your `MONGODB_URI`, `JWT_SECRET`, and `PORT`. + +3. **Setup Frontend** -2. **Setup Backend** - ```bash - cd backend - npm install - ``` - Create a `.env` file in the `backend` folder and add your `MONGODB_URI`, `JWT_SECRET`, and `PORT`. + ```bash + cd ../frontend + npm install + ``` -3. **Setup Frontend** - ```bash - cd ../frontend - npm install - ``` +4. **Run Development Servers** -4. **Run Development Servers** - * **Backend:** `npm run test` (uses nodemon) - * **Frontend:** `npm run dev` (uses vite) + * **Backend:** `npm run test` (nodemon) + * **Frontend:** `npm run dev` (Vite) -5. **View the App** - Open `http://localhost:5173` for the frontend. +5. **View the App** + Open `http://localhost:5173` in your browser. --- @@ -95,6 +104,176 @@ Follow these steps to get a copy of Blogify running locally. This project was collaboratively designed and developed by: -* **Shamil** - [GitHub](https://github.com/shamil-tp) -* **Sinan** - [GitHub](https://github.com/sinanrahman/) -* **Ranfees** - [GitHub](https://github.com/Ranfees) \ No newline at end of file +* **Sinan** – [GitHub](https://github.com/sinanrahman/) +* **Hana** – [GitHub](https://github.com/Hana-Haris3) +* **Salih** – [GitHub](https://github.com/salih85) + +--- + +OG Docs is built with collaboration at its coreβ€”designed to help teams write, iterate, and ship documentation together, faster and cleaner. + + + + + + + + + + +frontend/src/ +β”œβ”€β”€ collaboration/ +β”‚ β”œβ”€β”€ socket.js +β”‚ β”œβ”€β”€ ydoc.js +β”‚ β”œβ”€β”€ awareness.js +β”‚ └── collabTypes.js +β”‚ +β”œβ”€β”€ hooks/ +β”‚ β”œβ”€β”€ useAuth.jsx +β”‚ └── useCollaboration.js ← NEW +β”‚ +β”œβ”€β”€ components/ +β”‚ └── GridEditor/ +β”‚ β”œβ”€β”€ GridEditor.jsx +β”‚ β”œβ”€β”€ withCollaboration.js ← NEW +β”‚ β”œβ”€β”€ TextWidget.jsx +β”‚ β”œβ”€β”€ ImageWidget.jsx +β”‚ └── VideoWidget.jsx + + + + + + + + +backend/ +β”œβ”€β”€ socket/ +β”‚ β”œβ”€β”€ index.js +β”‚ β”œβ”€β”€ auth.js +β”‚ β”œβ”€β”€ rooms.js +β”‚ └── handlers/ +β”‚ β”œβ”€β”€ joinDoc.js +β”‚ β”œβ”€β”€ syncUpdate.js +β”‚ β”œβ”€β”€ awareness.js +β”‚ └── disconnect.js +β”‚ +β”œβ”€β”€ collaboration/ +β”‚ β”œβ”€β”€ yjs/ +β”‚ β”‚ β”œβ”€β”€ createDoc.js +β”‚ β”‚ └── applyUpdate.js +β”‚ └── persistence/ +β”‚ β”œβ”€β”€ loadSnapshot.js +β”‚ └── saveSnapshot.js + + + + + +πŸ‘€ Teammate A β€” Backend (Realtime Engine) +🎯 Responsibility + +Everything related to: + +Socket.IO + +Yjs document sync + +MongoDB snapshots + +πŸ“ Files they touch +backend/ +β”œβ”€β”€ socket/ +β”œβ”€β”€ collaboration/ +β”œβ”€β”€ models/Blog.js + + +They do NOT touch frontend. + +πŸ”§ Their Tasks (Very Exact) +A1 β€” Socket.IO server + +Attach Socket.IO to Express + +JWT auth on connection + +Rooms per blogId + +A2 β€” Yjs document manager + +Create Yjs doc per blog + +Load collabSnapshot + +Keep docs in memory + +A3 β€” Sync protocol + +Receive Yjs updates + +Broadcast to room + +Prevent echo loops + +A4 β€” Persistence + +Save snapshot when: + +last user leaves + +OR every 30s + +πŸ“¦ Output + +socket.emit("doc:update", update) +socket.emit("doc:sync", state) + + + + + +πŸ‘€ Teammate B β€” Frontend (Quill + Yjs) +🎯 Responsibility + +Bind Quill editor β†’ Yjs β†’ Socket.IO. + +πŸ“ Files they touch +frontend/src/ +β”œβ”€β”€ collaboration/ +β”‚ β”œβ”€β”€ socket.js +β”‚ β”œβ”€β”€ ydoc.js +β”‚ └── awareness.js +β”‚ +β”œβ”€β”€ components/GridEditor/ +β”‚ β”œβ”€β”€ GridEditor.jsx +β”‚ └── withCollaboration.js + + +They do NOT touch backend. + +πŸ”§ Their Tasks (Very Exact) +B1 β€” Yjs + Quill binding + +Create Y.Doc + +Create Y.Text + +Bind using y-quill + +import { QuillBinding } from 'y-quill' + +B2 β€” Socket sync + +Send Yjs updates to backend + +Apply remote updates + +B3 β€” Presence & cursors + +Use quill-cursors + +Show colored cursors per user + +πŸ“¦ Output + + \ No newline at end of file diff --git a/backend/collaboration/persistence/loadSnapshot.js b/backend/collaboration/persistence/loadSnapshot.js new file mode 100644 index 0000000..46afbba --- /dev/null +++ b/backend/collaboration/persistence/loadSnapshot.js @@ -0,0 +1,20 @@ +// const Blog = require("../../models/Blog"); + +// const loadSnapshot = async (blogId) => { +// const blog = await Blog.findById(blogId); +// if (blog && blog.collabSnapshot) { +// return blog.collabSnapshot; +// } +// return null; +// }; + +// module.exports = { loadSnapshot }; +// collaboration/persistence/loadSnapshot.js +const Blog = require("../../models/Blog"); + +const loadSnapshot = async (blogId) => { + const blog = await Blog.findById(blogId); + return blog?.collabSnapshot || null; +}; + +module.exports = { loadSnapshot }; diff --git a/backend/collaboration/persistence/saveSnapshot.js b/backend/collaboration/persistence/saveSnapshot.js new file mode 100644 index 0000000..aab55dc --- /dev/null +++ b/backend/collaboration/persistence/saveSnapshot.js @@ -0,0 +1,29 @@ +// const Blog = require("../../models/Blog"); +// const Y = require("yjs"); + +// const saveSnapshot = async (blogId, ydoc) => { +// try { +// const update = Y.encodeStateAsUpdate(ydoc); +// await Blog.findByIdAndUpdate(blogId, { collabSnapshot: update }); +// setInterval(async () => { +// for (const [blogId, ydoc] of docs) { +// await saveSnapshot(blogId, ydoc); +// } +// }, 30_000); +// } catch (e) { +// console.error("Failed to save snapshot", e); +// } +// }; + +// module.exports = { saveSnapshot }; +// collaboration/persistence/saveSnapshot.js +const Blog = require("../../models/Blog"); +const Y = require("yjs"); + +const saveSnapshot = async (blogId, ydoc) => { + const snapshot = Y.encodeStateAsUpdate(ydoc); + await Blog.findByIdAndUpdate(blogId, { collabSnapshot: Buffer.from(snapshot) }); +}; + +module.exports = { saveSnapshot }; + diff --git a/backend/collaboration/socket.js b/backend/collaboration/socket.js new file mode 100644 index 0000000..c89c627 --- /dev/null +++ b/backend/collaboration/socket.js @@ -0,0 +1,23 @@ +import io from 'socket.io-client'; + +let socket = null; + +export const initSocket = (url, docId) => { + if (socket) return socket; + + socket = io(url, { query: { docId } }); + + socket.on('connect', () => { + console.log('πŸ”— Socket connected!', socket.id); + }); + + socket.on('connect_error', (err) => { + console.error('❌ Socket connection error:', err); + }); + + socket.on('disconnect', (reason) => { + console.log('⚠️ Socket disconnected:', reason); + }); + + return socket; +}; diff --git a/backend/collaboration/yjs/applyUpdate.js b/backend/collaboration/yjs/applyUpdate.js new file mode 100644 index 0000000..154c81e --- /dev/null +++ b/backend/collaboration/yjs/applyUpdate.js @@ -0,0 +1,9 @@ +// collaboration/yjs/applyUpdate.js +const Y = require("yjs"); + +const applyUpdate = (ydoc, update) => { + const uint8Update = update instanceof Uint8Array ? update : new Uint8Array(update); + Y.applyUpdate(ydoc, uint8Update); +}; + +module.exports = { applyUpdate }; diff --git a/backend/collaboration/yjs/createDoc.js b/backend/collaboration/yjs/createDoc.js new file mode 100644 index 0000000..76c681c --- /dev/null +++ b/backend/collaboration/yjs/createDoc.js @@ -0,0 +1,19 @@ +// collaboration/yjs/createDoc.js +const Y = require("yjs"); +const { loadSnapshot } = require("../persistence/loadSnapshot"); + +const docs = new Map(); + +const createDoc = async (blogId) => { + if (docs.has(blogId)) return docs.get(blogId); + + const ydoc = new Y.Doc(); + + const snapshot = await loadSnapshot(blogId); + if (snapshot) Y.applyUpdate(ydoc, snapshot); + + docs.set(blogId, ydoc); + return ydoc; +}; + +module.exports = { createDoc, docs }; diff --git a/backend/controllers/auth-controller.js b/backend/controllers/auth-controller.js index b9409a7..f8c157e 100644 --- a/backend/controllers/auth-controller.js +++ b/backend/controllers/auth-controller.js @@ -37,7 +37,7 @@ const googleLogin = async (req, res) => { }; await user.save(); - res.status(200).json({ + return res.status(200).json({ success: true, accessToken, refreshToken, @@ -46,7 +46,7 @@ const googleLogin = async (req, res) => { } catch (error) { console.error("Authentication Error Details:", error.message); - res.status(401).json({ + return res.status(401).json({ success: false, message: "Google authentication failed" }); @@ -71,7 +71,7 @@ const refresh = async (req, res) => { const newAccessToken = generateAccessToken(user._id); - res.status(200).json({ + return res.status(200).json({ accessToken: newAccessToken, }); @@ -86,11 +86,11 @@ const logout = async (req, res) => { await User.findByIdAndUpdate(req.user._id, { $unset: { Rtoken: 1 } }); } - res.status(200).json({ success: true, message: "Logged out successfully" }); + return res.status(200).json({ success: true, message: "Logged out successfully" }); }; const me = (req, res) => { - res.status(200).json({ user: req.user }); + return res.status(200).json({ user: req.user }); }; module.exports = { googleLogin, logout, me, refresh }; diff --git a/backend/controllers/blogController.js b/backend/controllers/blogController.js index 07d6656..9f13995 100644 --- a/backend/controllers/blogController.js +++ b/backend/controllers/blogController.js @@ -1,4 +1,6 @@ const Blog = require('../models/Blog') +const User = require('../models/User') +const { sendEmail } = require('../utils/emailService') const slugify = require('slugify') const { nanoid } = require('nanoid') @@ -72,7 +74,14 @@ exports.getUserBlogs = async (req, res) => { return res.status(401).json({ message: 'user not found' }) } - const userBlogs = await Blog.find({ author: userId }).populate('author', 'name') + const userBlogs = await Blog.find({ + $or: [ + { author: userId }, + { 'collaborators.email': req.user.email } + ] + }) + .populate('author', 'name email picture') // Added populate + .sort({ updatedAt: -1 }); if (!userBlogs) { return res.status(401).json({ message: "no blogs found" }) @@ -104,7 +113,7 @@ exports.deleteUserPost = async (req, res) => { if (result.deletedCount === 1) { res.status(200).json({ message: "Successfully deleted the blog post." }) - } + } else { console.log("No post found with that ID or you are not the author."); res.status(401).json({ message: "No post found with that ID or you are not the author." }) @@ -115,47 +124,171 @@ exports.deleteUserPost = async (req, res) => { } } +// exports.getBlogById = async (req, res) => { +// try { +// let blogId = req.params.postId +// const blog = await Blog.findOne({ _id: blogId }) + +// if (!blog) { +// return res.status(404).json({ +// success: false, +// message: "Blog not found" +// }) +// } + +// res.status(200).json({ +// success: true, +// blog +// }) + +// } catch (e) { +// console.log(e) +// return res.status(500).json({ message: 'error from backend', error: e }) +// } +// } + exports.getBlogById = async (req, res) => { try { - let blogId = req.params.postId - const blog = await Blog.findOne({ _id: blogId }) + const blogId = req.params.postId; - if (!blog) { - return res.status(404).json({ - success: false, - message: "Blog not found" - }) + if (!blogId || blogId === "null") { + return res.status(400).json({ message: "Invalid blog id" }); } - res.status(200).json({ - success: true, - blog - }) + const blog = await Blog.findById(blogId); + + if (!blog) { + return res.status(404).json({ message: "Blog not found" }); + } + res.json({ success: true, blog }); } catch (e) { - console.log(e) - return res.status(500).json({ message: 'error from backend', error: e }) + res.status(500).json({ message: "error from backend", error: e }); } -} +}; exports.updateBlog = async (req, res) => { try { - const { title, content } = req.body; + const { title, content, published } = req.body; const blogId = req.params.postId; const userId = req.user._id; - const blog = await Blog.findOneAndUpdate( - { _id: blogId, author: userId }, - { title, content }, - { new: true } - ); + const blog = await Blog.findById(blogId); if (!blog) { - return res.status(404).json({ message: "Blog not found or unauthorized" }); + return res.status(404).json({ message: "Blog not found" }); } + const isAuthor = blog.author.toString() === userId.toString(); + const collaborator = blog.collaborators.find(c => c.email === req.user.email); + const canEdit = isAuthor || (collaborator && collaborator.role === 'edit'); + + if (!canEdit) { + return res.status(403).json({ message: "You do not have permission to edit this blog" }); + } + + blog.title = title; + blog.content = content; + blog.published = published ?? blog.published; + + await blog.save(); + res.status(200).json({ success: true, blog }); } catch (e) { res.status(500).json({ message: "Update failed", error: e }); } }; + + +exports.createDraft = async (req, res) => { + const blog = await Blog.create({ + author: req.user._id, + title: '', + published: false, + slug: slugify(`draft-${Date.now()}`, { lower: true }), + content: [] + }) + res.json({ blog }) +} + +exports.shareBlog = async (req, res) => { + console.log("Sharing blog route hit!", { blogId: req.params.postId, email: req.body.email }); + try { + const { email, role } = req.body; + const blogId = req.params.postId; + const userId = req.user._id; + + if (!email || !role) { + return res.status(400).json({ message: "Email and role are required" }); + } + + const blog = await Blog.findById(blogId); + + if (!blog) { + return res.status(404).json({ message: "Blog not found" }); + } + + // Only author can share + if (blog.author.toString() !== userId.toString()) { + return res.status(403).json({ message: "Only the author can share this blog" }); + } + + // Check if user already has access + const existingCollaborator = blog.collaborators.find(c => c.email === email); + if (existingCollaborator) { + existingCollaborator.role = role; + } else { + blog.collaborators.push({ email, role }); + } + + await blog.save(); + + // Send email notification + const inviteLink = `${process.env.FRONTEND_URL}/blog/${blogId}`; + const subject = `Invitation to collaborate on "${blog.title || 'Untitled Blog'}"`; + const text = `You have been invited to ${role} the blog "${blog.title || 'Untitled Blog'}". Access it here: ${inviteLink}`; + const html = `

You have been invited to ${role} the blog "${blog.title || 'Untitled Blog'}".

Access it here: ${inviteLink}

`; + + try { + await sendEmail(email, subject, text, html); + } catch (emailError) { + console.error("Detailed Email Error:", emailError); + return res.status(200).json({ + success: true, + message: `Collaborator added, but email failed: ${emailError.message}. Check your RESEND_API_KEY settings.` + }); + } + + res.status(200).json({ success: true, message: "Invitation sent successfully" }); + } catch (e) { + console.error(e); + res.status(500).json({ message: "Sharing failed", error: e.message }); + } +}; + +exports.removeCollaborator = async (req, res) => { + try { + const { email } = req.body; + const blogId = req.params.postId; + const userId = req.user._id; + + const blog = await Blog.findById(blogId); + + if (!blog) { + return res.status(404).json({ message: "Blog not found" }); + } + + // Only author can remove collaborators + if (blog.author.toString() !== userId.toString()) { + return res.status(403).json({ message: "Only the author can remove collaborators" }); + } + + blog.collaborators = blog.collaborators.filter(c => c.email !== email); + await blog.save(); + + res.status(200).json({ success: true, message: "Collaborator removed successfully" }); + } catch (e) { + console.error(e); + res.status(500).json({ message: "Action failed", error: e.message }); + } +}; \ No newline at end of file diff --git a/backend/index.js b/backend/index.js index f42d254..72fdb14 100644 --- a/backend/index.js +++ b/backend/index.js @@ -1,7 +1,9 @@ require("dotenv").config(); const express = require("express"); const cors = require("cors"); -const connectDB = require("./config/db"); +const helmet = require("helmet"); +const rateLimit = require("express-rate-limit"); +const connectDB = require("./config/db"); const authRoutes = require("./routes/authRoutes"); const blogRoutes = require("./routes/blogRoutes"); @@ -9,39 +11,92 @@ const blogRoutes = require("./routes/blogRoutes"); const app = express(); const PORT = process.env.PORT || 3000; -const helmet = require('helmet') -const rateLimit = require('express-rate-limit') -const hpp = require('hpp') +// 1. CORS - MUST BE FIRST to catch all requests and provide headers +app.use( + cors({ + origin: (origin, callback) => { + if (!origin) return callback(null, true); + + const allowedOrigins = [ + "http://localhost:5173", + "http://localhost:5174", + "http://localhost:5175", + process.env.FRONTEND_URL, + "https://og-doc.vercel.app", + ]; + const isAllowed = allowedOrigins.some(ao => ao && ao.replace(/\/$/, '').toLowerCase() === origin.replace(/\/$/, '').toLowerCase()) || + origin.endsWith(".onrender.com") || + origin.includes("ngrok-free") || + origin.endsWith(".vercel.app"); + + if (isAllowed) { + callback(null, true); + } else { + console.error("❌ CORS Blocked:", origin); + callback(new Error('CORS not allowed for this origin')); + } + }, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept'], + exposedHeaders: ['Set-Cookie'] + }) +); + +// 2. Security Headers (configured to allow cross-origin communication) +app.use(helmet({ + crossOriginOpenerPolicy: { policy: "same-origin-allow-popups" }, + crossOriginResourcePolicy: { policy: "cross-origin" }, + crossOriginEmbedderPolicy: false, + contentSecurityPolicy: false, + referrerPolicy: { policy: "strict-origin-when-cross-origin" }, +})); + +// 3. Rate Limiting (moved below CORS so preflight OPTIONS requests aren't blocked silently) const limiter = rateLimit({ - windowMs: 15 * 60 * 1000, - limit: 100, - standardHeaders: 'draft-8', - legacyHeaders: false, - ipv6Subnet: 56, + windowMs: 15 * 60 * 1000, + limit: 500, // Increased limit for testing + standardHeaders: 'draft-8', + legacyHeaders: false, }) - app.use(limiter) -app.use(helmet()) - -app.use( - cors({ - origin: ["http://localhost:5173", "http://localhost:5174", "http://localhost:5175",process.env.FRONTEND_URL,process.env.LIBRARY_URL], - credentials:true - }) -); +const hpp = require('hpp') +const http = require("http") +const initSocket = require("./socket"); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(hpp()) connectDB() +// Health check endpoint +app.get('/health', (req, res) => { + res.status(200).json({ status: 'ok', message: 'Server is running' }); +}); + +// Root endpoint +app.get('/', (req, res) => { + res.status(200).json({ + message: 'ogDoc API Server', + version: '1.0.0', + endpoints: { + auth: '/api/auth', + blog: '/api/blog' + } + }); +}); + app.use("/api/auth", authRoutes); -app.use("/api", blogRoutes); +app.use("/api/blog", blogRoutes); if (require.main === module) { - app.listen(PORT); -} + const server = http.createServer(app); + initSocket(server); + + console.log(`Attempting to listen on port ${PORT}`); + server.listen(PORT, () => console.log(`Server running on ${PORT}`)); +} module.exports = app; \ No newline at end of file diff --git a/backend/middlewares/authMiddleware.js b/backend/middlewares/authMiddleware.js index d567e8d..1ec8fa1 100644 --- a/backend/middlewares/authMiddleware.js +++ b/backend/middlewares/authMiddleware.js @@ -15,12 +15,10 @@ const protect = async (req, res, next) => { next(); } catch (error) { console.log("Auth Middleware: Token Verification Failed:", error.message); - res.status(401).json({ message: "Not authorized" }); + return res.status(401).json({ message: "Not authorized" }); } - } - - if (!token) { - res.status(401).json({ message: "Not authorized, no token" }); + } else { + return res.status(401).json({ message: "Not authorized, no token" }); } }; diff --git a/backend/models/Blog.js b/backend/models/Blog.js index f80246e..3246292 100644 --- a/backend/models/Blog.js +++ b/backend/models/Blog.js @@ -1,3 +1,34 @@ +// const mongoose = require('mongoose') + +// const blogSchema = new mongoose.Schema( +// { +// author: { +// type: mongoose.Schema.Types.ObjectId, +// ref: 'User', +// required: true +// }, +// title: { +// type: String, +// required: true, +// trim: true +// }, +// slug: { +// type: String, +// required: true, +// unique: true +// }, +// content: { +// type: Array, +// required: true, +// default: [] +// } +// }, +// { timestamps: true } +// ) + +// module.exports = mongoose.model('Blog', blogSchema) + + const mongoose = require('mongoose') const blogSchema = new mongoose.Schema( @@ -7,21 +38,55 @@ const blogSchema = new mongoose.Schema( ref: 'User', required: true }, + title: { type: String, - required: true, - trim: true + trim: true, + required: function () { + return this.published === true + } }, slug: { type: String, required: true, unique: true }, + content: { type: Array, - required: true, default: [] - } + }, + published: { + type: Boolean, + default: false + }, + + // Yjs snapshot for collaboration + collabSnapshot: { + type: Buffer, // store encoded Yjs state + default: null + }, + + // Optional: track who is currently editing + activeEditors: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + } + ], + + // Collaboration versioning (future-proof) + collabVersion: { + type: Number, + default: 1 + }, + + collaborators: [ + { + email: { type: String, required: true }, + role: { type: String, enum: ['view', 'edit'], default: 'view' } + } + ] }, { timestamps: true } ) diff --git a/backend/package-lock.json b/backend/package-lock.json index 87a95d9..9d9dbe6 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,7 +20,12 @@ "jsonwebtoken": "^9.0.3", "mongoose": "^9.1.5", "nanoid": "^3.3.11", + "nodemailer": "^8.0.1", + "resend": "^6.9.2", "slugify": "^1.6.6", + "socket.io": "^4.8.3", + "socket.io-client": "^4.8.3", + "yjs": "^13.6.29", "zod": "^4.3.6" }, "devDependencies": { @@ -78,6 +83,36 @@ "node": ">=14" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "25.2.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.2.tgz", + "integrity": "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, "node_modules/@types/webidl-conversions": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", @@ -195,6 +230,15 @@ ], "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -553,6 +597,91 @@ "node": ">= 0.8" } }, + "node_modules/engine.io": { + "version": "6.6.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz", + "integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -665,6 +794,12 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -1213,6 +1348,16 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❀", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -1289,6 +1434,27 @@ "node": ">=18.0.0" } }, + "node_modules/lib0": { + "version": "0.2.117", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❀", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lodash": { "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", @@ -1615,6 +1781,15 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.11", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", @@ -1759,6 +1934,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/postal-mime": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz", + "integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==", + "license": "MIT-0" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1840,6 +2021,27 @@ "node": ">=8.10.0" } }, + "node_modules/resend": { + "version": "6.9.2", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.9.2.tgz", + "integrity": "sha512-uIM6CQ08tS+hTCRuKBFbOBvHIGaEhqZe8s4FOgqsVXSbQLAhmNWpmUhG3UAtRnmcwTWFUqnHa/+Vux8YGPyDBA==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.3", + "svix": "1.84.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/rimraf": { "version": "5.0.10", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", @@ -2093,6 +2295,105 @@ "node": ">=8.0.0" } }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.18.3" + } + }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/sparse-bitfield": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", @@ -2102,6 +2403,16 @@ "memory-pager": "^1.0.2" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2220,6 +2531,16 @@ "node": ">=4" } }, + "node_modules/svix": { + "version": "1.84.1", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz", + "integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2285,6 +2606,12 @@ "dev": true, "license": "MIT" }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -2294,6 +2621,19 @@ "node": ">= 0.8" } }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -2446,6 +2786,52 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/yjs": { + "version": "13.6.29", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.29.tgz", + "integrity": "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❀", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/backend/package.json b/backend/package.json index a62d360..5733a4d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,7 +23,12 @@ "jsonwebtoken": "^9.0.3", "mongoose": "^9.1.5", "nanoid": "^3.3.11", + "nodemailer": "^8.0.1", + "resend": "^6.9.2", "slugify": "^1.6.6", + "socket.io": "^4.8.3", + "socket.io-client": "^4.8.3", + "yjs": "^13.6.29", "zod": "^4.3.6" }, "devDependencies": { diff --git a/backend/routes/blogRoutes.js b/backend/routes/blogRoutes.js index 8a9d114..7805c6e 100644 --- a/backend/routes/blogRoutes.js +++ b/backend/routes/blogRoutes.js @@ -1,17 +1,38 @@ const express = require("express"); const { protect } = require("../middlewares/authMiddleware"); -const { addBlog, getBlog, getUserBlogs, deleteUserPost, getBlogById, updateBlog } = require("../controllers/blogController"); +const { + addBlog, + getBlog, + getUserBlogs, + deleteUserPost, + getBlogById, + updateBlog, + createDraft, + shareBlog, + removeCollaborator +} = require("../controllers/blogController"); const router = express.Router(); -router.get("/viewblog/:slug", getBlog); +/** + * ALL ROUTES HERE ARE PREFIXED WITH /api/blog (via index.js) + */ + +// Debugging +router.get("/ping", (req, res) => res.json({ message: "Blog routes active" })); -router.post("/blog/postblog", protect, addBlog); -router.get("/blog/user-blogs",protect,getUserBlogs) -router.get("/blog/deleteblog/:postId",protect,deleteUserPost) -router.get("/blog/:postId",getBlogById) -router.post("/blog/updateblog/:postId", protect, updateBlog); +// MAIN SHARE ROUTE +router.post("/share-post/:postId", protect, shareBlog); +router.post("/remove-collaborator/:postId", protect, removeCollaborator); +// Other Routes +router.post("/create-draft", protect, createDraft); +router.get("/user-blogs", protect, getUserBlogs); +router.get("/deleteblog/:postId", protect, deleteUserPost); +router.get("/:postId", getBlogById); +router.post("/updateblog/:postId", protect, updateBlog); +router.post("/postblog", protect, addBlog); +router.get("/viewblog/:slug", getBlog); module.exports = router; diff --git a/backend/socket/auth.js b/backend/socket/auth.js new file mode 100644 index 0000000..80f153a --- /dev/null +++ b/backend/socket/auth.js @@ -0,0 +1,26 @@ +// socket/auth.js +const jwt = require("jsonwebtoken"); +const User = require("../models/User"); + +const authSocket = async (socket, next) => { + try { + const token = socket.handshake.auth?.token; + + if (token) { + // JWT user + const decoded = jwt.verify(token, process.env.JWT_SECRET); + socket.user = await User.findById(decoded.userId).select("-password"); + } else { + // Guest user (anyone with link) + const ip = socket.handshake.address.replace(/[:.]/g, ""); // sanitize IP + socket.user = { _id: `guest-${ip}-${socket.id}`, name: `Guest-${ip}` }; + } + + next(); + } catch (err) { + console.log("Socket Auth Error:", err.message); + next(new Error("Authentication failed")); + } +}; + +module.exports = authSocket; diff --git a/backend/socket/handlers/awareness.js b/backend/socket/handlers/awareness.js new file mode 100644 index 0000000..16ec4a9 --- /dev/null +++ b/backend/socket/handlers/awareness.js @@ -0,0 +1,11 @@ +const rooms = require("../rooms"); + +module.exports = (io, socket, { blogId, awareness }) => { + if (!rooms[blogId]) return; + + // Broadcast awareness (cursor, selection) + socket.to(blogId).emit("doc:awareness", { + userId: socket.user._id, + awareness + }); +}; diff --git a/backend/socket/handlers/disconnect.js b/backend/socket/handlers/disconnect.js new file mode 100644 index 0000000..c50b445 --- /dev/null +++ b/backend/socket/handlers/disconnect.js @@ -0,0 +1,22 @@ +const { leaveRoom, rooms } = require("../rooms"); +const { saveSnapshot } = require("../../collaboration/persistence/saveSnapshot"); +const { docs } = require("../../collaboration/yjs/createDoc"); + +const disconnect = async (io, socket) => { + rooms.forEach(async (socketsSet, blogId) => { + if (socketsSet.has(socket.id)) { + leaveRoom(blogId, socket); + + // if last user left, persist snapshot + if (!rooms.has(blogId)) { + const ydoc = docs.get(blogId); + if (ydoc) { + await saveSnapshot(blogId, ydoc); + // Note: docs.delete(blogId) should probably happen here if we want to free memory + } + } + } + }); +}; + +module.exports = { disconnect }; diff --git a/backend/socket/handlers/joinDoc.js b/backend/socket/handlers/joinDoc.js new file mode 100644 index 0000000..c28bc94 --- /dev/null +++ b/backend/socket/handlers/joinDoc.js @@ -0,0 +1,32 @@ +const Y = require("yjs"); +const { createDoc } = require("../../collaboration/yjs/createDoc"); +const { joinRoom } = require("../rooms"); + +const joinDoc = async (io, socket, blogId) => { + socket.join(blogId); + joinRoom(blogId, socket); + + // Load or create Yjs document + const ydoc = await createDoc(blogId); + + // Send current Yjs state to this socket + const state = Y.encodeStateAsUpdate(ydoc); + socket.emit("doc:sync", state); + + // Build users list WITH socketId + const usersInRoom = Array.from( + io.sockets.adapter.rooms.get(blogId) || [] + ).map((id) => { + const s = io.sockets.sockets.get(id); + return { + name: s.user?.name || "Anonymous", + email: s.user?.email || null, + socketId: id, + }; + }); + + // Emit users to everyone in the room + io.to(blogId).emit("doc:users", usersInRoom); +}; + +module.exports = { joinDoc }; diff --git a/backend/socket/handlers/syncUpdate.js b/backend/socket/handlers/syncUpdate.js new file mode 100644 index 0000000..34df314 --- /dev/null +++ b/backend/socket/handlers/syncUpdate.js @@ -0,0 +1,16 @@ +// socket/handlers/syncUpdate.js +const { applyUpdate } = require("../../collaboration/yjs/applyUpdate"); +const { docs } = require("../../collaboration/yjs/createDoc"); + +const syncUpdate = (io, socket, blogId, update) => { + const ydoc = docs.get(blogId); + if (!ydoc) return; + + // Apply update to Yjs doc + applyUpdate(ydoc, update); + + // Broadcast to everyone else + socket.to(blogId).emit("doc:update", update); +}; + +module.exports = { syncUpdate }; diff --git a/backend/socket/index.js b/backend/socket/index.js new file mode 100644 index 0000000..ac29123 --- /dev/null +++ b/backend/socket/index.js @@ -0,0 +1,96 @@ +const { Server } = require("socket.io"); +const authSocket = require("./auth"); +const { joinDoc } = require("./handlers/joinDoc"); +const { syncUpdate } = require("./handlers/syncUpdate"); +const { disconnect } = require("./handlers/disconnect"); + +const initSocket = (server) => { + const io = new Server(server, { + cors: { + origin: [ + "http://localhost:5173", + "http://localhost:5174", + "http://localhost:5175", + "http://192.168.29.93:5173", + ], + credentials: true + }, + }); + + io.use(authSocket); + + io.on("connection", (socket) => { + console.log("Socket connected:", socket.id, socket.user?.name); + + // ================= DOC COLLAB ================= + socket.on("doc:join", async (blogId) => { + await joinDoc(io, socket, blogId); + }); + + socket.on("doc:update", async (data) => { + await syncUpdate(io, socket, data.blogId, data.update); + }); + + socket.on("doc:awareness", ({ blogId, awareness }) => { + socket.to(blogId).emit("doc:awareness", awareness); + }); + + // ================= VIDEO CALL ================= + socket.on("call:join", (blogId) => { + socket.join(`call-${blogId}`); + socket.to(`call-${blogId}`).emit("call:user-joined", { + socketId: socket.id, + }); + }); + + socket.on("call:signal", ({ to, signal }) => { + io.to(to).emit("call:signal", { + from: socket.id, + signal, + }); + }); + + socket.on("call:leave", (blogId) => { + socket.leave(`call-${blogId}`); + socket.to(`call-${blogId}`).emit("call:user-left", socket.id); + }); + + + // INVITE + socket.on("call:invite", ({ blogId, to }) => { + io.to(to).emit("call:incoming", { + from: socket.id, + blogId, + user: socket.user, + }); + }); + + // KICK + socket.on("doc:kick", ({ blogId, socketId }) => { + io.to(socketId).emit("doc:kicked"); + }); + + // ACCEPT + socket.on("call:accept", ({ to, blogId }) => { + io.to(to).emit("call:accepted", { + from: socket.id, + blogId, + }); + }); + + // DECLINE + socket.on("call:decline", ({ to }) => { + io.to(to).emit("call:declined", { + from: socket.id, + }); + }); + + + socket.on("disconnect", () => { + disconnect(io, socket); + console.log("Socket disconnected:", socket.id); + }); + }); +}; + +module.exports = initSocket; diff --git a/backend/socket/registerHandlers.js b/backend/socket/registerHandlers.js new file mode 100644 index 0000000..22a96eb --- /dev/null +++ b/backend/socket/registerHandlers.js @@ -0,0 +1,20 @@ +// const joinDoc = require("./handlers/joinDoc"); +// const syncUpdate = require("./handlers/syncUpdate"); +// const awareness = require("./handlers/awareness"); +// const disconnect = require("./handlers/disconnect"); + +// module.exports = (io, socket) => { +// socket.on("doc:join", (data) => joinDoc(io, socket, data)); +// socket.on("doc:update", (data) => syncUpdate(io, socket, data)); +// socket.on("doc:awareness", (data) => awareness(io, socket, data)); +// socket.on("disconnect", () => disconnect(io, socket)); +// }; +const joinDoc = require('./handlers/joinDoc'); +const syncUpdate = require('./handlers/syncUpdate'); +const disconnect = require('./handlers/disconnect'); + +module.exports = (io, socket) => { + socket.on('doc:join', (data) => joinDoc(io, socket, data)); + socket.on('sync:update', (data) => syncUpdate(io, socket, data)); + socket.on('disconnect', () => disconnect(io, socket)); +}; diff --git a/backend/socket/rooms.js b/backend/socket/rooms.js new file mode 100644 index 0000000..8096759 --- /dev/null +++ b/backend/socket/rooms.js @@ -0,0 +1,14 @@ +const rooms = new Map(); // blogId -> Set of socket ids + +const joinRoom = (blogId, socket) => { + if (!rooms.has(blogId)) rooms.set(blogId, new Set()); + rooms.get(blogId).add(socket.id); +}; + +const leaveRoom = (blogId, socket) => { + if (!rooms.has(blogId)) return; + rooms.get(blogId).delete(socket.id); + if (rooms.get(blogId).size === 0) rooms.delete(blogId); +}; + +module.exports = { joinRoom, leaveRoom, rooms }; diff --git a/backend/utils/emailService.js b/backend/utils/emailService.js new file mode 100644 index 0000000..4efcf2b --- /dev/null +++ b/backend/utils/emailService.js @@ -0,0 +1,37 @@ +const { Resend } = require("resend"); + +const resend = new Resend(process.env.RESEND_API_KEY); + +const sendEmail = async (to, subject, text, html) => { + console.log("Resend Service Attempt:", { + apiKey: process.env.RESEND_API_KEY ? "DETECTED" : "MISSING", + to + }); + + if (!process.env.RESEND_API_KEY) { + throw new Error("Missing RESEND_API_KEY environment variable"); + } + + try { + const { data, error } = await resend.emails.send({ + from: "ogDoc@shamiltp.me", + to, + subject, + text, + html, + }); + + if (error) { + console.error("Resend API Error:", error); + throw new Error(error.message); + } + + console.log("Email sent successfully via Resend:", data.id); + return data; + } catch (error) { + console.error("Resend execution error:", error); + throw error; + } +}; + +module.exports = { sendEmail }; diff --git a/backend/utils/tokenUtil.js b/backend/utils/tokenUtil.js index df16a2e..bd42159 100644 --- a/backend/utils/tokenUtil.js +++ b/backend/utils/tokenUtil.js @@ -1,7 +1,7 @@ const jwt = require("jsonwebtoken"); const generateAccessToken = (userId) => { - return jwt.sign({ userId }, process.env.JWT_SECRET, { expiresIn: "15m" }); + return jwt.sign({ userId }, process.env.JWT_SECRET, { expiresIn: "24h" }); }; const generateRefreshToken = (userId) => { diff --git a/diagnose-404.ps1 b/diagnose-404.ps1 new file mode 100644 index 0000000..96474cb --- /dev/null +++ b/diagnose-404.ps1 @@ -0,0 +1,69 @@ +# 404 Error Diagnostic Script +# Run this to help identify what's causing the 404 error + +Write-Host "ogDoc 404 Error Diagnostic Tool" -ForegroundColor Cyan +Write-Host "===================================" -ForegroundColor Cyan +Write-Host "" + +# Test 1: Check Backend Health +Write-Host "1. Testing Backend Health..." -ForegroundColor Yellow +try { + $backendHealth = Invoke-RestMethod -Uri "https://ogdoc.onrender.com/health" -Method Get -TimeoutSec 10 + if ($backendHealth.status -eq "ok") { + Write-Host " OK: Backend is healthy!" -ForegroundColor Green + Write-Host " Response: $($backendHealth | ConvertTo-Json -Compress)" -ForegroundColor Gray + } else { + Write-Host " Warning: Backend returned unexpected response" -ForegroundColor Yellow + } +} catch { + Write-Host " Error: Backend health check failed!" -ForegroundColor Red + Write-Host " Message: $($_.Exception.Message)" -ForegroundColor Red +} +Write-Host "" + +# Test 2: Check Local Backend +Write-Host "2. Testing Local Backend (Port 5001)..." -ForegroundColor Yellow +try { + $localBackend = Invoke-RestMethod -Uri "http://localhost:5001/health" -Method Get -TimeoutSec 2 + Write-Host " OK: Local backend is running on port 5001" -ForegroundColor Green +} catch { + Write-Host " Info: Local backend not running on 5001" -ForegroundColor Gray +} +Write-Host "" + +# Test 3: Check Frontend +Write-Host "3. Testing Frontend..." -ForegroundColor Yellow +try { + $frontendResponse = Invoke-WebRequest -Uri "https://ogdoc-1.onrender.com/" -Method Get -TimeoutSec 10 -UseBasicParsing + Write-Host " OK: Frontend is accessible!" -ForegroundColor Green + Write-Host " Status Code: $($frontendResponse.StatusCode)" -ForegroundColor Gray +} catch { + Write-Host " Error: Frontend failed!" -ForegroundColor Red + Write-Host " Message: $($_.Exception.Message)" -ForegroundColor Red +} +Write-Host "" + +# Test 4: Check if local dev server is running +Write-Host "4. Checking Local Dev Server (Port 5173)..." -ForegroundColor Yellow +try { + $localResponse = Invoke-WebRequest -Uri "http://localhost:5173/" -Method Get -TimeoutSec 2 -UseBasicParsing + Write-Host " OK: Local dev server is running on port 5173" -ForegroundColor Green +} catch { + Write-Host " Info: Local dev server not running" -ForegroundColor Gray +} +Write-Host "" + +Write-Host "Summary & Next Steps:" -ForegroundColor Cyan +Write-Host "========================" -ForegroundColor Cyan +Write-Host "" +Write-Host "If you're seeing 404 errors, please provide:" -ForegroundColor White +Write-Host "1. The EXACT URL showing the 404 (from network tab)" -ForegroundColor White +Write-Host "2. Check if VITE_BACKEND_URL in frontend/.env matches your intended backend" -ForegroundColor White +Write-Host "" +Write-Host "Common Issues:" -ForegroundColor Yellow +Write-Host "- Testing locally without rebuilding: Run 'npm run dev' in frontend folder" -ForegroundColor Gray +Write-Host "- Render still deploying: Wait 2-3 minutes after git push" -ForegroundColor Gray +Write-Host "- Browser cache: Hard refresh with Ctrl+Shift+R" -ForegroundColor Gray +Write-Host "- Missing render.json: Ensure it's in your build output" -ForegroundColor Gray +Write-Host "" + diff --git a/frontend/index.html b/frontend/index.html index 089e053..14c7e9d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,10 +2,13 @@ - + + + + - blogify + ogDoc
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b3836fb..078cbd5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,13 +12,16 @@ "@coreui/icons": "^3.0.1", "@coreui/icons-react": "^2.3.0", "@coreui/react": "^5.9.2", + "@ngrok/ngrok": "^1.7.0", "@react-oauth/google": "^0.13.4", "@tailwindcss/vite": "^4.1.18", "axios": "^1.13.2", "classnames": "^2.5.1", "is-hotkey": "^0.2.0", "lodash": "^4.17.23", + "ogl": "^1.0.11", "quill": "^2.0.3", + "quill-cursors": "^4.0.4", "react": "^19.2.0", "react-dom": "^19.2.0", "react-grid-layout": "^2.2.2", @@ -26,7 +29,11 @@ "react-router-dom": "^7.12.0", "react-secure-storage": "^1.3.2", "slate-history": "^0.113.1", + "socket.io-client": "^4.8.3", "uuid": "^13.0.0", + "y-protocols": "^1.0.7", + "y-quill": "^1.0.0", + "yjs": "^13.6.29", "zod": "^4.3.6" }, "devDependencies": { @@ -921,6 +928,235 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@ngrok/ngrok": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok/-/ngrok-1.7.0.tgz", + "integrity": "sha512-P06o9TpxrJbiRbHQkiwy/rUrlXRupc+Z8KT4MiJfmcdWxvIdzjCaJOdnNkcOTs6DMyzIOefG5tvk/HLdtjqr0g==", + "license": "(MIT OR Apache-2.0)", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@ngrok/ngrok-android-arm64": "1.7.0", + "@ngrok/ngrok-darwin-arm64": "1.7.0", + "@ngrok/ngrok-darwin-universal": "1.7.0", + "@ngrok/ngrok-darwin-x64": "1.7.0", + "@ngrok/ngrok-freebsd-x64": "1.7.0", + "@ngrok/ngrok-linux-arm-gnueabihf": "1.7.0", + "@ngrok/ngrok-linux-arm64-gnu": "1.7.0", + "@ngrok/ngrok-linux-arm64-musl": "1.7.0", + "@ngrok/ngrok-linux-x64-gnu": "1.7.0", + "@ngrok/ngrok-linux-x64-musl": "1.7.0", + "@ngrok/ngrok-win32-arm64-msvc": "1.7.0", + "@ngrok/ngrok-win32-ia32-msvc": "1.7.0", + "@ngrok/ngrok-win32-x64-msvc": "1.7.0" + } + }, + "node_modules/@ngrok/ngrok-android-arm64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-android-arm64/-/ngrok-android-arm64-1.7.0.tgz", + "integrity": "sha512-8tco3ID6noSaNy+CMS7ewqPoIkIM6XO5COCzsUp3Wv3XEbMSyn65RN6cflX2JdqLfUCHcMyD0ahr9IEiHwqmbQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-arm64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-arm64/-/ngrok-darwin-arm64-1.7.0.tgz", + "integrity": "sha512-+dmJSOzSO+MNDVrPOca2yYDP1W3KfP4qOlAkarIeFRIfqonQwq3QCBmcR7HAlZocLsSqEwyG6KP4RRvAuT0WGQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-universal": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-universal/-/ngrok-darwin-universal-1.7.0.tgz", + "integrity": "sha512-fDEfewyE2pWGFBhOSwQZObeHUkc65U1l+3HIgSOe094TMHsqmyJD0KTCgW9KSn0VP4OvDZbAISi1T3nvqgZYhQ==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-x64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-x64/-/ngrok-darwin-x64-1.7.0.tgz", + "integrity": "sha512-+fwMi5uHd9G8BS42MMa9ye6exI5lwTcjUO6Ut497Vu0qgLONdVRenRqnEePV+Q3KtQR7NjqkMnomVfkr9MBjtw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-freebsd-x64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-freebsd-x64/-/ngrok-freebsd-x64-1.7.0.tgz", + "integrity": "sha512-2OGgbrjy3yLRrqAz5N6hlUKIWIXSpR5RjQa2chtZMsSbszQ6c9dI+uVQfOKAeo05tHMUgrYAZ7FocC+ig0dzdQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm-gnueabihf": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm-gnueabihf/-/ngrok-linux-arm-gnueabihf-1.7.0.tgz", + "integrity": "sha512-SN9YIfEQiR9xN90QVNvdgvAemqMLoFVSeTWZs779145hQMhvF9Qd9rnWi6J+2uNNK10OczdV1oc/nq1es7u/3g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm64-gnu": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-gnu/-/ngrok-linux-arm64-gnu-1.7.0.tgz", + "integrity": "sha512-KDMgzPKFU2kbpVSaA2RZBBia5IPdJEe063YlyVFnSMJmPYWCUnMwdybBsucXfV9u1Lw/ZjKTKotIlbTWGn3HGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm64-musl": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-musl/-/ngrok-linux-arm64-musl-1.7.0.tgz", + "integrity": "sha512-e66vUdVrBlQ0lT9ZdamB4U604zt5Gualt8/WVcUGzbu8s5LajWd6g/mzZCUjK4UepjvMpfgmCp1/+rX7Rk8d5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-x64-gnu": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-gnu/-/ngrok-linux-x64-gnu-1.7.0.tgz", + "integrity": "sha512-M6gF0DyOEFqXLfWxObfL3bxYZ4+PnKBHuyLVaqNfFN9Y5utY2mdPOn5422Ppbk4XoIK5/YkuhRqPJl/9FivKEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-x64-musl": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-musl/-/ngrok-linux-x64-musl-1.7.0.tgz", + "integrity": "sha512-4Ijm0dKeoyzZTMaYxR2EiNjtlK81ebflg/WYIO1XtleFrVy4UJEGnxtxEidYoT4BfCqi4uvXiK2Mx216xXKvog==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-win32-arm64-msvc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-arm64-msvc/-/ngrok-win32-arm64-msvc-1.7.0.tgz", + "integrity": "sha512-u7qyWIJI2/YG1HTBnHwUR1+Z2tyGfAsUAItJK/+N1G0FeWJhIWQvSIFJHlaPy4oW1Dc8mSDBX9qvVsiQgLaRFg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-win32-ia32-msvc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-ia32-msvc/-/ngrok-win32-ia32-msvc-1.7.0.tgz", + "integrity": "sha512-/UdYUsLNv/Q8j9YJsyIfq/jLCoD8WP+NidouucTUzSoDtmOsXBBT3itLrmPiZTEdEgKiFYLuC1Zon8XQQvbVLA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-win32-x64-msvc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-x64-msvc/-/ngrok-win32-x64-msvc-1.7.0.tgz", + "integrity": "sha512-UFJg/duEWzZlLkEs61Gz6/5nYhGaKI62I8dvUGdBR3NCtIMagehnFaFxmnXZldyHmCM8U0aCIFNpWRaKcrQkoA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "license": "MIT", @@ -1263,6 +1499,12 @@ "win32" ] }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@swc/core": { "version": "1.15.10", "dev": true, @@ -2074,7 +2316,6 @@ }, "node_modules/debug": { "version": "4.4.3", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2128,6 +2369,28 @@ "dev": true, "license": "ISC" }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.4", "license": "MIT", @@ -2793,6 +3056,16 @@ "dev": true, "license": "ISC" }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❀", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/jiti": { "version": "2.6.1", "license": "MIT", @@ -2872,6 +3145,27 @@ "node": ">= 0.8.0" } }, + "node_modules/lib0": { + "version": "0.2.117", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❀", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lightningcss": { "version": "1.30.2", "license": "MPL-2.0", @@ -3229,7 +3523,6 @@ }, "node_modules/ms": { "version": "2.1.3", - "dev": true, "license": "MIT" }, "node_modules/murmurhash-js": { @@ -3271,6 +3564,12 @@ "node": ">=0.10.0" } }, + "node_modules/ogl": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ogl/-/ogl-1.0.11.tgz", + "integrity": "sha512-kUpC154AFfxi16pmZUK4jk3J+8zxwTWGPo03EoYA8QPbzikHoaC82n6pNTbd+oEaJonaE8aPWBlX7ad9zrqLsA==", + "license": "Unlicense" + }, "node_modules/optionator": { "version": "0.9.4", "dev": true, @@ -3441,6 +3740,12 @@ "npm": ">=8.2.3" } }, + "node_modules/quill-cursors": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quill-cursors/-/quill-cursors-4.0.4.tgz", + "integrity": "sha512-beHOYwRZ/I+Ift3bsvMnNWZ7gX25upW3b0aREpklUTR273MFJgxsCYmlgd/6otBE0FtFefOfh2/xU6xbkkxgIg==", + "license": "MIT" + }, "node_modules/quill-delta": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", @@ -3687,6 +3992,34 @@ "slate": ">=0.65.3" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "license": "BSD-3-Clause", @@ -3900,11 +4233,96 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/y-protocols": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", + "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❀", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, + "node_modules/y-quill": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/y-quill/-/y-quill-1.0.0.tgz", + "integrity": "sha512-WpYBXsFXdofGuaAVyvKpZ3rg+TklWtKtpemUziY044NLhnwud0D+QTX2mdGKMrLON+BshKQeT77FbXa68ZJbcA==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.93", + "y-protocols": "^1.0.6" + }, + "funding": { + "type": "GitHub Sponsors ❀", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "quill": "^2.0.0", + "quill-cursors": "^4.0.2", + "yjs": "^13.6.14" + } + }, "node_modules/yallist": { "version": "3.1.1", "dev": true, "license": "ISC" }, + "node_modules/yjs": { + "version": "13.6.29", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.29.tgz", + "integrity": "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❀", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "dev": true, diff --git a/frontend/package.json b/frontend/package.json index 6e24551..833207a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,13 +14,16 @@ "@coreui/icons": "^3.0.1", "@coreui/icons-react": "^2.3.0", "@coreui/react": "^5.9.2", + "@ngrok/ngrok": "^1.7.0", "@react-oauth/google": "^0.13.4", "@tailwindcss/vite": "^4.1.18", "axios": "^1.13.2", "classnames": "^2.5.1", "is-hotkey": "^0.2.0", "lodash": "^4.17.23", + "ogl": "^1.0.11", "quill": "^2.0.3", + "quill-cursors": "^4.0.4", "react": "^19.2.0", "react-dom": "^19.2.0", "react-grid-layout": "^2.2.2", @@ -28,7 +31,11 @@ "react-router-dom": "^7.12.0", "react-secure-storage": "^1.3.2", "slate-history": "^0.113.1", + "socket.io-client": "^4.8.3", "uuid": "^13.0.0", + "y-protocols": "^1.0.7", + "y-quill": "^1.0.0", + "yjs": "^13.6.29", "zod": "^4.3.6" }, "devDependencies": { diff --git a/frontend/public/_redirects b/frontend/public/_redirects new file mode 100644 index 0000000..7797f7c --- /dev/null +++ b/frontend/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/frontend/public/images/Logo.png b/frontend/public/images/Logo.png index aa75eae..b07a4f0 100644 Binary files a/frontend/public/images/Logo.png and b/frontend/public/images/Logo.png differ diff --git a/frontend/public/images/img.png b/frontend/public/images/img.png deleted file mode 100644 index 91cfd12..0000000 Binary files a/frontend/public/images/img.png and /dev/null differ diff --git a/frontend/public/render.json b/frontend/public/render.json new file mode 100644 index 0000000..70bff2f --- /dev/null +++ b/frontend/public/render.json @@ -0,0 +1,8 @@ +{ + "rewrites": [ + { + "source": "/(.*)", + "destination": "/index.html" + } + ] +} \ No newline at end of file diff --git a/frontend/render.json b/frontend/render.json new file mode 100644 index 0000000..0d199b2 --- /dev/null +++ b/frontend/render.json @@ -0,0 +1,5 @@ +{ + "rewrites": [ + { "source": "/(.*)", "destination": "/index.html" } + ] +} diff --git a/frontend/src/api/axios.jsx b/frontend/src/api/axios.jsx index f66a742..f710bc0 100644 --- a/frontend/src/api/axios.jsx +++ b/frontend/src/api/axios.jsx @@ -1,20 +1,20 @@ import axios from "axios"; import secureLocalStorage from "react-secure-storage"; +const baseURL = import.meta.env.VITE_BACKEND_URL || "http://192.168.29.93:5005"; +console.log("Axios initialized with baseURL:", baseURL); + const api = axios.create({ - baseURL: import.meta.env.VITE_BACKEND_URL, - withCredentials:true -}); + baseURL, + withCredentials: true +}); // Request Interceptor: Attach Access Token api.interceptors.request.use( (config) => { const token = secureLocalStorage.getItem("accessToken"); if (token) { - console.log("Axios Interceptor: Attaching Access Token:", token.substring(0, 10) + "..."); config.headers.Authorization = `Bearer ${token}`; - } else { - console.log("Axios Interceptor: No Access Token found in storage."); } return config; }, @@ -44,7 +44,7 @@ api.interceptors.response.use( console.log("Axios Interceptor: Sending Refresh Request..."); const res = await axios.post(`${import.meta.env.VITE_BACKEND_URL}/api/auth/refresh`, { refreshToken, - }); + }, { withCredentials: true }); console.log("Axios Interceptor: Refresh Successful. New Access Token received."); const { accessToken } = res.data; diff --git a/frontend/src/collaboration/awareness.js b/frontend/src/collaboration/awareness.js new file mode 100644 index 0000000..5d6a1b4 --- /dev/null +++ b/frontend/src/collaboration/awareness.js @@ -0,0 +1,34 @@ +import { Awareness } from 'y-protocols/awareness'; +import { getYDoc } from './ydoc'; + +let awareness = null; + +export const initAwareness = () => { + const ydoc = getYDoc(); + if (!ydoc) throw new Error('YDoc not initialized'); + + if (awareness) return awareness; + + awareness = new Awareness(ydoc); + + awareness.setLocalState({ + user: { + id: `user-${Math.random().toString(36).slice(2)}`, + name: 'User', + color: `hsl(${Math.random() * 360},70%,50%)`, + }, + cursor: null, + }); + + return awareness; +}; + +export const getAwareness = () => awareness; + +export const updateLocalState = (updates) => { + if (!awareness) return; + awareness.setLocalState({ + ...awareness.getLocalState(), + ...updates, + }); +}; \ No newline at end of file diff --git a/frontend/src/collaboration/provider.js b/frontend/src/collaboration/provider.js new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/collaboration/socket.js b/frontend/src/collaboration/socket.js new file mode 100644 index 0000000..a3566fc --- /dev/null +++ b/frontend/src/collaboration/socket.js @@ -0,0 +1,34 @@ +import io from 'socket.io-client'; + +let socket = null; + +// Use explicit port 5001 or environment variable +const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || "http://192.168.29.93:5005"; + +export const initSocket = (docId) => { + if (socket) return socket; + + socket = io(BACKEND_URL, { + transports: ['websocket'], + query: { docId } + }); + + socket.on("connect", () => { + console.log("Socket connected:", socket.id); + }); + + socket.on("connect_error", (err) => { + console.error("Socket connection error:", err); + }); + + return socket; +}; + +export const getSocket = () => socket; + +export const disconnectSocket = () => { + if (socket) { + socket.disconnect(); + socket = null; + } +}; diff --git a/frontend/src/collaboration/useVideoCall.js b/frontend/src/collaboration/useVideoCall.js new file mode 100644 index 0000000..b6e744e --- /dev/null +++ b/frontend/src/collaboration/useVideoCall.js @@ -0,0 +1,151 @@ +import { useEffect, useRef, useState } from "react"; + +const ICE = { + iceServers: [{ urls: "stun:stun.l.google.com:19302" }], +}; + +export const useVideoCall = (socket, blogId) => { + const localVideo = useRef(null); + const localStreamRef = useRef(null); + const peers = useRef({}); + + const [remoteStreams, setRemoteStreams] = useState([]); + const [isMuted, setIsMuted] = useState(false); + const [isCameraOff, setIsCameraOff] = useState(false); + + + + const toggleMute = () => { + localStreamRef.current?.getAudioTracks().forEach(track => { + track.enabled = !track.enabled; + setIsMuted(!track.enabled); + }); + }; + + const toggleCamera = () => { + localStreamRef.current?.getVideoTracks().forEach(track => { + track.enabled = !track.enabled; + setIsCameraOff(!track.enabled); + }); + }; + + const endCall = () => { + socket.emit("call:leave", blogId); + + Object.values(peers.current).forEach(pc => pc.close()); + peers.current = {}; + + localStreamRef.current?.getTracks().forEach(t => t.stop()); + setRemoteStreams([]); + }; + + + + useEffect(() => { + if (!socket) return; + + let mounted = true; + + navigator.mediaDevices + .getUserMedia({ video: true, audio: true }) + .then(stream => { + if (!mounted) return; + + localStreamRef.current = stream; + localVideo.current.srcObject = stream; + + socket.emit("call:join", blogId); + + socket.on("call:user-joined", async ({ socketId }) => { + const pc = new RTCPeerConnection(ICE); + peers.current[socketId] = pc; + + stream.getTracks().forEach(t => pc.addTrack(t, stream)); + + pc.ontrack = e => { + setRemoteStreams(prev => [...prev, e.streams[0]]); + }; + + pc.onicecandidate = e => { + if (e.candidate) { + socket.emit("call:signal", { + blogId, + to: socketId, + signal: { candidate: e.candidate }, + }); + } + }; + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + + socket.emit("call:signal", { + blogId, + to: socketId, + signal: { sdp: offer }, + }); + }); + + socket.on("call:signal", async ({ from, signal }) => { + let pc = peers.current[from]; + + if (!pc) { + pc = new RTCPeerConnection(ICE); + peers.current[from] = pc; + + stream.getTracks().forEach(t => pc.addTrack(t, stream)); + + pc.ontrack = e => { + setRemoteStreams(prev => [...prev, e.streams[0]]); + }; + + pc.onicecandidate = e => { + if (e.candidate) { + socket.emit("call:signal", { + blogId, + to: from, + signal: { candidate: e.candidate }, + }); + } + }; + } + + if (signal.sdp) { + await pc.setRemoteDescription(signal.sdp); + + if (signal.sdp.type === "offer") { + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + + socket.emit("call:signal", { + blogId, + to: from, + signal: { sdp: answer }, + }); + } + } + + if (signal.candidate) { + await pc.addIceCandidate(signal.candidate); + } + }); + }); + + return () => { + mounted = false; + endCall(); + socket.off("call:user-joined"); + socket.off("call:signal"); + }; + }, [socket, blogId]); + + return { + localVideo, + remoteStreams, + toggleMute, + toggleCamera, + endCall, + isMuted, + isCameraOff, + }; +}; diff --git a/frontend/src/collaboration/ydoc.js b/frontend/src/collaboration/ydoc.js new file mode 100644 index 0000000..1d65e91 --- /dev/null +++ b/frontend/src/collaboration/ydoc.js @@ -0,0 +1,19 @@ +import * as Y from 'yjs'; + +let ydoc = null; + +export const initYDoc = () => { + if (!ydoc) ydoc = new Y.Doc(); + return ydoc; +}; + +export const getYDoc = () => ydoc; + +export const getSharedText = () => { + if (!ydoc) throw new Error('YDoc missing'); + return ydoc.getText('content'); +}; + +export const applyUpdate = (update, origin) => { + Y.applyUpdate(ydoc, update, origin); +}; \ No newline at end of file diff --git a/frontend/src/components/GridEditor/GridEditor.jsx b/frontend/src/components/GridEditor/GridEditor.jsx index 3de1822..7e2f469 100644 --- a/frontend/src/components/GridEditor/GridEditor.jsx +++ b/frontend/src/components/GridEditor/GridEditor.jsx @@ -41,7 +41,7 @@ const useContainerWidth = () => { }; -const GridEditor = ({ widgets, setWidgets, readOnly = false }) => { +const GridEditor = ({ widgets, setWidgets, readOnly = false, ydoc, awareness }) => { const { width, ref: containerRef } = useContainerWidth(); const onLayoutStop = (layout) => { @@ -113,13 +113,13 @@ const GridEditor = ({ widgets, setWidgets, readOnly = false }) => { minW: 2, minH: 2 }} - className="relative group border border-transparent hover:border-slate-200 dark:hover:border-slate-700 rounded-lg transition-colors flex flex-col" + className="relative group border border-transparent hover:border-slate-200 dark:hover:border-gray-500 rounded-lg transition-colors flex flex-col" > {!readOnly && (
{/* DRAG HANDLE */} -
+
drag_indicator
{/* DELETE BUTTON */} @@ -129,7 +129,7 @@ const GridEditor = ({ widgets, setWidgets, readOnly = false }) => { e.stopPropagation(); removeWidget(widget.id); }} - className="p-0.5 bg-white text-red-500 rounded shadow-md hover:bg-red-50 dark:bg-slate-700 pointer-events-auto" + className="p-0.5 bg-white text-red-500 rounded shadow-md hover:bg-red-50 dark:bg-gray-400 pointer-events-auto" title="Remove Item" > close @@ -145,8 +145,9 @@ const GridEditor = ({ widgets, setWidgets, readOnly = false }) => { content={widget.content} onChange={handleContentChange} readOnly={readOnly} - /> - )} + ydoc={ydoc} + awareness={awareness} + />)} {widget.type === 'image' && ( )} diff --git a/frontend/src/components/GridEditor/ImageWidget.jsx b/frontend/src/components/GridEditor/ImageWidget.jsx index 74f97b2..830765f 100644 --- a/frontend/src/components/GridEditor/ImageWidget.jsx +++ b/frontend/src/components/GridEditor/ImageWidget.jsx @@ -2,7 +2,7 @@ import React from 'react' const ImageWidget = ({ url, readOnly }) => { return ( -
+
Post content { +const TextWidget = ({ id, content, onChange, readOnly = false, ydoc, awareness }) => { const safeContent = Array.isArray(content) ? '' : content; + const quillRef = useRef(null); + + useEffect(() => { + if (!ydoc || !quillRef.current || !awareness) { + console.log(`TextWidget ${id}: Waiting for dependencies...`, { ydoc: !!ydoc, quillRef: !!quillRef.current, awareness: !!awareness }); + return; + } + + const quillInstance = quillRef.current.getEditor(); + const ytext = ydoc.getText(id); + + console.log(`βœ… Binding Yjs to TextWidget ${id}...`); + console.log(` - Y.Text initial content:`, ytext.toString()); + console.log(` - Quill initial content:`, quillInstance.getText()); + + // Bind Yjs to Quill with support for Awareness (cursors) + const binding = new QuillBinding(ytext, quillInstance, awareness); + + // Add listener to track Y.Text changes + const ytextObserver = () => { + console.log(`πŸ“ Y.Text changed for widget ${id}:`, ytext.toString()); + }; + ytext.observe(ytextObserver); + + return () => { + console.log(`πŸ”΄ Destroying binding for TextWidget ${id}`); + ytext.unobserve(ytextObserver); + binding.destroy(); + }; + }, [ydoc, id, awareness]); return ( -
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} > onChange(id, val)} modules={readOnly ? { toolbar: false } : modules} formats={formats} readOnly={readOnly} - className="h-full flex flex-col dark:bg-slate-800 dark:text-slate-100" + className="h-full flex flex-col dark:bg-gray-300 dark:text-gray-900" style={{ height: '100%', display: 'flex', flexDirection: 'column' }} /> {/* Override Quill internal styles locally for flex growth and THEMING */}
); diff --git a/frontend/src/components/GridEditor/VideoWidget.jsx b/frontend/src/components/GridEditor/VideoWidget.jsx index 5358693..0e864eb 100644 --- a/frontend/src/components/GridEditor/VideoWidget.jsx +++ b/frontend/src/components/GridEditor/VideoWidget.jsx @@ -10,7 +10,7 @@ const VideoWidget = ({ url }) => { } return ( -
+
{/* Overlay to allow dragging over the iframe without getting captured by the iframe */}