Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions api/app/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.venv
venv
__pycache__
*.pyc
.pytest_cache
.mypy_cache
.ruff_cache
.git
.env
.env.*
!.env.example
*.md
tests
18 changes: 18 additions & 0 deletions api/app/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
FROM python:3.12-slim

WORKDIR /app

COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project

COPY . .

RUN uv sync --frozen --no-dev

ENV PATH="/app/.venv/bin:$PATH"

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
9 changes: 6 additions & 3 deletions api/app/app/websocket/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,12 @@ async def board_websocket(
data = await websocket.receive_text()
msg = json.loads(data)
event = msg.get("event")
payload = msg.get("data", {})
if event in (
"cursor.moved",
payload = dict(msg.get("data", {}))
if event == "cursor.moved":
payload["user_id"] = str(user.id)
payload["username"] = user.username or user.email or "Anonymous"
await broadcast_to_board(board_id, event, payload)
elif event in (
"element.created",
"element.updated",
"element.deleted",
Expand Down
7 changes: 5 additions & 2 deletions api/app/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastapi",
"uvicorn",
"uvicorn[standard]",
"sqlalchemy",
"alembic",
"psycopg2-binary",
Expand Down Expand Up @@ -34,4 +34,7 @@ pretty = true

[tool.ruff]
line-length = 100
target-version = "py312"
target-version = "py312"

[tool.pytest.ini_options]
pythonpath = ["."]
9 changes: 9 additions & 0 deletions api/app/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import pytest
from fastapi.testclient import TestClient

from app.main import app


@pytest.fixture
def client() -> TestClient:
return TestClient(app)
15 changes: 15 additions & 0 deletions api/app/tests/test_api_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from fastapi.testclient import TestClient


def test_boards_list_requires_auth(client: TestClient) -> None:
response = client.get("/api/boards?workspace_id=00000000-0000-0000-0000-000000000000")
assert response.status_code == 401


def test_login_rejects_invalid_json(client: TestClient) -> None:
response = client.post(
"/api/auth/login",
content="not json",
headers={"Content-Type": "application/json"},
)
assert response.status_code == 422
7 changes: 7 additions & 0 deletions api/app/tests/test_health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from fastapi.testclient import TestClient


def test_health_returns_ok(client: TestClient) -> None:
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
237 changes: 235 additions & 2 deletions api/app/uv.lock

Large diffs are not rendered by default.

69 changes: 69 additions & 0 deletions apps/frontend/src/components/board/RemoteCursorsOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useEffect, useState } from "react";
import type { RemoteCursor } from "@/hooks/useBoardWebSocket";

/** Editor-like: pageToViewport for positioning; store.listen to react to camera changes. */
interface EditorLike {
pageToViewport: (point: { x: number; y: number }) => { x: number; y: number };
store: { listen: (listener: (...args: unknown[]) => void) => () => void };
}

interface RemoteCursorsOverlayProps {
editor: EditorLike | null;
cursors: Record<string, RemoteCursor>;
}

export function RemoteCursorsOverlay({
editor,
cursors,
}: RemoteCursorsOverlayProps) {
const [, setTick] = useState(0);

// Re-render when camera/zoom changes so cursor positions update
useEffect(() => {
if (!editor) return;
const unsub = editor.store.listen(() => setTick((t) => t + 1));
return unsub;
}, [editor]);

if (!editor) return null;

const entries = Object.entries(cursors);
if (entries.length === 0) return null;

return (
<div
className="absolute inset-0 pointer-events-none overflow-hidden"
aria-hidden
>
{entries.map(([userId, cursor]) => {
const vp = editor.pageToViewport({ x: cursor.x, y: cursor.y });
return (
<div
key={userId}
className="absolute flex items-center gap-1.5 transition-transform duration-75 will-change-transform"
style={{
left: vp.x,
top: vp.y,
transform: "translate(8px, 8px)",
}}
>
<div
className="w-3 h-3 rounded-full border-2 border-[var(--bg-primary)] shadow-sm"
style={{ backgroundColor: "var(--accent)" }}
/>
<span
className="text-xs font-medium px-1.5 py-0.5 rounded max-w-[120px] truncate"
style={{
color: "var(--text-primary)",
backgroundColor: "var(--bg-secondary)",
border: "1px solid var(--border)",
}}
>
{cursor.username}
</span>
</div>
);
})}
</div>
);
}
123 changes: 123 additions & 0 deletions apps/frontend/src/hooks/useBoardWebSocket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getBoardWsUrl } from "@/lib/api";

const CURSOR_EXPIRE_MS = 5000;
const CURSOR_SEND_THROTTLE_MS = 100;

export interface RemoteCursor {
x: number;
y: number;
username: string;
lastSeen: number;
}

export interface UseBoardWebSocketOptions {
/** Current user id so we don't show our own cursor in remote list */
currentUserId?: string | null;
/** Called when a remote tldraw_snapshot document update is received */
onRemoteDocument?: (document: unknown) => void;
}

export function useBoardWebSocket(
boardId: string | null,
token: string | null,
options: UseBoardWebSocketOptions = {},
) {
const { currentUserId = null, onRemoteDocument } = options;
const [connected, setConnected] = useState(false);
const [remoteCursors, setRemoteCursors] = useState<
Record<string, RemoteCursor>
>({});
const wsRef = useRef<WebSocket | null>(null);
const lastSendRef = useRef<number>(0);
const onRemoteDocumentRef = useRef(onRemoteDocument);

useEffect(() => {
onRemoteDocumentRef.current = onRemoteDocument;
}, [onRemoteDocument]);

// Expire stale cursors
useEffect(() => {
if (!connected) return;
const interval = setInterval(() => {
const now = Date.now();
setRemoteCursors((prev) => {
const next: Record<string, RemoteCursor> = {};
for (const [id, c] of Object.entries(prev)) {
if (now - c.lastSeen < CURSOR_EXPIRE_MS) next[id] = c;
}
return Object.keys(next).length === Object.keys(prev).length
? prev
: next;
});
}, 1000);
return () => clearInterval(interval);
}, [connected]);

// Connect and message handling
useEffect(() => {
if (!boardId || !token) return;

const url = getBoardWsUrl(boardId, token);
const ws = new WebSocket(url);
wsRef.current = ws;

ws.onopen = () => setConnected(true);
ws.onclose = () => setConnected(false);
ws.onerror = () => setConnected(false);

ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data as string) as {
event?: string;
data?: Record<string, unknown>;
};
const eventType = msg.event;
const data = msg.data ?? {};

if (eventType === "cursor.moved") {
const userId = data.user_id as string | undefined;
const x = typeof data.x === "number" ? data.x : 0;
const y = typeof data.y === "number" ? data.y : 0;
const username =
typeof data.username === "string" ? data.username : "Anonymous";
if (userId && userId !== currentUserId) {
setRemoteCursors((prev) => ({
...prev,
[userId]: { x, y, username, lastSeen: Date.now() },
}));
}
}

if (
eventType === "element.updated" &&
(data as { type?: string }).type === "tldraw_snapshot" &&
(data as { document?: unknown }).document
) {
const doc = (data as { document: unknown }).document;
onRemoteDocumentRef.current?.(doc);
}
} catch {
// ignore parse errors
}
};

return () => {
ws.close();
wsRef.current = null;
setConnected(false);
setRemoteCursors({});
};
}, [boardId, token, currentUserId]);

const sendCursor = useCallback((x: number, y: number) => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const now = Date.now();
if (now - lastSendRef.current < CURSOR_SEND_THROTTLE_MS) return;
lastSendRef.current = now;
ws.send(JSON.stringify({ event: "cursor.moved", data: { x, y } }));
}, []);

return { connected, remoteCursors, sendCursor };
}
15 changes: 15 additions & 0 deletions apps/frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ import { useAuthStore } from "@/stores/authStore";

const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:8000";

/** Base URL for WebSocket (ws or wss from http or https). */
export function getWsBaseUrl(): string {
const url = API_BASE.trim();
if (url.startsWith("https://")) return url.replace("https://", "wss://");
if (url.startsWith("http://")) return url.replace("http://", "ws://");
return `ws://${url}`;
}

/** WebSocket URL for a board: /api/ws/boards/{boardId}?token=... */
export function getBoardWsUrl(boardId: string, token: string): string {
const base = getWsBaseUrl().replace(/\/$/, "");
const params = new URLSearchParams({ token });
return `${base}/api/ws/boards/${boardId}?${params.toString()}`;
}

function getToken(): string | null {
return useAuthStore.getState().token;
}
Expand Down
Loading
Loading