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.
This commit is contained in:
M1ngdaXie
2026-01-06 22:03:07 -08:00
parent 8ae7fd96e8
commit 0a5e6661f1
30 changed files with 1923 additions and 118 deletions

View File

@@ -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
}

View File

@@ -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)
}
if len(room.clients) == 0 {
delete(h.rooms, client.roomID)
log.Printf("Deleted empty room with ID: %s", client.roomID)
// 检查房间是否还有其他人
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 remainingClientsCount == 0 {
delete(h.rooms, 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() {

View File

@@ -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
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/editor/:id" element={<EditorPage />} />
<Route path="/kanban/:id" element={<KanbanPage />} />
</Routes>
</BrowserRouter>
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route
path="/"
element={
<ProtectedRoute>
<Home />
</ProtectedRoute>
}
/>
<Route
path="/editor/:id"
element={
<ProtectedRoute>
<EditorPage />
</ProtectedRoute>
}
/>
<Route
path="/kanban/:id"
element={
<ProtectedRoute>
<KanbanPage />
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter>
</AuthProvider>
);
}

24
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,24 @@
import type { User } from '../types/auth';
import { API_BASE_URL, authFetch } from './client';
export const authApi = {
getCurrentUser: async (): Promise<User> => {
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<void> => {
const response = await authFetch(`${API_BASE_URL}/auth/logout`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to logout');
}
},
};

View File

@@ -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<Response> {
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 };

View File

@@ -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<DocumentType> => {
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<DocumentType> => {
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<void> => {
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<Uint8Array> => {
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,

100
frontend/src/api/share.ts Normal file
View File

@@ -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<DocumentShareWithUser> => {
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<DocumentShareWithUser[]> => {
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<void> => {
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<ShareLink> => {
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<ShareLink | null> => {
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<void> => {
const response = await authFetch(
`${API_BASE_URL}/documents/${documentId}/share-link`,
{
method: 'DELETE',
}
);
if (!response.ok) {
throw new Error('Failed to revoke share link');
}
},
};

View File

@@ -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 (
<div className="kanban-card">
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="kanban-card"
>
<h4>{task.title}</h4>
{task.description && <p>{task.description}</p>}
</div>

View File

@@ -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 (
<div className="kanban-column">
<div
ref={setNodeRef}
className={`kanban-column ${isOver ? 'column-drag-over' : ''}`}
>
<h3 className="column-title">{column.title}</h3>
<div className="column-content">
{column.tasks.map((task) => (
<Card key={task.id} task={task} />
))}
<SortableContext
items={column.tasks.map(t => t.id)}
strategy={verticalListSortingStrategy}
>
{column.tasks.map((task) => (
<Card key={task.id} task={task} />
))}
</SortableContext>
{isAdding ? (
<div className="add-task-form">

View File

@@ -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<KanbanColumn[]>([]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // Prevent accidental drags
},
})
);
useEffect(() => {
const yarray = providers.ydoc.getArray<any>("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 (
<div className="kanban-board">
{columns.map((column) => (
<Column
key={column.id}
column={column}
onAddTask={(task) => addTask(column.id, task)}
onMoveTask={(taskId, toColumnId) =>
moveTask(column.id, toColumnId, taskId)
}
/>
))}
</div>
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<div className="kanban-board">
{columns.map((column) => (
<Column
key={column.id}
column={column}
onAddTask={(task) => addTask(column.id, task)}
onMoveTask={(taskId, toColumnId) =>
moveTask(column.id, toColumnId, taskId)
}
/>
))}
</div>
</DndContext>
);
};

View File

@@ -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;
}

View File

@@ -0,0 +1,34 @@
import { useAuth } from '../contexts/AuthContext';
import './Navbar.css';
function Navbar() {
const { user, logout } = useAuth();
if (!user) return null;
return (
<nav className="navbar">
<div className="navbar-content">
<div className="navbar-brand">
<a href="/">Realtime Collab</a>
</div>
<div className="navbar-user">
{user.avatar_url && (
<img
src={user.avatar_url}
alt={user.name}
className="user-avatar"
/>
)}
<span className="user-name">{user.name}</span>
<button onClick={logout} className="logout-button">
Logout
</button>
</div>
</div>
</nav>
);
}
export default Navbar;

View File

@@ -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);

View File

@@ -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 (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
}}>
<p>Loading...</p>
</div>
);
}
if (!user) {
return <Navigate to={`/login?redirect=${location.pathname}`} replace />;
}
return <>{children}</>;
}
export default ProtectedRoute;

View File

@@ -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;
}

View File

@@ -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<DocumentShareWithUser[]>([]);
const [shareLink, setShareLink] = useState<ShareLink | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(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 (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Share Document</h2>
<button className="close-button" onClick={onClose}>×</button>
</div>
<div className="tabs">
<button
className={`tab ${activeTab === 'users' ? 'active' : ''}`}
onClick={() => setActiveTab('users')}
>
Share with Users
</button>
<button
className={`tab ${activeTab === 'link' ? 'active' : ''}`}
onClick={() => setActiveTab('link')}
>
Public Link
</button>
</div>
{error && <div className="message error">{error}</div>}
{success && <div className="message success">{success}</div>}
{activeTab === 'users' && (
<div className="tab-content">
<form onSubmit={handleAddUser} className="share-form">
<input
type="email"
placeholder="Email address"
value={userEmail}
onChange={(e) => setUserEmail(e.target.value)}
className="share-input"
disabled={loading}
/>
<select
value={permission}
onChange={(e) => setPermission(e.target.value as 'view' | 'edit')}
className="share-select"
disabled={loading}
>
<option value="view">Can view</option>
<option value="edit">Can edit</option>
</select>
<button type="submit" className="share-button" disabled={loading}>
{loading ? 'Adding...' : 'Add User'}
</button>
</form>
<div className="shares-list">
<h3>People with access</h3>
{shares.length === 0 ? (
<p className="empty-state">No users have been given access yet.</p>
) : (
shares.map((share) => (
<div key={share.id} className="share-item">
<div className="share-user">
{share.user.avatar_url && (
<img
src={share.user.avatar_url}
alt={share.user.name}
className="share-avatar"
/>
)}
<div className="share-info">
<div className="share-name">{share.user.name}</div>
<div className="share-email">{share.user.email}</div>
</div>
</div>
<div className="share-actions">
<span className="permission-badge">{share.permission}</span>
<button
onClick={() => handleRemoveUser(share.user_id)}
className="remove-button"
disabled={loading}
>
Remove
</button>
</div>
</div>
))
)}
</div>
</div>
)}
{activeTab === 'link' && (
<div className="tab-content">
{!shareLink ? (
<div className="link-creation">
<p>Create a public link that anyone can use to access this document.</p>
<div className="link-form">
<select
value={linkPermission}
onChange={(e) => setLinkPermission(e.target.value as 'view' | 'edit')}
className="share-select"
disabled={loading}
>
<option value="view">Can view</option>
<option value="edit">Can edit</option>
</select>
<button
onClick={handleGenerateLink}
className="share-button"
disabled={loading}
>
{loading ? 'Creating...' : 'Generate Link'}
</button>
</div>
</div>
) : (
<div className="link-display">
<p>Anyone with this link can {shareLink.permission} this document.</p>
<div className="link-box">
<input
type="text"
value={`${window.location.origin}/editor/${documentId}?share=${shareLink.token}`}
readOnly
className="link-input"
/>
<button onClick={handleCopyLink} className="copy-button">
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
<div className="link-meta">
<span className="permission-badge">{shareLink.permission}</span>
<span className="link-date">
Created {new Date(shareLink.created_at).toLocaleDateString()}
</span>
</div>
<button
onClick={handleRevokeLink}
className="revoke-button"
disabled={loading}
>
{loading ? 'Revoking...' : 'Revoke Link'}
</button>
</div>
)}
</div>
)}
</div>
</div>
);
}
export default ShareModal;

View File

@@ -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<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -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<YjsProviders | null>(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 };

View File

@@ -13,7 +13,18 @@ export interface YjsProviders {
awareness: Awareness;
}
export const createYjsDocument = async (documentId: string): Promise<YjsProviders> => {
export interface YjsUser {
id: string;
name: string;
avatar_url?: string;
}
export const createYjsDocument = async (
documentId: string,
user: YjsUser,
token: string,
shareToken?: string
): Promise<YjsProviders> => {
// Create Yjs document
const ydoc = new Y.Doc();
@@ -31,8 +42,16 @@ export const createYjsDocument = async (documentId: string): Promise<YjsProvider
// IndexedDB persistence (offline support)
const indexeddbProvider = new IndexeddbPersistence(documentId, ydoc);
// WebSocket provider (real-time sync)
const websocketProvider = new WebsocketProvider(WS_URL, documentId, ydoc);
// WebSocket provider (real-time sync) with auth token
const wsParams: { [key: string]: string } = shareToken
? { share: shareToken }
: { token: token };
const websocketProvider = new WebsocketProvider(
WS_URL,
documentId,
ydoc,
{ params: wsParams }
);
// Awareness for cursors and presence
const awareness = websocketProvider.awareness;
@@ -51,8 +70,15 @@ export const destroyYjsDocument = (providers: YjsProviders) => {
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];
};

View File

@@ -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<string | null>(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 (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}>
<div style={{
background: 'white',
borderRadius: '16px',
padding: '48px',
textAlign: 'center',
}}>
{error ? (
<>
<h2 style={{ color: '#e53e3e', marginBottom: '8px' }}>Error</h2>
<p style={{ color: '#718096' }}>{error}</p>
<p style={{ color: '#718096', fontSize: '14px', marginTop: '16px' }}>
Redirecting to login...
</p>
</>
) : (
<>
<h2 style={{ color: '#1a202c', marginBottom: '8px' }}>Logging you in...</h2>
<p style={{ color: '#718096' }}>Please wait while we complete the authentication.</p>
</>
)}
</div>
</div>
);
}
export default AuthCallback;

View File

@@ -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 <div className="loading">Connecting...</div>;
@@ -14,10 +20,18 @@ const EditorPage = () => {
return (
<div className="editor-page">
<Navbar />
<div className="page-header">
<button onClick={() => navigate("/")}> Back to Home</button>
<div className="sync-status">
{synced ? "✓ Synced" : "⟳ Syncing..."}
<div className="header-actions">
<div className="sync-status">
{synced ? "✓ Synced" : "⟳ Syncing..."}
</div>
{!shareToken && (
<button className="share-btn" onClick={() => setShowShareModal(true)}>
Share
</button>
)}
</div>
</div>
@@ -29,6 +43,10 @@ const EditorPage = () => {
<UserList awareness={providers.awareness} />
</div>
</div>
{showShareModal && (
<ShareModal documentId={id!} onClose={() => setShowShareModal(false)} />
)}
</div>
);
};

View File

@@ -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 (
<div className="home-page" style={{ position: 'relative' }}>
<FloatingGem position={{ top: '20px', right: '40px' }} delay={0} size={40} />
<FloatingGem position={{ top: '60px', left: '60px' }} delay={1.5} size={32} />
<FloatingGem position={{ bottom: '100px', right: '100px' }} delay={3} size={36} />
<>
<Navbar />
<div className="home-page" style={{ position: 'relative' }}>
<FloatingGem position={{ top: '20px', right: '40px' }} delay={0} size={40} />
<FloatingGem position={{ top: '60px', left: '60px' }} delay={1.5} size={32} />
<FloatingGem position={{ bottom: '100px', right: '100px' }} delay={3} size={36} />
<h1>My Documents</h1>
<h1>My Documents</h1>
<div className="create-buttons">
<button onClick={() => createDocument("editor")} disabled={creating}>
@@ -105,7 +108,8 @@ const Home = () => {
))
)}
</div>
</div>
</div>
</>
);
};

View File

@@ -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 <div className="loading">Connecting...</div>;
@@ -14,10 +20,18 @@ const KanbanPage = () => {
return (
<div className="kanban-page">
<Navbar />
<div className="page-header">
<button onClick={() => navigate("/")}> Back to Home</button>
<div className="sync-status">
{synced ? "✓ Synced" : "⟳ Syncing..."}
<div className="header-actions">
<div className="sync-status">
{synced ? "✓ Synced" : "⟳ Syncing..."}
</div>
{!shareToken && (
<button className="share-btn" onClick={() => setShowShareModal(true)}>
Share
</button>
)}
</div>
</div>
@@ -29,6 +43,10 @@ const KanbanPage = () => {
<UserList awareness={providers.awareness} />
</div>
</div>
{showShareModal && (
<ShareModal documentId={id!} onClose={() => setShowShareModal(false)} />
)}
</div>
);
};

View File

@@ -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);
}

View File

@@ -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 (
<div className="login-page">
<div className="login-container">
<p>Loading...</p>
</div>
</div>
);
}
return (
<div className="login-page">
<div className="login-container">
<h1 className="login-title">Realtime Collab</h1>
<p className="login-subtitle">Collaborate in real-time with your team</p>
<div className="login-buttons">
<button
className="login-button google-button"
onClick={handleGoogleLogin}
>
<svg className="button-icon" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Sign in with Google
</button>
<button
className="login-button github-button"
onClick={handleGitHubLogin}
>
<svg className="button-icon" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34-.46-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.87 1.52 2.34 1.07 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.92 0-1.11.38-2 1.03-2.71-.1-.25-.45-1.29.1-2.64 0 0 .84-.27 2.75 1.02.79-.22 1.65-.33 2.5-.33.85 0 1.71.11 2.5.33 1.91-1.29 2.75-1.02 2.75-1.02.55 1.35.2 2.39.1 2.64.65.71 1.03 1.6 1.03 2.71 0 3.82-2.34 4.66-4.57 4.91.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2z"
/>
</svg>
Sign in with GitHub
</button>
</div>
</div>
</div>
);
}
export default LoginPage;

View File

@@ -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<void>;
logout: () => void;
refreshUser: () => Promise<void>;
}

View File

@@ -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;
}