From e22db2ea7b24058aeda79e0aa34d1c648df7a82e Mon Sep 17 00:00:00 2001 From: Arjay Bayona Date: Sat, 12 Sep 2026 00:44:18 +0800 Subject: [PATCH 01/10] feat(examples): add collaborative kanban board --- README.md | 49 +++- examples/collaboration/example-shell.js | 274 +++++++++++++++++ examples/collaboration/index.html | 2 +- examples/collaboration/kanban.html | 101 +++++++ examples/collaboration/kanban.js | 5 + examples/collaboration/kanban/board.css | 72 +++++ examples/collaboration/kanban/board.js | 277 ++++++++++++++++++ examples/collaboration/kanban/model.js | 58 ++++ examples/collaboration/kanban/model.test.js | 72 +++++ examples/collaboration/main.js | 269 +---------------- examples/collaboration/presence.js | 6 +- examples/collaboration/tests/kanban.spec.js | 140 +++++++++ examples/collaboration/vite.config.js | 1 + .../controllers/client_controller.ex | 1 + lib/synixir_web/router.ex | 1 + package.json | 2 +- 16 files changed, 1058 insertions(+), 272 deletions(-) create mode 100644 examples/collaboration/example-shell.js create mode 100644 examples/collaboration/kanban.html create mode 100644 examples/collaboration/kanban.js create mode 100644 examples/collaboration/kanban/board.css create mode 100644 examples/collaboration/kanban/board.js create mode 100644 examples/collaboration/kanban/model.js create mode 100644 examples/collaboration/kanban/model.test.js create mode 100644 examples/collaboration/tests/kanban.spec.js diff --git a/README.md b/README.md index f1ec781..8aad801 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ Synixir is an Elixir/Phoenix collaboration backend in early development. It connects browser Yjs documents to supervised Yex processes through Phoenix Channels. Each room has its own document process backed by a PostgreSQL update log, and clients need a signed room access token to join. The browser example -provides a shared plain text editor with live cursors, participant information, -and recovery after a server crash. +provides a shared plain text editor with live cursors. A Kanban example adds +shared cards, drag-and-drop, and card activity. Both recover saved state after a +server crash. This implementation runs on one Phoenix node. Accounts, expiring sessions, and owner/editor/viewer room permissions are implemented. The browser SDK lives in @@ -138,6 +139,50 @@ access callbacks. `/sdk.html?room=` provides a second small consumer that synchronizes a title in a Y.Map without CodeMirror. It uses the same signed-in account and room membership as the editor. +## Try the Kanban example + +Open [127.0.0.1:5173/kanban.html](http://127.0.0.1:5173/kanban.html) with the same +development servers running. Sign in and create a board, or open an existing room. +The text editor and board link to each other and share account and room access. +The production build also includes `/kanban.html`. + +- Add cards to Backlog, In progress, or Done. Open a card to edit its title, + description, color, and status. Changes save as you type. +- Drag cards between columns, or use the status selector with a keyboard or on + a touch device. Moved cards go to the end of the destination column. +- Open the same board in two tabs to see changes and card activity. To collaborate + with another account, grant its username access under **Manage access**. +- Undo and redo affect this tab's local changes. Deleted cards can be restored + with Undo. Viewers can inspect cards and publish presence, but cannot edit them. +- Offline changes remain in the open tab. Reconnect and wait for **Saved** before + closing it. Saved boards recover through the existing PostgreSQL document log. + +The [board model](examples/collaboration/kanban/model.js) stores each card as a +nested Y.Map under `kanban:cards:v1`. Column and order are a single placement value, +so simultaneous moves converge to one location without duplicating a card. +Edits to different fields merge. Concurrent edits to the same title, description, +color, or placement resolve through Y.Map's conflict rules to one value; these +fields do not provide character-level text merging. A deletion wins over a +concurrent edit to that card. Ordering ties use the card ID. This first example +has fixed columns and does not support reordering within a column. + +The [shared example shell](examples/collaboration/example-shell.js) owns account, +room, connection, permission, and cleanup UI for both examples. Each editing +surface consumes the public SDK. Kanban's data uses a separate shared type, so +opening a text room as a board does not change its text content. + +`npm test` includes model checks for concurrent creation, moves, deletion, and +local undo. The browser suite includes Kanban sync, presence, dragging, offline +recovery, server restart, permissions, room navigation, and mobile layout. + +Run only the Kanban browser checks with: + +```sh +npm test --workspace synixir-collaboration-example -- kanban.spec.js +``` + +## Editor presence and connection states + Each account has a stable username; each editor visit gets a cursor color. The participant list counts connected editor visits, including multiple tabs for one account. Names and cursor positions are diff --git a/examples/collaboration/example-shell.js b/examples/collaboration/example-shell.js new file mode 100644 index 0000000..326a123 --- /dev/null +++ b/examples/collaboration/example-shell.js @@ -0,0 +1,274 @@ +import { SynixirRoom } from "@synixir/client"; +import { showParticipants } from "./presence.js"; +import "./style.css"; + +export function startExample({ createSurface, pagePath = "/", presenceOptions }) { + const roomId = new URL(window.location.href).searchParams.get("room"); + let csrf; + let clearCollaboration = () => {}; + let hasUnsavedChanges = () => false; + + async function api(path, { method = "GET", body } = {}) { + const response = await fetch(path, { + method, credentials: "same-origin", signal: AbortSignal.timeout(10_000), + headers: { "Content-Type": "application/json", ...(csrf ? { "x-csrf-token": csrf } : {}) }, + body: body ? JSON.stringify(body) : undefined, + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error ?? "request_failed"); + if (result.csrf_token) csrf = result.csrf_token; + return result; + } + + function explain(error) { + return { + invalid_credentials: "Username or password is incorrect.", + invalid_registration: "Choose an available username and a password of 15–128 characters.", + rate_limited: "Too many attempts. Wait a minute and try again.", + unauthorized: "Access expired or denied", + forbidden: "Only an owner can manage room access.", + last_owner: "Add another owner before removing or changing the last owner.", + room_unavailable: "That room ID is unavailable. Choose another.", + room_quota: "This account has reached its room ownership limit. Ask an operator to review the limit.", + account_not_found: "No account has that username.", + }[error.message] ?? "The request failed. Please try again."; + } + + function sessionChanged() { + try { localStorage.setItem("synixir:session-change", crypto.randomUUID()); } catch { /* Storage may be disabled. */ } + } + window.addEventListener("storage", event => { + if (event.key === "synixir:session-change") { + clearCollaboration(); + window.location.reload(); + } + }); + + document.querySelector("#auth-form").addEventListener("submit", async event => { + event.preventDefault(); + const form = event.currentTarget; + const values = Object.fromEntries(new FormData(form)); + const path = event.submitter.value === "register" ? "/api/accounts" : "/api/session"; + const buttons = [...form.querySelectorAll("button")]; + buttons.forEach(button => button.disabled = true); + try { + await api(path, { method: "POST", body: values }); + sessionChanged(); + window.location.reload(); + } catch (error) { + document.querySelector("#account-error").textContent = explain(error); + buttons.forEach(button => button.disabled = false); + } + }); + + document.querySelector("#sign-out").addEventListener("click", async () => { + if (hasUnsavedChanges() && !window.confirm("Sign out and discard unsaved changes? Copy your draft first if you need it.")) return; + try { + await api("/api/session", { method: "DELETE" }); + clearCollaboration(); + sessionChanged(); + window.location.reload(); + } catch (error) { document.querySelector("#account-error").textContent = explain(error); } + }); + + document.querySelector("#create-room-form").addEventListener("submit", async event => { + event.preventDefault(); + try { + const { data } = await api("/api/rooms", { method: "POST", body: Object.fromEntries(new FormData(event.currentTarget)) }); + window.location.href = `${pagePath}?room=${encodeURIComponent(data.id)}`; + } catch (error) { document.querySelector("#account-error").textContent = explain(error); } + }); + + async function boot() { + try { + const { user } = await api("/api/session"); + document.querySelector("#auth-panel").hidden = Boolean(user); + document.querySelector("#account-bar").hidden = !user; + if (!user) return; + document.querySelector("#account-name").textContent = user.username; + if (roomId) { + document.querySelector("#collaboration").hidden = false; + startCollaboration(user); + } else { + document.querySelector("#rooms-panel").hidden = false; + const { data } = await api("/api/rooms"); + document.querySelector("#room-list").replaceChildren(...data.map(room => { + const item = document.createElement("li"); + const link = document.createElement("a"); + link.href = `${pagePath}?room=${encodeURIComponent(room.id)}`; + link.textContent = `${room.id} · ${room.role}`; + item.append(link); + return item; + })); + } + } catch (error) { document.querySelector("#account-error").textContent = explain(error); } + } + document.querySelectorAll("[data-room-home]").forEach(link => { link.href = pagePath; }); + const exampleLink = document.querySelector("#other-example"); + if (exampleLink && roomId) exampleLink.href += `?room=${encodeURIComponent(roomId)}`; + void boot(); + + function startCollaboration(user) { + document.querySelector("#room").value = roomId; + document.querySelector("#document-title").textContent = roomId; + + const room = new SynixirRoom({ roomId, userId: user.id }); + const status = document.querySelector("#status"); + const connection = document.querySelector("#connection"); + const stopParticipants = showParticipants(room, user.username, presenceOptions); + const destroySurface = createSurface(room); + let unsaved = false; + const saveErrors = { + storage_quota: "This room has reached its storage limit. Keep this tab open or copy your draft, and ask an operator for more space before retrying.", + message_too_large: "This update exceeds the transfer limit. Keep this tab open and reduce the size of your changes before retrying.", + rate_limited: "Too many updates at once. Wait a moment, then disconnect and connect to retry. Keep this tab open.", + invalid_message: "The server rejected this update. Keep this tab open so your draft remains available.", + timeout: "The save acknowledgement did not arrive. Disconnect and connect again to confirm your edits. Keep this tab open.", + }; + function renderSave(value, reason) { + const saveStatus = document.querySelector("#save-status"); + saveStatus.textContent = value; + saveStatus.dataset.state = value === "Saved" ? "saved" : value === "Save failed" ? "failed" : "unsaved"; + unsaved = ["Saving", "Unsaved changes", "Save failed"].includes(value); + document.querySelector("#save-help").textContent = value === "Save failed" + ? saveErrors[reason] ?? "Save could not be confirmed. Disconnect and connect again to retry. Keep this tab open." + : unsaved ? "Keep this tab open until Saved appears. Offline edits stay in this tab until you reconnect." + : "Saved edits stay in this room after everyone leaves."; + } + const saveLabels = { "not-saved": "Not saved", saving: "Saving", saved: "Saved", + unsaved: "Unsaved changes", failed: "Save failed", "view-only": "View only" }; + const connectionLabels = { connected: "Connected", connecting: "Connecting", + reconnecting: "Reconnecting", disconnected: "Disconnected" }; + const connectionErrors = { + node_channel_quota: "The server has reached its connection limit. Keep your draft and try again later.", + room_channel_quota: "This room has reached its participant limit. Keep your draft and try again later.", + account_channel_quota: "Too many room tabs are open for this account. Disconnect another tab before retrying.", + document_quota: "The server has reached its active document limit. Keep your draft and try again later.", + }; + let disposed = false; + let previousRole; + let checkedRevocation = false; + let stopState = () => {}; + stopState = room.subscribe(state => { + if (disposed) return; + renderSave(saveLabels[state.saveStatus], state.saveError); + unsaved = state.hasUnsavedChanges; + destroySurface.setReadOnly(state.readOnly); + document.querySelector("#role-label").textContent = state.role ?? ""; + document.querySelector("#members-panel").hidden = state.role !== "owner"; + if (state.role === "owner" && previousRole !== state.role) void loadMembers(); + previousRole = state.role; + const frozen = ["access_changed", "account_changed"].includes(state.error?.code); + const active = ["connecting", "connected", "reconnecting"].includes(state.connection); + connection.textContent = frozen ? "Reload" : active ? "Disconnect" : "Connect"; + connection.disabled = false; + status.textContent = connectionLabels[state.connection] ?? + (frozen ? "Access changed. Reload to check permissions." + : state.error?.code === "unauthorized" ? "Access expired or denied" + : state.error?.code === "timeout" ? "Connection timed out" + : connectionErrors[state.error?.code] ?? "Document sync failed. Keep this tab open."); + status.dataset.state = state.connection === "error" ? "error" + : state.connection === "connected" ? "connected" : active ? "connecting" : "disconnected"; + if (state.error?.code === "account_changed") { + clearCollaboration(); + window.location.reload(); + } else if (frozen && !checkedRevocation) { + checkedRevocation = true; + void checkRevokedSession(); + } + }); + + // Connection errors are rendered by the subscription. Network failures also + // recover automatically; the button permits explicit disconnect/retry. + function connect() { void room.connect().catch(() => {}); } + connection.addEventListener("click", () => { + if (["access_changed", "account_changed"].includes(room.state.error?.code)) return window.location.reload(); + if (["connecting", "connected", "reconnecting"].includes(room.state.connection)) void room.disconnect(); + else connect(); + }); + + function dispose() { + if (disposed) return; + disposed = true; + stopState(); + stopParticipants(); + destroySurface(); + void room.destroy(); + } + window.addEventListener("pagehide", dispose); + clearCollaboration = () => { + unsaved = false; + dispose(); + document.querySelector("#editor").replaceChildren(); + document.querySelector("#participants").replaceChildren(); + document.querySelector("#collaboration").hidden = true; + }; + hasUnsavedChanges = () => unsaved; + + async function checkRevokedSession() { + try { + const session = await api("/api/session"); + if (session.user?.id !== user.id) { + clearCollaboration(); + window.location.reload(); + } + } catch { /* Keep the revoked draft available to copy. */ } + } + + async function loadMembers() { + try { + const { data } = await api(`/api/rooms/${encodeURIComponent(roomId)}/members`); + document.querySelector("#members-list").replaceChildren(...data.map(member => { + const item = document.createElement("li"); + const label = document.createElement("span"); + label.textContent = member.username; + const select = document.createElement("select"); + select.setAttribute("aria-label", `Role for ${member.username}`); + for (const role of ["owner", "editor", "viewer"]) { + const option = document.createElement("option"); + option.value = role; + option.textContent = role; + select.append(option); + } + select.value = member.role; + select.addEventListener("change", () => changeMember(member.username, select.value)); + const remove = document.createElement("button"); + remove.textContent = "Remove"; + remove.setAttribute("aria-label", `Remove ${member.username}`); + remove.addEventListener("click", () => changeMember(member.username, null)); + item.append(label, select, remove); + return item; + })); + } catch (error) { document.querySelector("#member-error").textContent = explain(error); } + } + async function changeMember(username, nextRole) { + try { + await api(`/api/rooms/${encodeURIComponent(roomId)}/members/${encodeURIComponent(username)}`, { + method: nextRole ? "PUT" : "DELETE", body: nextRole ? { role: nextRole } : undefined, + }); + document.querySelector("#member-error").textContent = ""; + await loadMembers(); + } catch (error) { document.querySelector("#member-error").textContent = explain(error); } + } + document.querySelector("#member-form").addEventListener("submit", event => { + event.preventDefault(); + const values = new FormData(event.currentTarget); + void changeMember(values.get("username"), values.get("role")); + }); + + window.addEventListener("beforeunload", (event) => { + if (unsaved) { + event.preventDefault(); + event.returnValue = ""; + } + }); + + // A page restored from the back/forward cache needs a fresh connection and editor. + window.addEventListener("pageshow", (event) => { + if (event.persisted) window.location.reload(); + }); + + connect(); + } + +} diff --git a/examples/collaboration/index.html b/examples/collaboration/index.html index 8e38271..db49321 100644 --- a/examples/collaboration/index.html +++ b/examples/collaboration/index.html @@ -10,7 +10,7 @@
Synixir - Collaborative documents + Try the Kanban board