189 lines
8.0 KiB
HTML
189 lines
8.0 KiB
HTML
<!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; }
|
|
.examples { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 16px 0; }
|
|
.example { border: 1px solid #ddd; border-radius: 4px; padding: 12px; }
|
|
.example h3 { margin: 0 0 8px 0; font-size: 13px; color: #666; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
#status { font-size: 12px; color: #666; margin-bottom: 10px; }
|
|
#revisions { margin-top: 20px; }
|
|
#revisions button { margin: 2px; }
|
|
#ribbit { border: 1px solid #ccc; border-radius: 4px; padding: 20px; min-height: 200px; }
|
|
.ribbit-toolbar { background: #f5f5f5; border: 1px solid #ccc; border-radius: 4px; padding: 4px; margin-bottom: 8px; }
|
|
.ribbit-toolbar ul { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 2px; align-items: center; }
|
|
.ribbit-toolbar button { padding: 4px 8px; border: 1px solid #ddd; border-radius: 3px; background: white; cursor: pointer; font-size: 12px; }
|
|
.ribbit-toolbar button:hover { background: #e8e8e8; }
|
|
.ribbit-toolbar button.active { background: #d0d0ff; border-color: #99f; }
|
|
.ribbit-toolbar button.disabled { opacity: 0.3; cursor: default; }
|
|
.ribbit-toolbar .spacer { width: 12px; }
|
|
.ribbit-dropdown { position: absolute; background: white; border: 1px solid #ccc; border-radius: 4px; padding: 4px; z-index: 10; }
|
|
.ribbit-dropdown button { display: block; width: 100%; text-align: left; margin: 1px 0; }
|
|
</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({
|
|
macros: [
|
|
{
|
|
name: 'block',
|
|
block: true,
|
|
toHTML: ({ keywords, content }) => {
|
|
const className = keywords.join(' ');
|
|
const classAttr = className ? ' class="' + className + '"' : '';
|
|
return '<div' + classAttr + '>' + (content || '') + '</div>';
|
|
},
|
|
},
|
|
],
|
|
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>
|