Add collaboration support
Real-time collaboration through consumer-provided transport and presence interfaces. Also includes a sample backend app.
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
# Flask Collaboration Example
|
||||
|
||||
A minimal Flask server demonstrating ribbit's collaboration features:
|
||||
real-time sync, presence, locking, and revisions.
|
||||
|
||||
## Setup
|
||||
|
||||
```sh
|
||||
pip install flask flask-sock
|
||||
```
|
||||
|
||||
Copy (or symlink) the ribbit dist into the static directory:
|
||||
|
||||
```sh
|
||||
ln -s /path/to/ribbit/dist/ribbit static/ribbit
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
python server.py
|
||||
```
|
||||
|
||||
Open http://localhost:5000 in multiple browser tabs. Edits in one tab
|
||||
appear in the others in real time.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
- **Real-time sync**: WebSocket relays document updates between clients
|
||||
- **Presence**: colored badges show connected users and their status
|
||||
- **Revisions**: save button creates named revisions, click to restore
|
||||
- **Locking**: (available via console: `editor.lockForEditing()`)
|
||||
- **Source mode**: entering markdown mode pauses sync, shows remote change count
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser A ──┐
|
||||
├── WebSocket ──→ Flask server ──→ WebSocket ──→ Browser B
|
||||
Browser C ──┘ │
|
||||
├── /api/revisions (REST)
|
||||
└── /api/lock (REST)
|
||||
```
|
||||
|
||||
The server is ~160 lines. In production you'd replace the in-memory
|
||||
stores with a database and add authentication.
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Flask collaboration server example for ribbit.
|
||||
|
||||
Demonstrates: WebSocket relay, presence, revisions, and locking.
|
||||
Requires: flask, flask-sock
|
||||
|
||||
pip install flask flask-sock
|
||||
python server.py
|
||||
|
||||
Then open http://localhost:5000 in multiple browser tabs.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
|
||||
from flask import Flask, jsonify, render_template, request
|
||||
from flask_sock import Sock
|
||||
|
||||
app = Flask(__name__)
|
||||
sock = Sock(app)
|
||||
|
||||
# In-memory state (replace with a database in production)
|
||||
document = {"content": "# Hello\n\nEdit this page collaboratively.\n\n- Try opening multiple tabs\n- Watch edits appear in real time\n"}
|
||||
revisions = []
|
||||
lock_holder = None
|
||||
lock_mutex = Lock()
|
||||
clients = {} # ws -> user info
|
||||
|
||||
|
||||
# ── Pages ────────────────────────────────────────────────
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("index.html", content=document["content"])
|
||||
|
||||
|
||||
# ── Revisions API ────────────────────────────────────────
|
||||
|
||||
@app.route("/api/revisions", methods=["GET"])
|
||||
def list_revisions():
|
||||
return jsonify([{k: v for k, v in r.items() if k != "content"} for r in revisions])
|
||||
|
||||
|
||||
@app.route("/api/revisions/<revision_id>", methods=["GET"])
|
||||
def get_revision(revision_id):
|
||||
for r in revisions:
|
||||
if r["id"] == revision_id:
|
||||
return jsonify(r)
|
||||
return jsonify({"error": "not found"}), 404
|
||||
|
||||
|
||||
@app.route("/api/revisions", methods=["POST"])
|
||||
def create_revision():
|
||||
data = request.json
|
||||
rev = {
|
||||
"id": str(uuid.uuid4())[:8],
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"author": data.get("author", "anonymous"),
|
||||
"summary": data.get("summary", ""),
|
||||
"content": data.get("content", document["content"]),
|
||||
}
|
||||
revisions.append(rev)
|
||||
broadcast_json({"type": "revision", "revision": {k: v for k, v in rev.items() if k != "content"}})
|
||||
return jsonify(rev), 201
|
||||
|
||||
|
||||
# ── Locking API ──────────────────────────────────────────
|
||||
|
||||
@app.route("/api/lock", methods=["POST"])
|
||||
def acquire_lock():
|
||||
global lock_holder
|
||||
with lock_mutex:
|
||||
if lock_holder is None:
|
||||
lock_holder = request.json
|
||||
broadcast_json({"type": "lock", "holder": lock_holder})
|
||||
return jsonify({"ok": True})
|
||||
return jsonify({"ok": False, "holder": lock_holder}), 409
|
||||
|
||||
|
||||
@app.route("/api/lock", methods=["DELETE"])
|
||||
def release_lock():
|
||||
global lock_holder
|
||||
with lock_mutex:
|
||||
lock_holder = None
|
||||
broadcast_json({"type": "lock", "holder": None})
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/api/lock/force", methods=["POST"])
|
||||
def force_lock():
|
||||
global lock_holder
|
||||
with lock_mutex:
|
||||
lock_holder = request.json
|
||||
broadcast_json({"type": "lock", "holder": lock_holder})
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# ── WebSocket relay ──────────────────────────────────────
|
||||
|
||||
@sock.route("/ws")
|
||||
def websocket(ws):
|
||||
client_id = str(uuid.uuid4())[:8]
|
||||
clients[client_id] = {"ws": ws, "user": None}
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = ws.receive()
|
||||
|
||||
if isinstance(data, bytes):
|
||||
# Binary = document update, relay to all other clients
|
||||
document["content"] = data.decode("utf-8")
|
||||
for cid, client in clients.items():
|
||||
if cid != client_id:
|
||||
try:
|
||||
client["ws"].send(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elif isinstance(data, str):
|
||||
msg = json.loads(data)
|
||||
|
||||
if msg.get("type") == "join":
|
||||
clients[client_id]["user"] = msg.get("user")
|
||||
# Send current document state
|
||||
ws.send(document["content"].encode("utf-8"))
|
||||
# Send current lock state
|
||||
ws.send(json.dumps({"type": "lock", "holder": lock_holder}))
|
||||
# Broadcast updated peer list
|
||||
broadcast_peers()
|
||||
|
||||
elif msg.get("type") == "presence":
|
||||
clients[client_id]["user"] = msg
|
||||
broadcast_peers()
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
del clients[client_id]
|
||||
broadcast_peers()
|
||||
|
||||
|
||||
def broadcast_json(msg):
|
||||
data = json.dumps(msg)
|
||||
for client in clients.values():
|
||||
try:
|
||||
client["ws"].send(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def broadcast_peers():
|
||||
peers = [c["user"] for c in clients.values() if c["user"]]
|
||||
broadcast_json({"type": "peers", "peers": peers})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True, port=5000)
|
||||
@@ -0,0 +1,164 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Ribbit Collaboration Example</title>
|
||||
<link rel="stylesheet" href="/static/ribbit/themes/ribbit-default/theme.css">
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 800px; margin: 40px auto; }
|
||||
#peers { padding: 8px; background: #f0f0f0; border-radius: 4px; margin-bottom: 10px; font-size: 14px; }
|
||||
#peers .peer { display: inline-block; padding: 2px 8px; border-radius: 3px; margin-right: 4px; color: white; }
|
||||
#status { font-size: 12px; color: #666; margin-bottom: 10px; }
|
||||
#revisions { margin-top: 20px; }
|
||||
#revisions button { margin: 2px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Ribbit Collaboration Example</h1>
|
||||
<div id="peers">No peers connected</div>
|
||||
<div id="status"></div>
|
||||
<article id="ribbit">{{ content }}</article>
|
||||
<div id="revisions">
|
||||
<h3>Revisions</h3>
|
||||
<div id="revision-list">Loading...</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/ribbit/ribbit.js"></script>
|
||||
<script>
|
||||
const userId = 'user-' + Math.random().toString(36).slice(2, 6);
|
||||
const colors = ['#e74c3c', '#3498db', '#2ecc71', '#9b59b6', '#f39c12', '#1abc9c'];
|
||||
const color = colors[Math.floor(Math.random() * colors.length)];
|
||||
|
||||
const ws = new WebSocket(`ws://${location.host}/ws`);
|
||||
|
||||
const transport = {
|
||||
connect() {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'join',
|
||||
user: { userId, displayName: userId, color, status: 'active', lastActive: Date.now() },
|
||||
}));
|
||||
},
|
||||
disconnect() {},
|
||||
send(update) { if (ws.readyState === 1) ws.send(update); },
|
||||
onReceive(callback) {
|
||||
ws.addEventListener('message', (e) => {
|
||||
if (e.data instanceof Blob) {
|
||||
e.data.arrayBuffer().then(buf => callback(new Uint8Array(buf)));
|
||||
}
|
||||
});
|
||||
},
|
||||
async lock() {
|
||||
const res = await fetch('/api/lock', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId, displayName: userId }),
|
||||
});
|
||||
return res.ok;
|
||||
},
|
||||
unlock() { fetch('/api/lock', { method: 'DELETE' }); },
|
||||
async forceLock() {
|
||||
const res = await fetch('/api/lock/force', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId, displayName: userId }),
|
||||
});
|
||||
return res.ok;
|
||||
},
|
||||
onLockChange(callback) {
|
||||
ws.addEventListener('message', (e) => {
|
||||
if (typeof e.data === 'string') {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'lock') callback(msg.holder);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const presence = {
|
||||
send(info) {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'presence', ...info }));
|
||||
},
|
||||
onUpdate(callback) {
|
||||
ws.addEventListener('message', (e) => {
|
||||
if (typeof e.data === 'string') {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'peers') callback(msg.peers);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const revisions = {
|
||||
async list() {
|
||||
return (await fetch('/api/revisions')).json();
|
||||
},
|
||||
async get(id) {
|
||||
return (await fetch(`/api/revisions/${id}`)).json();
|
||||
},
|
||||
async create(content, metadata) {
|
||||
const res = await fetch('/api/revisions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, ...metadata }),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
|
||||
const editor = new ribbit.Editor({
|
||||
collaboration: {
|
||||
transport,
|
||||
presence,
|
||||
revisions,
|
||||
user: { userId, displayName: userId, color, status: 'active', lastActive: Date.now() },
|
||||
},
|
||||
on: {
|
||||
peerChange({ peers }) {
|
||||
const el = document.getElementById('peers');
|
||||
if (peers.length === 0) {
|
||||
el.innerHTML = 'No peers connected';
|
||||
} else {
|
||||
el.innerHTML = peers.map(p =>
|
||||
`<span class="peer" style="background:${p.color || '#999'}">${p.displayName} (${p.status})</span>`
|
||||
).join('');
|
||||
}
|
||||
},
|
||||
lockChange({ holder }) {
|
||||
const el = document.getElementById('status');
|
||||
el.textContent = holder ? `🔒 Locked by ${holder.displayName}` : '';
|
||||
},
|
||||
remoteActivity({ count }) {
|
||||
const el = document.getElementById('status');
|
||||
el.textContent = `⚡ ${count} remote change${count > 1 ? 's' : ''} while in source mode`;
|
||||
},
|
||||
save({ markdown }) {
|
||||
revisions.create(markdown, { author: userId, summary: 'Manual save' }).then(refreshRevisions);
|
||||
},
|
||||
revisionCreated() {
|
||||
refreshRevisions();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
editor.run();
|
||||
|
||||
async function refreshRevisions() {
|
||||
const list = await editor.listRevisions();
|
||||
const el = document.getElementById('revision-list');
|
||||
if (list.length === 0) {
|
||||
el.innerHTML = '<em>No revisions yet. Click Save to create one.</em>';
|
||||
} else {
|
||||
el.innerHTML = list.map(r =>
|
||||
`<button onclick="restore('${r.id}')">${r.timestamp} by ${r.author}${r.summary ? ': ' + r.summary : ''}</button>`
|
||||
).join('<br>');
|
||||
}
|
||||
}
|
||||
|
||||
window.restore = async function(id) {
|
||||
await editor.restoreRevision(id);
|
||||
};
|
||||
|
||||
refreshRevisions();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user