From 0a5e6661f1c9371a0639e2f5f04bea89ea6f6b5f Mon Sep 17 00:00:00 2001 From: M1ngdaXie <156019134+M1ngdaXie@users.noreply.github.com> Date: Tue, 6 Jan 2026 22:03:07 -0800 Subject: [PATCH] feat: Implement Share Modal for document sharing functionality - Added ShareModal component to manage user and link sharing for documents. - Created AuthContext to handle user authentication state and token management. - Updated useYjsDocument hook to support sharing via tokens. - Enhanced Yjs document creation to include user information and authentication tokens. - Introduced AuthCallback page to handle authentication redirects and token processing. - Modified EditorPage and KanbanPage to include share functionality. - Created LoginPage with Google and GitHub authentication options. - Added styles for LoginPage. - Defined types for authentication and sharing in respective TypeScript files. --- backend/internal/handlers/document.go | 14 +- backend/internal/hub/hub.go | 189 +++++++-- backend/internal/hub/yjs_protocol.go | 99 +++++ frontend/package-lock.json | 62 +++ frontend/package.json | 3 + frontend/src/App.tsx | 43 ++- frontend/src/api/auth.ts | 24 ++ frontend/src/api/client.ts | 31 ++ frontend/src/api/document.ts | 15 +- frontend/src/api/share.ts | 100 +++++ frontend/src/components/Kanban/Card.tsx | 26 +- frontend/src/components/Kanban/Column.tsx | 22 +- .../src/components/Kanban/KanbanBoard.tsx | 59 ++- frontend/src/components/Navbar.css | 65 ++++ frontend/src/components/Navbar.tsx | 34 ++ frontend/src/components/Presence/UserList.tsx | 4 +- frontend/src/components/ProtectedRoute.tsx | 33 ++ frontend/src/components/Share/ShareModal.css | 365 ++++++++++++++++++ frontend/src/components/Share/ShareModal.tsx | 292 ++++++++++++++ frontend/src/contexts/AuthContext.tsx | 109 ++++++ frontend/src/hooks/useYjsDocument.ts | 55 ++- frontend/src/lib/yjs.ts | 47 ++- frontend/src/pages/AuthCallback.tsx | 68 ++++ frontend/src/pages/EditorPage.tsx | 26 +- frontend/src/pages/Home.tsx | 16 +- frontend/src/pages/KanbanPage.tsx | 26 +- frontend/src/pages/LoginPage.css | 83 ++++ frontend/src/pages/LoginPage.tsx | 86 +++++ frontend/src/types/auth.ts | 20 + frontend/src/types/share.ts | 25 ++ 30 files changed, 1923 insertions(+), 118 deletions(-) create mode 100644 backend/internal/hub/yjs_protocol.go create mode 100644 frontend/src/api/auth.ts create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/share.ts create mode 100644 frontend/src/components/Navbar.css create mode 100644 frontend/src/components/Navbar.tsx create mode 100644 frontend/src/components/ProtectedRoute.tsx create mode 100644 frontend/src/components/Share/ShareModal.css create mode 100644 frontend/src/components/Share/ShareModal.tsx create mode 100644 frontend/src/contexts/AuthContext.tsx create mode 100644 frontend/src/pages/AuthCallback.tsx create mode 100644 frontend/src/pages/LoginPage.css create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/types/auth.ts create mode 100644 frontend/src/types/share.ts diff --git a/backend/internal/handlers/document.go b/backend/internal/handlers/document.go index 23fe907..ae54625 100644 --- a/backend/internal/handlers/document.go +++ b/backend/internal/handlers/document.go @@ -175,13 +175,19 @@ func (h *DocumentHandler) UpdateDocumentState(c *gin.Context) { return } - var req models.UpdateStateRequest - if err := c.ShouldBindJSON(&req); err != nil { - respondWithValidationError(c, err) + // Read binary data directly from request body + state, err := c.GetRawData() + if err != nil { + respondBadRequest(c, "Failed to read request body") return } - if err := h.store.UpdateDocumentState(id, req.State); err != nil { + if len(state) == 0 { + respondBadRequest(c, "Empty state data") + return + } + + if err := h.store.UpdateDocumentState(id, state); err != nil { respondInternalError(c, "Failed to update state", err) return } diff --git a/backend/internal/hub/hub.go b/backend/internal/hub/hub.go index 97f535d..ec98333 100644 --- a/backend/internal/hub/hub.go +++ b/backend/internal/hub/hub.go @@ -30,11 +30,14 @@ type Client struct { unregisterOnce sync.Once failureCount int failureMu sync.Mutex + observedYjsIDs map[uint64]uint64 // clientID -> maxClock + idsMu sync.Mutex } type Room struct { - ID string - clients map[*Client]bool - mu sync.RWMutex + ID string + clients map[*Client]bool + mu sync.RWMutex + lastAwareness []byte // 存储最新的 awareness 消息,用于新用户加入时立即同步 } type Hub struct { @@ -49,7 +52,7 @@ func NewHub() *Hub { rooms: make(map[string]*Room), Register: make(chan *Client), Unregister: make(chan *Client), - Broadcast: make(chan *Message), + Broadcast: make(chan *Message, 1024), } } @@ -81,26 +84,39 @@ func (h *Hub) registerClient(client *Client) { } room.mu.Lock() room.clients[client] = true + // 获取现有的 awareness 数据(如果有的话) + awarenessData := room.lastAwareness room.mu.Unlock() log.Printf("Client %s joined room %s (total clients: %d)", client.ID, client.roomID, len(room.clients)) + + // 如果房间有之前的 awareness 状态,立即发送给新用户 + // 这样新用户不需要等待其他用户的下一次广播就能看到在线用户 + if len(awarenessData) > 0 { + select { + case client.send <- awarenessData: + log.Printf("📤 Sent existing awareness to new client %s", client.ID) + default: + log.Printf("⚠️ Failed to send awareness to new client %s (channel full)", client.ID) + } + } } func (h *Hub) unregisterClient(client *Client) { h.mu.Lock() - defer h.mu.Unlock() + // --------------------------------------------------- + // 注意:这里不要用 defer h.mu.Unlock(),因为我们要手动控制锁 + // --------------------------------------------------- room, exists := h.rooms[client.roomID] if !exists { - log.Printf("Room %s does not exist for client %s", client.roomID, client.ID) + h.mu.Unlock() return } - room.mu.Lock() - defer room.mu.Unlock() - + room.mu.Lock() // 锁住房间 if _, ok := room.clients[client]; ok { delete(room.clients, client) - - // Safely close send channel exactly once + + // 关闭连接通道 client.sendMu.Lock() if !client.sendClosed { close(client.send) @@ -108,14 +124,70 @@ func (h *Hub) unregisterClient(client *Client) { } client.sendMu.Unlock() - log.Printf("Client %s disconnected from room %s (total clients: %d)", - client.ID, client.roomID, len(room.clients)) + log.Printf("Client disconnected: %s", client.ID) + } + + // 检查房间是否还有其他人 + remainingClientsCount := len(room.clients) + room.mu.Unlock() // 解锁房间 (我们已经删完人了) + + // --------------------------------------------------- + // [新增] 僵尸用户清理逻辑 (核心修改) + // --------------------------------------------------- + + // 只有当房间里还有其他人时,才需要广播通知 + if remainingClientsCount > 0 { + // 1. 从 client 的小本本里取出它用过的 Yjs ID 和对应的 clock + client.idsMu.Lock() + clientClocks := make(map[uint64]uint64, len(client.observedYjsIDs)) + for id, clock := range client.observedYjsIDs { + clientClocks[id] = clock + } + client.idsMu.Unlock() + + // 2. 如果有记录到的 ID,就伪造删除消息 (使用 clock+1) + if len(clientClocks) > 0 { + deleteMsg := MakeYjsDeleteMessage(clientClocks) // 调用工具函数,传入 clientID -> clock map + + log.Printf("🧹 Notifying others to remove Yjs IDs with clocks: %v", clientClocks) + + // 3. 广播给房间里的幸存者 + // 构造一个消息对象 + msg := &Message{ + RoomID: client.roomID, + Data: deleteMsg, + sender: nil, // sender 设为 nil,表示系统消息 + } + + // !!特别注意!! + // 不要在这里直接调用 h.broadcastMessage(msg),因为那会尝试重新获取 h.mu 锁导致死锁 + // 我们直接把它扔到 Channel 里,让 Run() 去处理 + // 必须在一个非阻塞的 goroutine 里发,或者确保 channel 有缓冲 + + go func() { + // 使用 select 尝试发送,但如果满了,我们要稍微等一下,而不是直接丢弃 + // 因为这是“清理僵尸”的关键消息,丢了就会出 Bug + select { + case h.Broadcast <- msg: + // 发送成功 + case <-time.After(500 * time.Millisecond): + // 如果 500ms 还没塞进去,那说明系统真的挂了,只能丢弃并打印错误 + log.Printf("❌ Critical: Failed to broadcast cleanup message (Channel blocked)") + } + }() + } } - if len(room.clients) == 0 { + // --------------------------------------------------- + // 结束清理逻辑 + // --------------------------------------------------- + + if remainingClientsCount == 0 { delete(h.rooms, client.roomID) - log.Printf("Deleted empty room with ID: %s", client.roomID) + log.Printf("Room destroyed: %s", client.roomID) } + + h.mu.Unlock() // 最后解锁 Hub } const ( @@ -134,6 +206,14 @@ func (h *Hub) broadcastMessage(message *Message) { return } + // 如果是 awareness 消息 (type=1),保存它供新用户使用 + if len(message.Data) > 0 && message.Data[0] == 1 { + room.mu.Lock() + room.lastAwareness = make([]byte, len(message.Data)) + copy(room.lastAwareness, message.Data) + room.mu.Unlock() + } + room.mu.RLock() defer room.mu.RUnlock() @@ -171,29 +251,59 @@ func (h *Hub) broadcastMessage(message *Message) { func (c *Client) ReadPump() { - c.Conn.SetReadDeadline(time.Now().Add(pongWait)) - c.Conn.SetPongHandler(func(string) error { - c.Conn.SetReadDeadline(time.Now().Add(pongWait)) - return nil - }) - defer func() { - c.unregister() - c.Conn.Close() - }() - for { - messageType, message, err := c.Conn.ReadMessage() - if err != nil { - log.Printf("Error reading message from client %s: %v", c.ID, err) - break - } - if messageType == websocket.BinaryMessage { - c.hub.Broadcast <- &Message{ - RoomID: c.roomID, - Data: message, - sender: c, - } - } - } + c.Conn.SetReadDeadline(time.Now().Add(pongWait)) + c.Conn.SetPongHandler(func(string) error { + c.Conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + defer func() { + c.unregister() + c.Conn.Close() + }() + + for { + messageType, message, err := c.Conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("error: %v", err) + } + break + } + + // ========================================================== + // 1. 偷听逻辑 (Sniff) - 必须放在转发之前! + // ========================================================== + if messageType == websocket.BinaryMessage && len(message) > 0 && message[0] == 1 { + clockMap := SniffYjsClientIDs(message) + if len(clockMap) > 0 { + c.idsMu.Lock() + for id, clock := range clockMap { + if clock > c.observedYjsIDs[id] { + log.Printf("🕵️ [Sniff] Client %s uses YjsID: %d (clock: %d)", c.ID, id, clock) + c.observedYjsIDs[id] = clock + } + } + c.idsMu.Unlock() + } + } + + // ========================================================== + // 2. 转发逻辑 (Broadcast) - 恢复协作功能 + // ========================================================== + if messageType == websocket.BinaryMessage { + // 注意:这里要检查 channel 是否已满,避免阻塞导致 ReadPump 卡死 + select { + case c.hub.Broadcast <- &Message{ + RoomID: c.roomID, + Data: message, + sender: c, + }: + // 发送成功 + default: + log.Printf("⚠️ Hub broadcast channel is full, dropping message from %s", c.ID) + } + } + } } func (c *Client) WritePump() { @@ -238,9 +348,10 @@ func NewClient(id string, userID *uuid.UUID, userName string, userAvatar *string UserName: userName, UserAvatar: userAvatar, Conn: conn, - send: make(chan []byte, 256), + send: make(chan []byte, 1024), hub: hub, roomID: roomID, + observedYjsIDs: make(map[uint64]uint64), } } func (c *Client) unregister() { diff --git a/backend/internal/hub/yjs_protocol.go b/backend/internal/hub/yjs_protocol.go new file mode 100644 index 0000000..71479d0 --- /dev/null +++ b/backend/internal/hub/yjs_protocol.go @@ -0,0 +1,99 @@ +package hub + +import ( + "encoding/binary" +) + +// 这个函数用来从前端发来的二进制数据里,提取出 Yjs 的 ClientID 和对应的 Clock +// data: 前端发来的 []byte +// return: map[clientID]clock +func SniffYjsClientIDs(data []byte) map[uint64]uint64 { + // 简单的防御:如果不是 Type 1 (Awareness) 消息,直接忽略 + if len(data) < 2 || data[0] != 1 { + return nil + } + + result := make(map[uint64]uint64) + offset := 1 // 跳过 [0] MessageType + + // 读取总长度 (跳过) + _, n := binary.Uvarint(data[offset:]) + if n <= 0 { return nil } + offset += n + + // 读取 Count (包含几个客户端的信息) + count, n := binary.Uvarint(data[offset:]) + if n <= 0 { return nil } + offset += n + + // 循环读取每个客户端的信息 + for i := 0; i < int(count); i++ { + if offset >= len(data) { break } + + // 1. 读取 ClientID + id, n := binary.Uvarint(data[offset:]) + if n <= 0 { break } + offset += n + + // 2. 读取 Clock (现在我们需要保存它!) + clock, n := binary.Uvarint(data[offset:]) + if n <= 0 { break } + offset += n + + // 保存 clientID -> clock + result[id] = clock + + // 3. 跳过 JSON String + if offset >= len(data) { break } + strLen, n := binary.Uvarint(data[offset:]) + if n <= 0 { break } + offset += n + + // 跳过具体字符串内容 + offset += int(strLen) + } + + return result +} + +// 这个函数用来伪造一条"删除消息" +// 输入:clientClocks map[clientID]clock - 要删除的 ClientID 及其最后已知的 clock 值 +// 输出:可以广播给前端的 []byte +func MakeYjsDeleteMessage(clientClocks map[uint64]uint64) []byte { + if len(clientClocks) == 0 { + return nil + } + + // 构造 Payload (负载) + // 格式: [Count] [ID] [Clock] [StringLen] [String] ... + payload := make([]byte, 0) + + // 写入 Count (变长整数) + buf := make([]byte, 10) + n := binary.PutUvarint(buf, uint64(len(clientClocks))) + payload = append(payload, buf[:n]...) + + for id, clock := range clientClocks { + // ClientID + n = binary.PutUvarint(buf, id) + payload = append(payload, buf[:n]...) + // Clock: 必须使用 clock + 1,这样 Yjs 才会接受这个更新! + n = binary.PutUvarint(buf, clock+1) + payload = append(payload, buf[:n]...) + // String Length (填 4,因为 "null" 长度是 4) + n = binary.PutUvarint(buf, 4) + payload = append(payload, buf[:n]...) + // String Content (这里是关键:null 代表删除用户) + payload = append(payload, []byte("null")...) + } + + // 构造最终消息: [Type=1] [PayloadLength] [Payload] + finalMsg := make([]byte, 0) + finalMsg = append(finalMsg, 1) // Type 1 + + n = binary.PutUvarint(buf, uint64(len(payload))) + finalMsg = append(finalMsg, buf[:n]...) // Length + finalMsg = append(finalMsg, payload...) // Body + + return finalMsg +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 64f4b0b..c7786df 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,9 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tiptap/extension-collaboration": "^2.27.1", "@tiptap/extension-collaboration-cursor": "^2.26.2", "@tiptap/pm": "^2.27.1", @@ -317,6 +320,59 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -3792,6 +3848,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6993d39..f0fb387 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,9 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tiptap/extension-collaboration": "^2.27.1", "@tiptap/extension-collaboration-cursor": "^2.26.2", "@tiptap/pm": "^2.27.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6c7c06b..28edb6e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,17 +1,46 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { AuthProvider } from "./contexts/AuthContext"; +import ProtectedRoute from "./components/ProtectedRoute"; +import LoginPage from "./pages/LoginPage"; +import AuthCallback from "./pages/AuthCallback"; import EditorPage from "./pages/EditorPage.tsx"; import Home from "./pages/Home.tsx"; import KanbanPage from "./pages/KanbanPage.tsx"; function App() { return ( - - - } /> - } /> - } /> - - + + + + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + ); } diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..c0ee100 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,24 @@ +import type { User } from '../types/auth'; +import { API_BASE_URL, authFetch } from './client'; + +export const authApi = { + getCurrentUser: async (): Promise => { + const response = await authFetch(`${API_BASE_URL}/auth/me`); + console.log("current user is " + response) + if (!response.ok) { + throw new Error('Failed to get current user'); + } + + return response.json(); + }, + + logout: async (): Promise => { + const response = await authFetch(`${API_BASE_URL}/auth/logout`, { + method: 'POST', + }); + + if (!response.ok) { + throw new Error('Failed to logout'); + } + }, +}; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..0864f18 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,31 @@ +const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080/api"; + +export async function authFetch(url: string, options?: RequestInit): Promise { + const token = localStorage.getItem('auth_token'); + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...options?.headers, + }; + + // Add Authorization header if token exists + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(url, { + ...options, + headers, + }); + + // Handle 401: Token expired or invalid + if (response.status === 401) { + localStorage.removeItem('auth_token'); + window.location.href = '/login'; + throw new Error('Unauthorized'); + } + + return response; +} + +export { API_BASE_URL }; diff --git a/frontend/src/api/document.ts b/frontend/src/api/document.ts index 50dcbc1..9db1148 100644 --- a/frontend/src/api/document.ts +++ b/frontend/src/api/document.ts @@ -1,4 +1,4 @@ -const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080/api"; +import { authFetch, API_BASE_URL } from './client'; export type DocumentType = { id: string; @@ -16,23 +16,22 @@ export type CreateDocumentRequest = { export const documentsApi = { // List all documents list: async (): Promise<{ documents: DocumentType[]; total: number }> => { - const response = await fetch(`${API_BASE_URL}/documents`); + const response = await authFetch(`${API_BASE_URL}/documents`); if (!response.ok) throw new Error("Failed to fetch documents"); return response.json(); }, // Get a single document get: async (id: string): Promise => { - const response = await fetch(`${API_BASE_URL}/documents/${id}`); + const response = await authFetch(`${API_BASE_URL}/documents/${id}`); if (!response.ok) throw new Error("Failed to fetch document"); return response.json(); }, // Create a new document create: async (data: CreateDocumentRequest): Promise => { - const response = await fetch(`${API_BASE_URL}/documents`, { + const response = await authFetch(`${API_BASE_URL}/documents`, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); if (!response.ok) throw new Error("Failed to create document"); @@ -41,7 +40,7 @@ export const documentsApi = { // Delete a document delete: async (id: string): Promise => { - const response = await fetch(`${API_BASE_URL}/documents/${id}`, { + const response = await authFetch(`${API_BASE_URL}/documents/${id}`, { method: "DELETE", }); if (!response.ok) throw new Error("Failed to delete document"); @@ -49,7 +48,7 @@ export const documentsApi = { // Get document Yjs state getState: async (id: string): Promise => { - const response = await fetch(`${API_BASE_URL}/documents/${id}/state`); + const response = await authFetch(`${API_BASE_URL}/documents/${id}/state`); if (!response.ok) throw new Error("Failed to fetch document state"); const arrayBuffer = await response.arrayBuffer(); return new Uint8Array(arrayBuffer); @@ -61,7 +60,7 @@ export const documentsApi = { const buffer = new ArrayBuffer(state.byteLength); new Uint8Array(buffer).set(state); - const response = await fetch(`${API_BASE_URL}/documents/${id}/state`, { + const response = await authFetch(`${API_BASE_URL}/documents/${id}/state`, { method: "PUT", headers: { "Content-Type": "application/octet-stream" }, body: buffer, diff --git a/frontend/src/api/share.ts b/frontend/src/api/share.ts new file mode 100644 index 0000000..0d06d41 --- /dev/null +++ b/frontend/src/api/share.ts @@ -0,0 +1,100 @@ +import { authFetch, API_BASE_URL } from './client'; +import type { + DocumentShareWithUser, + CreateShareRequest, + ShareLink, +} from '../types/share'; + +export const shareApi = { + // Create a share with a specific user + createShare: async ( + documentId: string, + request: CreateShareRequest + ): Promise => { + const response = await authFetch(`${API_BASE_URL}/documents/${documentId}/shares`, { + method: 'POST', + body: JSON.stringify(request), + }); + + if (!response.ok) { + throw new Error('Failed to create share'); + } + + return response.json(); + }, + + // List all shares for a document + listShares: async (documentId: string): Promise => { + const response = await authFetch(`${API_BASE_URL}/documents/${documentId}/shares`); + + if (!response.ok) { + throw new Error('Failed to list shares'); + } + + const data = await response.json(); + return data.shares || []; + }, + + // Delete a share + deleteShare: async (documentId: string, userId: string): Promise => { + const response = await authFetch( + `${API_BASE_URL}/documents/${documentId}/shares/${userId}`, + { + method: 'DELETE', + } + ); + + if (!response.ok) { + throw new Error('Failed to delete share'); + } + }, + + // Create a public share link + createShareLink: async ( + documentId: string, + permission: 'view' | 'edit' + ): Promise => { + const response = await authFetch( + `${API_BASE_URL}/documents/${documentId}/share-link`, + { + method: 'POST', + body: JSON.stringify({ permission }), + } + ); + + if (!response.ok) { + throw new Error('Failed to create share link'); + } + + return response.json(); + }, + + // Get the current share link + getShareLink: async (documentId: string): Promise => { + const response = await authFetch(`${API_BASE_URL}/documents/${documentId}/share-link`); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + throw new Error('Failed to get share link'); + } + + return response.json(); + }, + + // Revoke the share link + revokeShareLink: async (documentId: string): Promise => { + const response = await authFetch( + `${API_BASE_URL}/documents/${documentId}/share-link`, + { + method: 'DELETE', + } + ); + + if (!response.ok) { + throw new Error('Failed to revoke share link'); + } + }, +}; diff --git a/frontend/src/components/Kanban/Card.tsx b/frontend/src/components/Kanban/Card.tsx index a268561..6b18cf0 100644 --- a/frontend/src/components/Kanban/Card.tsx +++ b/frontend/src/components/Kanban/Card.tsx @@ -1,3 +1,5 @@ +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import type { Task } from "./KanbanBoard.tsx"; interface CardProps { @@ -5,8 +7,30 @@ interface CardProps { } const Card = ({ task }: CardProps) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: task.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + cursor: 'grab', + }; + return ( -
+

{task.title}

{task.description &&

{task.description}

}
diff --git a/frontend/src/components/Kanban/Column.tsx b/frontend/src/components/Kanban/Column.tsx index e45bda4..5229cdb 100644 --- a/frontend/src/components/Kanban/Column.tsx +++ b/frontend/src/components/Kanban/Column.tsx @@ -1,4 +1,6 @@ import { useState } from "react"; +import { useDroppable } from '@dnd-kit/core'; +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import Card from "./Card.tsx"; import type { KanbanColumn, Task } from "./KanbanBoard.tsx"; @@ -12,6 +14,10 @@ const Column = ({ column, onAddTask }: ColumnProps) => { const [isAdding, setIsAdding] = useState(false); const [newTaskTitle, setNewTaskTitle] = useState(""); + const { setNodeRef, isOver } = useDroppable({ + id: column.id, + }); + const handleAddTask = () => { if (newTaskTitle.trim()) { onAddTask({ @@ -25,12 +31,20 @@ const Column = ({ column, onAddTask }: ColumnProps) => { }; return ( -
+

{column.title}

- {column.tasks.map((task) => ( - - ))} + t.id)} + strategy={verticalListSortingStrategy} + > + {column.tasks.map((task) => ( + + ))} + {isAdding ? (
diff --git a/frontend/src/components/Kanban/KanbanBoard.tsx b/frontend/src/components/Kanban/KanbanBoard.tsx index 567ecc4..b211e74 100644 --- a/frontend/src/components/Kanban/KanbanBoard.tsx +++ b/frontend/src/components/Kanban/KanbanBoard.tsx @@ -1,4 +1,11 @@ import { useEffect, useState } from "react"; +import { + DndContext, + type DragEndEvent, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; import type { YjsProviders } from "../../lib/yjs"; import Column from "./Column.tsx"; @@ -21,6 +28,14 @@ export interface KanbanColumn { const KanbanBoard = ({ providers }: KanbanBoardProps) => { const [columns, setColumns] = useState([]); + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, // Prevent accidental drags + }, + }) + ); + useEffect(() => { const yarray = providers.ydoc.getArray("kanban-columns"); @@ -93,19 +108,39 @@ const KanbanBoard = ({ providers }: KanbanBoardProps) => { } }; + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (!over) return; + + const taskId = active.id as string; + const targetColumnId = over.id as string; + + // Find which column the task is currently in + const fromColumn = columns.find(col => + col.tasks.some(task => task.id === taskId) + ); + + if (fromColumn && fromColumn.id !== targetColumnId) { + moveTask(fromColumn.id, targetColumnId, taskId); + } + }; + return ( -
- {columns.map((column) => ( - addTask(column.id, task)} - onMoveTask={(taskId, toColumnId) => - moveTask(column.id, toColumnId, taskId) - } - /> - ))} -
+ +
+ {columns.map((column) => ( + addTask(column.id, task)} + onMoveTask={(taskId, toColumnId) => + moveTask(column.id, toColumnId, taskId) + } + /> + ))} +
+
); }; diff --git a/frontend/src/components/Navbar.css b/frontend/src/components/Navbar.css new file mode 100644 index 0000000..0a3a48e --- /dev/null +++ b/frontend/src/components/Navbar.css @@ -0,0 +1,65 @@ +.navbar { + background: white; + border-bottom: 1px solid #e2e8f0; + padding: 12px 24px; + position: sticky; + top: 0; + z-index: 100; +} + +.navbar-content { + max-width: 1400px; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; +} + +.navbar-brand a { + font-size: 20px; + font-weight: 700; + color: #1a202c; + text-decoration: none; +} + +.navbar-brand a:hover { + color: #667eea; +} + +.navbar-user { + display: flex; + align-items: center; + gap: 12px; +} + +.user-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: cover; + border: 2px solid #e2e8f0; +} + +.user-name { + font-size: 14px; + font-weight: 500; + color: #4a5568; +} + +.logout-button { + padding: 6px 16px; + font-size: 14px; + font-weight: 500; + color: #718096; + background: #f7fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; +} + +.logout-button:hover { + background: #edf2f7; + color: #2d3748; + border-color: #cbd5e0; +} diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx new file mode 100644 index 0000000..2da6227 --- /dev/null +++ b/frontend/src/components/Navbar.tsx @@ -0,0 +1,34 @@ +import { useAuth } from '../contexts/AuthContext'; +import './Navbar.css'; + +function Navbar() { + const { user, logout } = useAuth(); + + if (!user) return null; + + return ( + + ); +} + +export default Navbar; diff --git a/frontend/src/components/Presence/UserList.tsx b/frontend/src/components/Presence/UserList.tsx index 3702c1b..22a0bee 100644 --- a/frontend/src/components/Presence/UserList.tsx +++ b/frontend/src/components/Presence/UserList.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; +import { Awareness } from "y-protocols/awareness"; interface UserListProps { - awareness: any; + awareness: Awareness; } interface User { @@ -26,6 +27,7 @@ const UserList = ({ awareness }: UserListProps) => { color: state.user.color, }); } + console.log("one of the user name is" + state.user.name); }); setUsers(userList); diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..197ee99 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,33 @@ +import { ReactNode } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; + +interface ProtectedRouteProps { + children: ReactNode; +} + +function ProtectedRoute({ children }: ProtectedRouteProps) { + const { user, loading } = useAuth(); + const location = useLocation(); + + if (loading) { + return ( +
+

Loading...

+
+ ); + } + + if (!user) { + return ; + } + + return <>{children}; +} + +export default ProtectedRoute; diff --git a/frontend/src/components/Share/ShareModal.css b/frontend/src/components/Share/ShareModal.css new file mode 100644 index 0000000..c5a9eb9 --- /dev/null +++ b/frontend/src/components/Share/ShareModal.css @@ -0,0 +1,365 @@ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.modal-content { + background: white; + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + width: 90%; + max-width: 600px; + max-height: 80vh; + overflow: hidden; + display: flex; + flex-direction: column; + animation: slideUp 0.2s ease; +} + +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + border-bottom: 1px solid #e2e8f0; +} + +.modal-header h2 { + margin: 0; + font-size: 20px; + color: #1a202c; +} + +.close-button { + background: none; + border: none; + font-size: 28px; + color: #718096; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + transition: all 0.2s ease; +} + +.close-button:hover { + background: #f7fafc; + color: #2d3748; +} + +.tabs { + display: flex; + border-bottom: 1px solid #e2e8f0; + padding: 0 24px; +} + +.tab { + padding: 12px 20px; + background: none; + border: none; + font-size: 14px; + font-weight: 500; + color: #718096; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.2s ease; +} + +.tab:hover { + color: #2d3748; +} + +.tab.active { + color: #667eea; + border-bottom-color: #667eea; +} + +.tab-content { + padding: 24px; + overflow-y: auto; +} + +.message { + padding: 12px 16px; + border-radius: 6px; + margin-bottom: 16px; + font-size: 14px; +} + +.message.error { + background: #fff5f5; + color: #c53030; + border: 1px solid #feb2b2; +} + +.message.success { + background: #f0fff4; + color: #22543d; + border: 1px solid #9ae6b4; +} + +.share-form { + display: flex; + gap: 8px; + margin-bottom: 24px; +} + +.share-input { + flex: 1; + padding: 10px 12px; + border: 1px solid #e2e8f0; + border-radius: 6px; + font-size: 14px; +} + +.share-input:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.share-select { + padding: 10px 12px; + border: 1px solid #e2e8f0; + border-radius: 6px; + font-size: 14px; + background: white; + cursor: pointer; +} + +.share-select:focus { + outline: none; + border-color: #667eea; +} + +.share-button { + padding: 10px 20px; + background: #667eea; + color: white; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.share-button:hover:not(:disabled) { + background: #5568d3; +} + +.share-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.shares-list h3 { + font-size: 14px; + font-weight: 600; + color: #2d3748; + margin: 0 0 12px 0; +} + +.empty-state { + padding: 32px; + text-align: center; + color: #718096; + font-size: 14px; +} + +.share-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + border: 1px solid #e2e8f0; + border-radius: 8px; + margin-bottom: 8px; +} + +.share-user { + display: flex; + align-items: center; + gap: 12px; +} + +.share-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; +} + +.share-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.share-name { + font-size: 14px; + font-weight: 500; + color: #2d3748; +} + +.share-email { + font-size: 13px; + color: #718096; +} + +.share-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.permission-badge { + padding: 4px 12px; + background: #edf2f7; + color: #4a5568; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + text-transform: capitalize; +} + +.remove-button { + padding: 6px 12px; + background: white; + color: #e53e3e; + border: 1px solid #feb2b2; + border-radius: 6px; + font-size: 13px; + cursor: pointer; + transition: all 0.2s ease; +} + +.remove-button:hover:not(:disabled) { + background: #fff5f5; +} + +.remove-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.link-creation { + text-align: center; +} + +.link-creation p { + color: #4a5568; + margin-bottom: 20px; +} + +.link-form { + display: flex; + gap: 12px; + justify-content: center; + align-items: center; +} + +.link-display { + display: flex; + flex-direction: column; + gap: 16px; +} + +.link-display > p { + color: #4a5568; + margin: 0; +} + +.link-box { + display: flex; + gap: 8px; +} + +.link-input { + flex: 1; + padding: 10px 12px; + border: 1px solid #e2e8f0; + border-radius: 6px; + font-size: 14px; + background: #f7fafc; + color: #2d3748; +} + +.copy-button { + padding: 10px 20px; + background: #48bb78; + color: white; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.copy-button:hover { + background: #38a169; +} + +.link-meta { + display: flex; + align-items: center; + gap: 12px; +} + +.link-date { + font-size: 13px; + color: #718096; +} + +.revoke-button { + padding: 10px 20px; + background: white; + color: #e53e3e; + border: 1px solid #feb2b2; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.revoke-button:hover:not(:disabled) { + background: #fff5f5; +} + +.revoke-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} diff --git a/frontend/src/components/Share/ShareModal.tsx b/frontend/src/components/Share/ShareModal.tsx new file mode 100644 index 0000000..83b07cc --- /dev/null +++ b/frontend/src/components/Share/ShareModal.tsx @@ -0,0 +1,292 @@ +import { useState, useEffect } from 'react'; +import { shareApi } from '../../api/share'; +import type { DocumentShareWithUser, ShareLink } from '../../types/share'; +import './ShareModal.css'; + +interface ShareModalProps { + documentId: string; + onClose: () => void; +} + +function ShareModal({ documentId, onClose }: ShareModalProps) { + const [activeTab, setActiveTab] = useState<'users' | 'link'>('users'); + const [shares, setShares] = useState([]); + const [shareLink, setShareLink] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + // Form state for user sharing + const [userEmail, setUserEmail] = useState(''); + const [permission, setPermission] = useState<'view' | 'edit'>('view'); + + // Form state for link sharing + const [linkPermission, setLinkPermission] = useState<'view' | 'edit'>('view'); + const [copied, setCopied] = useState(false); + + // Load shares on mount + useEffect(() => { + loadShares(); + loadShareLink(); + }, [documentId]); + + const loadShares = async () => { + try { + const data = await shareApi.listShares(documentId); + setShares(data); + } catch (err) { + console.error('Failed to load shares:', err); + } + }; + + const loadShareLink = async () => { + try { + const link = await shareApi.getShareLink(documentId); + setShareLink(link); + if (link) { + setLinkPermission(link.permission); + } + } catch (err) { + console.error('Failed to load share link:', err); + } + }; + + const handleAddUser = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!userEmail.trim()) { + setError('Please enter an email address'); + return; + } + + setLoading(true); + setError(null); + setSuccess(null); + + try { + await shareApi.createShare(documentId, { + user_email: userEmail, + permission, + }); + + setSuccess('User added successfully'); + setUserEmail(''); + await loadShares(); + } catch (err) { + setError('Failed to add user. Make sure the email is registered.'); + } finally { + setLoading(false); + } + }; + + const handleRemoveUser = async (userId: string) => { + if (!confirm('Remove access for this user?')) { + return; + } + + setLoading(true); + setError(null); + + try { + await shareApi.deleteShare(documentId, userId); + setSuccess('User removed successfully'); + await loadShares(); + } catch (err) { + setError('Failed to remove user'); + } finally { + setLoading(false); + } + }; + + const handleGenerateLink = async () => { + setLoading(true); + setError(null); + + try { + const link = await shareApi.createShareLink(documentId, linkPermission); + setShareLink(link); + setSuccess('Share link created'); + } catch (err) { + setError('Failed to create share link'); + } finally { + setLoading(false); + } + }; + + const handleRevokeLink = async () => { + if (!confirm('Revoke this share link? Anyone with the link will lose access.')) { + return; + } + + setLoading(true); + setError(null); + + try { + await shareApi.revokeShareLink(documentId); + setShareLink(null); + setSuccess('Share link revoked'); + } catch (err) { + setError('Failed to revoke share link'); + } finally { + setLoading(false); + } + }; + + const handleCopyLink = () => { + if (!shareLink) return; + + const url = `${window.location.origin}/editor/${documentId}?share=${shareLink.token}`; + navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
e.stopPropagation()}> +
+

Share Document

+ +
+ +
+ + +
+ + {error &&
{error}
} + {success &&
{success}
} + + {activeTab === 'users' && ( +
+
+ setUserEmail(e.target.value)} + className="share-input" + disabled={loading} + /> + + +
+ +
+

People with access

+ {shares.length === 0 ? ( +

No users have been given access yet.

+ ) : ( + shares.map((share) => ( +
+
+ {share.user.avatar_url && ( + {share.user.name} + )} +
+
{share.user.name}
+
{share.user.email}
+
+
+
+ {share.permission} + +
+
+ )) + )} +
+
+ )} + + {activeTab === 'link' && ( +
+ {!shareLink ? ( +
+

Create a public link that anyone can use to access this document.

+
+ + +
+
+ ) : ( +
+

Anyone with this link can {shareLink.permission} this document.

+
+ + +
+
+ {shareLink.permission} + + Created {new Date(shareLink.created_at).toLocaleDateString()} + +
+ +
+ )} +
+ )} +
+
+ ); +} + +export default ShareModal; diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..72df492 --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -0,0 +1,109 @@ +import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import type { User, AuthContextType } from '../types/auth'; +import { authApi } from '../api/auth'; + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [token, setToken] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Initialize auth state on mount + useEffect(() => { + const initAuth = async () => { + const storedToken = localStorage.getItem('auth_token'); + + if (!storedToken) { + setLoading(false); + return; + } + + try { + setToken(storedToken); + const currentUser = await authApi.getCurrentUser(); + setUser(currentUser); + setError(null); + } catch (err) { + console.error('Failed to validate token:', err); + localStorage.removeItem('auth_token'); + setToken(null); + setUser(null); + setError('Session expired'); + } finally { + setLoading(false); + } + }; + + initAuth(); + }, []); + + const login = async (newToken: string) => { + try { + setLoading(true); + setError(null); + + localStorage.setItem('auth_token', newToken); + setToken(newToken); + + const currentUser = await authApi.getCurrentUser(); + setUser(currentUser); + } catch (err) { + console.error('Login failed:', err); + localStorage.removeItem('auth_token'); + setToken(null); + setUser(null); + setError('Login failed'); + throw err; + } finally { + setLoading(false); + } + }; + + const logout = () => { + localStorage.removeItem('auth_token'); + setUser(null); + setToken(null); + setError(null); + + // Call backend logout endpoint (fire and forget) + authApi.logout().catch(console.error); + + // Redirect to login + window.location.href = '/login'; + }; + + const refreshUser = async () => { + if (!token) return; + + try { + const currentUser = await authApi.getCurrentUser(); + setUser(currentUser); + setError(null); + } catch (err) { + console.error('Failed to refresh user:', err); + setError('Failed to refresh user'); + } + }; + + const value: AuthContextType = { + user, + token, + loading, + error, + login, + logout, + refreshUser, + }; + + return {children}; +} + +export function useAuth(): AuthContextType { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/frontend/src/hooks/useYjsDocument.ts b/frontend/src/hooks/useYjsDocument.ts index a6784b2..6de5d96 100644 --- a/frontend/src/hooks/useYjsDocument.ts +++ b/frontend/src/hooks/useYjsDocument.ts @@ -1,14 +1,15 @@ import { useEffect, useState } from "react"; +import { useAuth } from "../contexts/AuthContext"; import { createYjsDocument, destroyYjsDocument, - getRandomColor, - getRandomName, + getColorFromUserId, type YjsProviders, } from "../lib/yjs"; import { useAutoSave } from "./useAutoSave"; -export const useYjsDocument = (documentId: string) => { +export const useYjsDocument = (documentId: string, shareToken?: string) => { + const { user, token } = useAuth(); const [providers, setProviders] = useState(null); const [synced, setSynced] = useState(false); @@ -16,11 +17,36 @@ export const useYjsDocument = (documentId: string) => { useAutoSave(documentId, providers?.ydoc || null); useEffect(() => { + // Wait for auth (unless we have a share token for public access) + if (!shareToken && (!user || !token)) { + return; + } + let mounted = true; let currentProviders: YjsProviders | null = null; const initializeDocument = async () => { - const yjsProviders = await createYjsDocument(documentId); + // For share token access, use placeholder user info + const authUser = user || { + id: "anonymous", + name: "Anonymous User", + avatar_url: undefined, + }; + const realUser = user?.user ? user.user : user || {}; + const currentName = realUser.name || realUser.email || "Anonymous"; + const currentId = realUser.id; + const currentAvatar = realUser.avatar_url || realUser.avatar; + console.log("✅ [Fixed] User Name is:", currentName); + console.log("🔍 [Debug] Initializing Awareness with User:", authUser); // <--- 添加这行 + const authToken = token || ""; + console.log("authToken is " + token); + + const yjsProviders = await createYjsDocument( + documentId, + { id: currentId, name: currentName, avatar_url: currentAvatar }, + authToken, + shareToken + ); currentProviders = yjsProviders; if (!mounted) { @@ -28,12 +54,16 @@ export const useYjsDocument = (documentId: string) => { return; } - // Set user info for awareness - const userName = getRandomName(); - const userColor = getRandomColor(); + console.log( + "🔍 [Debug] Full authUser object:", + JSON.stringify(authUser, null, 2) + ); + // Set user info for awareness with authenticated user data yjsProviders.awareness.setLocalStateField("user", { - name: userName, - color: userColor, + id: currentId, + name: currentName, + color: getColorFromUserId(currentId), + avatar: currentAvatar, }); // NEW: Add awareness event logging @@ -47,7 +77,6 @@ export const useYjsDocument = (documentId: string) => { removed: number[]; }) => { const states = yjsProviders.awareness.getStates(); - added.forEach((clientId) => { const state = states.get(clientId); const user = state?.user; @@ -95,8 +124,8 @@ export const useYjsDocument = (documentId: string) => { ); // Log local user info - console.log(`[Awareness] Local user initialized: ${userName}`, { - color: userColor, + console.log(`[Awareness] Local user initialized: ${authUser.name}`, { + color: getColorFromUserId(authUser.id), clientId: yjsProviders.awareness.clientID, }); @@ -114,7 +143,7 @@ export const useYjsDocument = (documentId: string) => { destroyYjsDocument(currentProviders); } }; - }, [documentId]); + }, [documentId, user, token, shareToken]); return { providers, synced }; diff --git a/frontend/src/lib/yjs.ts b/frontend/src/lib/yjs.ts index c8bc8c2..b720d42 100644 --- a/frontend/src/lib/yjs.ts +++ b/frontend/src/lib/yjs.ts @@ -13,7 +13,18 @@ export interface YjsProviders { awareness: Awareness; } -export const createYjsDocument = async (documentId: string): Promise => { +export interface YjsUser { + id: string; + name: string; + avatar_url?: string; +} + +export const createYjsDocument = async ( + documentId: string, + user: YjsUser, + token: string, + shareToken?: string +): Promise => { // Create Yjs document const ydoc = new Y.Doc(); @@ -31,8 +42,16 @@ export const createYjsDocument = async (documentId: string): Promise { providers.ydoc.destroy(); }; -// Random color generator for users -export const getRandomColor = () => { +// Deterministic color generator based on user ID +export const getColorFromUserId = (userId: string | undefined): string => { + // Default color if no userId + if (!userId) { + return "#718096"; // Gray for anonymous/undefined users + } + + // Hash user ID to consistent color index + const hash = userId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); const colors = [ "#FF6B6B", "#4ECDC4", @@ -63,14 +89,5 @@ export const getRandomColor = () => { "#BB8FCE", "#85C1E2", ]; - return colors[Math.floor(Math.random() * colors.length)]; -}; - -// Random name generator -export const getRandomName = () => { - const adjectives = ["Happy", "Clever", "Brave", "Swift", "Kind"]; - const animals = ["Panda", "Fox", "Wolf", "Bear", "Eagle"]; - return `${adjectives[Math.floor(Math.random() * adjectives.length)]} ${ - animals[Math.floor(Math.random() * animals.length)] - }`; + return colors[hash % colors.length]; }; diff --git a/frontend/src/pages/AuthCallback.tsx b/frontend/src/pages/AuthCallback.tsx new file mode 100644 index 0000000..e6820c7 --- /dev/null +++ b/frontend/src/pages/AuthCallback.tsx @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; + +function AuthCallback() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { login } = useAuth(); + const [error, setError] = useState(null); + + useEffect(() => { + const handleCallback = async () => { + const token = searchParams.get('token'); + const redirect = searchParams.get('redirect') || '/'; + + if (!token) { + setError('No authentication token received'); + setTimeout(() => navigate('/login'), 2000); + return; + } + + try { + await login(token); + navigate(redirect); + } catch (err) { + console.error('Login error:', err); + setError('Authentication failed. Please try again.'); + setTimeout(() => navigate('/login'), 2000); + } + }; + + handleCallback(); + }, [searchParams, login, navigate]); + + return ( +
+
+ {error ? ( + <> +

Error

+

{error}

+

+ Redirecting to login... +

+ + ) : ( + <> +

Logging you in...

+

Please wait while we complete the authentication.

+ + )} +
+
+ ); +} + +export default AuthCallback; diff --git a/frontend/src/pages/EditorPage.tsx b/frontend/src/pages/EditorPage.tsx index 91b087b..65e8dce 100644 --- a/frontend/src/pages/EditorPage.tsx +++ b/frontend/src/pages/EditorPage.tsx @@ -1,12 +1,18 @@ -import { useNavigate, useParams } from "react-router-dom"; +import { useState } from "react"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import Editor from "../components/Editor/Editor.tsx"; import UserList from "../components/Presence/UserList.tsx"; +import ShareModal from "../components/Share/ShareModal.tsx"; +import Navbar from "../components/Navbar.tsx"; import { useYjsDocument } from "../hooks/useYjsDocument.ts"; const EditorPage = () => { const { id } = useParams<{ id: string }>(); + const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const { providers, synced } = useYjsDocument(id!); + const shareToken = searchParams.get('share') || undefined; + const { providers, synced } = useYjsDocument(id!, shareToken); + const [showShareModal, setShowShareModal] = useState(false); if (!providers) { return
Connecting...
; @@ -14,10 +20,18 @@ const EditorPage = () => { return (
+
-
- {synced ? "✓ Synced" : "⟳ Syncing..."} +
+
+ {synced ? "✓ Synced" : "⟳ Syncing..."} +
+ {!shareToken && ( + + )}
@@ -29,6 +43,10 @@ const EditorPage = () => {
+ + {showShareModal && ( + setShowShareModal(false)} /> + )}
); }; diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 7e350c6..bb0626a 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import type { DocumentType } from "../api/document.ts"; import { documentsApi } from "../api/document.ts"; +import Navbar from "../components/Navbar.tsx"; import PixelIcon from "../components/PixelIcon/PixelIcon.tsx"; import FloatingGem from "../components/PixelSprites/FloatingGem.tsx"; @@ -57,12 +58,14 @@ const Home = () => { } return ( -
- - - + <> + +
+ + + -

My Documents

+

My Documents

-
+
+ ); }; diff --git a/frontend/src/pages/KanbanPage.tsx b/frontend/src/pages/KanbanPage.tsx index 91ed887..4992655 100644 --- a/frontend/src/pages/KanbanPage.tsx +++ b/frontend/src/pages/KanbanPage.tsx @@ -1,12 +1,18 @@ -import { useNavigate, useParams } from "react-router-dom"; +import { useState } from "react"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import KanbanBoard from "../components/Kanban/KanbanBoard.tsx"; import UserList from "../components/Presence/UserList.tsx"; +import ShareModal from "../components/Share/ShareModal.tsx"; +import Navbar from "../components/Navbar.tsx"; import { useYjsDocument } from "../hooks/useYjsDocument.ts"; const KanbanPage = () => { const { id } = useParams<{ id: string }>(); + const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const { providers, synced } = useYjsDocument(id!); + const shareToken = searchParams.get('share') || undefined; + const { providers, synced } = useYjsDocument(id!, shareToken); + const [showShareModal, setShowShareModal] = useState(false); if (!providers) { return
Connecting...
; @@ -14,10 +20,18 @@ const KanbanPage = () => { return (
+
-
- {synced ? "✓ Synced" : "⟳ Syncing..."} +
+
+ {synced ? "✓ Synced" : "⟳ Syncing..."} +
+ {!shareToken && ( + + )}
@@ -29,6 +43,10 @@ const KanbanPage = () => {
+ + {showShareModal && ( + setShowShareModal(false)} /> + )}
); }; diff --git a/frontend/src/pages/LoginPage.css b/frontend/src/pages/LoginPage.css new file mode 100644 index 0000000..42d4707 --- /dev/null +++ b/frontend/src/pages/LoginPage.css @@ -0,0 +1,83 @@ +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + padding: 20px; +} + +.login-container { + background: white; + border-radius: 16px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1); + padding: 48px 40px; + max-width: 440px; + width: 100%; + text-align: center; +} + +.login-title { + font-size: 32px; + font-weight: 700; + color: #1a202c; + margin: 0 0 8px 0; +} + +.login-subtitle { + font-size: 16px; + color: #718096; + margin: 0 0 32px 0; +} + +.login-buttons { + display: flex; + flex-direction: column; + gap: 16px; +} + +.login-button { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 14px 24px; + font-size: 16px; + font-weight: 600; + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + width: 100%; +} + +.button-icon { + width: 20px; + height: 20px; +} + +.google-button { + background: #4285f4; + color: white; +} + +.google-button:hover { + background: #357ae8; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(66, 133, 244, 0.3); +} + +.github-button { + background: #24292e; + color: white; +} + +.github-button:hover { + background: #1a1f23; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(36, 41, 46, 0.3); +} + +.login-button:active { + transform: translateY(0); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..cf5a9c3 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,86 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; +import './LoginPage.css'; + +const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080/api"; + +function LoginPage() { + const { user, loading } = useAuth(); + const navigate = useNavigate(); + + useEffect(() => { + if (!loading && user) { + navigate('/'); + } + }, [user, loading, navigate]); + + const handleGoogleLogin = () => { + window.location.href = `${API_BASE_URL}/auth/google`; + }; + + const handleGitHubLogin = () => { + window.location.href = `${API_BASE_URL}/auth/github`; + }; + + if (loading) { + return ( +
+
+

Loading...

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

Realtime Collab

+

Collaborate in real-time with your team

+ +
+ + + +
+
+
+ ); +} + +export default LoginPage; diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts new file mode 100644 index 0000000..f47e880 --- /dev/null +++ b/frontend/src/types/auth.ts @@ -0,0 +1,20 @@ +export interface User { + id: string; + email: string; + name: string; + avatar_url?: string; + provider: string; + created_at: string; + updated_at: string; + last_login_at?: string; +} + +export interface AuthContextType { + user: User | null; + token: string | null; + loading: boolean; + error: string | null; + login: (token: string) => Promise; + logout: () => void; + refreshUser: () => Promise; +} diff --git a/frontend/src/types/share.ts b/frontend/src/types/share.ts new file mode 100644 index 0000000..d7779e2 --- /dev/null +++ b/frontend/src/types/share.ts @@ -0,0 +1,25 @@ +import type { User } from './auth'; + +export interface DocumentShare { + id: string; + document_id: string; + user_id: string; + permission: 'view' | 'edit'; + created_at: string; + created_by?: string; +} + +export interface DocumentShareWithUser extends DocumentShare { + user: User; +} + +export interface CreateShareRequest { + user_email: string; + permission: 'view' | 'edit'; +} + +export interface ShareLink { + token: string; + permission: 'view' | 'edit'; + created_at: string; +}