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