feat: presença por sessão, eventos SSE em tempo real e autosave
- presence.go: hub SSE + registro de presença (TTL 30s por heartbeat)
- GET /api/events (EventSource, auth cookie/Bearer) transmite saved/presence;
POST /api/presence publica {session, notes[]}; create/update/restore/delete
publicam savedEvent com a session de origem (eco ignorado pelo autor)
- PUT/POST/restore aceitam campo opcional session
- UI: id de sessão em sessionStorage; selo 👥 na aba, na lista de notas e no
topo do editor quando outra sessão tem a mesma nota aberta; aba LIMPA
recarrega sozinha ao receber saved; aba SUJA abre o diálogo de conflito
automaticamente (conflictPending evita diálogos empilhados)
- Autosave: debounce de 1,5s em qualquer alteração (rascunho vira nota),
mensagem de revisão 'autosave', status 'salvando…', toggle nas Preferências
- Testes: SSE hello/presence/saved + presença 400/401 via httptest
This commit is contained in:
1 parent
3b91c1a509
commit
a55591d208
9 files changed
+543
-15
No files matched your search
@@ -919,3 +919,17 @@ html[data-theme="solar-escuro"] #revisions-list li:hover { background: #0c4450;
|
||||
}
|
||||
#wysiwyg:empty::before { content: 'Comece a escrever…'; color: var(--muted); }
|
||||
#wysiwyg [contenteditable="true"]:focus { outline: none; }
|
||||
|
||||
/* ------------------------------------------------- presença e autosave */
|
||||
.presence-badge {
|
||||
align-self: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--warn);
|
||||
white-space: nowrap;
|
||||
flex: none;
|
||||
}
|
||||
.tab .tab-pres { color: var(--warn); font-size: 10px; flex: none; }
|
||||
#st-saved.pending { color: var(--accent); font-weight: 600; }
|
||||
@@ -34,6 +34,7 @@
|
||||
<label class="row"><input type="checkbox" id="pf-colmark"> Marcar coluna
|
||||
<input type="number" id="pf-col" min="20" max="320" step="1" class="num"> (padrão 80)</label>
|
||||
<label class="row"><input type="checkbox" id="pf-hl"> Colorir sintaxe (Markdown e HTML)</label>
|
||||
<label class="row"><input type="checkbox" id="pf-autosave"> Autosave — salva automaticamente ~1,5 s após parar de digitar</label>
|
||||
<label class="row">Tecla Tab
|
||||
<select id="pf-tabmode" class="compact">
|
||||
<option value="spaces">insere espaços</option>
|
||||
@@ -222,6 +223,7 @@
|
||||
</div>
|
||||
<div id="note-meta">
|
||||
<input id="note-title" type="text" placeholder="Título da nota" maxlength="200" autocomplete="off">
|
||||
<span id="presence-badge" class="presence-badge hidden" title="Outras sessões com esta nota aberta"></span>
|
||||
<button type="button" class="btn" id="btn-cats" title="Atribuir categorias">🗀 Sem categoria</button>
|
||||
<div id="cat-picker" class="cat-picker hidden"></div>
|
||||
</div>
|
||||
|
||||
+154
-13
@@ -17,6 +17,7 @@
|
||||
notesList: $('#notes-list'), notesCount: $('#notes-count'),
|
||||
tabs: $('#tabs'), btnTabNew: $('#btn-tab-new'),
|
||||
noteTitle: $('#note-title'), btnCats: $('#btn-cats'), catPicker: $('#cat-picker'),
|
||||
presenceBadge: $('#presence-badge'),
|
||||
editor: $('#editor'), editorWrap: $('#editor-wrap'), gutter: $('#gutter'),
|
||||
gutterInner: $('#gutter-inner'), hl: $('#hl'), colMarker: $('#col-marker'),
|
||||
preview: $('#preview'), previewFrame: $('#preview-frame'), wysiwyg: $('#wysiwyg'),
|
||||
@@ -33,6 +34,16 @@
|
||||
const EXPAND_KEY = 'notas_expanded';
|
||||
const DRAFT_MAX = 200 * 1024;
|
||||
|
||||
// id de sessão por aba do navegador (sessionStorage): presença + eventos
|
||||
const SID = (() => {
|
||||
let v = sessionStorage.getItem('notas_sid');
|
||||
if (!v) {
|
||||
v = (crypto.randomUUID ? crypto.randomUUID() : 's' + Date.now() + '-' + Math.random().toString(36).slice(2));
|
||||
sessionStorage.setItem('notas_sid', v);
|
||||
}
|
||||
return v;
|
||||
})();
|
||||
|
||||
const state = {
|
||||
tabs: [], // abas abertas (nota ou rascunho)
|
||||
activeKey: null,
|
||||
@@ -42,6 +53,8 @@
|
||||
expanded: new Set(JSON.parse(localStorage.getItem(EXPAND_KEY) || '[]')),
|
||||
selectedRev: null,
|
||||
ctxCat: null,
|
||||
presence: {}, // noteID → [session,...] (exclui a própria sessão)
|
||||
booted: false,
|
||||
};
|
||||
let tabSeq = 0;
|
||||
|
||||
@@ -183,10 +196,91 @@
|
||||
}
|
||||
});
|
||||
|
||||
/* --------------------------------------------- presença + SSE (multi-sessão) */
|
||||
|
||||
const openNoteIds = () => state.tabs.filter((t) => t.id).map((t) => t.id);
|
||||
|
||||
function sendPresence() {
|
||||
fetch('/api/presence', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session: SID, notes: openNoteIds() }),
|
||||
}).catch(() => { /* offline: heartbeat tenta de novo */ });
|
||||
}
|
||||
|
||||
let es = null;
|
||||
function connectEvents() {
|
||||
if (es) es.close();
|
||||
es = new EventSource('/api/events?session=' + encodeURIComponent(SID));
|
||||
es.onmessage = (e) => {
|
||||
let ev;
|
||||
try { ev = JSON.parse(e.data); } catch { return; }
|
||||
handleServerEvent(ev);
|
||||
};
|
||||
// onerror: EventSource reconecta sozinho; o checkFreshness no focus é a rede de segurança
|
||||
}
|
||||
|
||||
function handleServerEvent(ev) {
|
||||
if (ev.type === 'hello' || ev.type === 'presence') { applyPresence(ev.sessions || []); return; }
|
||||
if (ev.type !== 'saved') return;
|
||||
const t = state.tabs.find((x) => x.id === ev.note_id);
|
||||
if (!t || ev.session === SID) return; // nota não aberta aqui, ou fui eu que salvei
|
||||
if (ev.deleted) {
|
||||
if (!t.dirty) {
|
||||
closeTab(t.key, true);
|
||||
toast('“' + (ev.title || 'Sem título') + '” foi excluída por outra sessão.');
|
||||
} else {
|
||||
toast('Cuidado: outra sessão excluiu esta nota; o texto local não salvo não poderá ser gravado.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (t.hash === ev.hash) return;
|
||||
t.remoteHash = ev.hash;
|
||||
if (!t.dirty) {
|
||||
// aba limpa: acompanha o servidor em tempo real
|
||||
api('GET', '/api/notes/' + t.id)
|
||||
.then((d) => {
|
||||
applyNoteToTab(t, d);
|
||||
if (t.key === state.activeKey) {
|
||||
toast('“' + (d.title || 'Sem título') + '” atualizada por outra sessão — recarregada');
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
} else if (t.key === state.activeKey) {
|
||||
resolveConflict(t, null);
|
||||
} else {
|
||||
renderTabs();
|
||||
}
|
||||
}
|
||||
|
||||
function applyPresence(sessions) {
|
||||
const map = {};
|
||||
for (const s of sessions) {
|
||||
if (s.session === SID) continue;
|
||||
for (const n of s.notes || []) (map[n] = map[n] || []).push(s.session);
|
||||
}
|
||||
state.presence = map;
|
||||
renderTabs();
|
||||
renderNotesList();
|
||||
updatePresenceBadge();
|
||||
}
|
||||
|
||||
function updatePresenceBadge() {
|
||||
const t = activeTab();
|
||||
const others = t && t.id ? (state.presence[t.id] || []) : [];
|
||||
E.presenceBadge.classList.toggle('hidden', !others.length);
|
||||
if (others.length) {
|
||||
E.presenceBadge.textContent = others.length === 1
|
||||
? '👥 outra sessão aberta aqui'
|
||||
: `👥 ${others.length} outras sessões abertas aqui`;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- abas */
|
||||
|
||||
function addTab(tab, activate = true) {
|
||||
state.tabs.push(tab);
|
||||
sendPresence();
|
||||
if (activate) activateTab(tab.key);
|
||||
else renderTabs();
|
||||
}
|
||||
@@ -223,11 +317,12 @@
|
||||
closeDrawer();
|
||||
}
|
||||
|
||||
async function closeTab(key) {
|
||||
async function closeTab(key, force) {
|
||||
const idx = state.tabs.findIndex((t) => t.key === key);
|
||||
if (idx < 0) return;
|
||||
if (!(await confirmDiscardTab(state.tabs[idx]))) return;
|
||||
if (!force && !(await confirmDiscardTab(state.tabs[idx]))) return;
|
||||
state.tabs.splice(idx, 1);
|
||||
sendPresence();
|
||||
if (!state.tabs.length) { addTab(newTab()); return; }
|
||||
if (state.activeKey === key) {
|
||||
const next = state.tabs[Math.min(idx, state.tabs.length - 1)];
|
||||
@@ -256,10 +351,17 @@
|
||||
const label = document.createElement('span');
|
||||
label.className = 'tab-label';
|
||||
label.textContent = (t.title || 'Sem título') + EXT[t.format];
|
||||
el.appendChild(label);
|
||||
if (t.id && state.presence[t.id] && state.presence[t.id].length) {
|
||||
const pres = document.createElement('span');
|
||||
pres.className = 'tab-pres';
|
||||
pres.textContent = '👥';
|
||||
pres.title = 'outra(s) sessão(ões) com esta nota aberta';
|
||||
el.appendChild(pres);
|
||||
}
|
||||
const close = document.createElement('span');
|
||||
close.className = 'tab-close';
|
||||
close.textContent = '✕';
|
||||
el.appendChild(label);
|
||||
el.appendChild(close);
|
||||
el.addEventListener('click', (e) => {
|
||||
if (e.target === close) { e.stopPropagation(); closeTab(t.key); }
|
||||
@@ -381,6 +483,7 @@
|
||||
const bits = [relTime(n.updated_at)];
|
||||
if (n.categories && n.categories.length) bits.push(n.categories.map(catName).join(' · '));
|
||||
if (n.tags && n.tags.length) bits.push(n.tags.map((t) => '#' + t).join(' '));
|
||||
if (state.presence[n.id] && state.presence[n.id].length) bits.push('👥 aberta por outra sessão');
|
||||
sub.textContent = bits.join(' · ');
|
||||
li.appendChild(title);
|
||||
li.appendChild(sub);
|
||||
@@ -648,6 +751,7 @@
|
||||
updateCatButton();
|
||||
updateToolbar();
|
||||
updateStatus();
|
||||
updatePresenceBadge();
|
||||
renderEditorVisual();
|
||||
if (modeState.mode === 'wysiwyg') enterWysiwyg();
|
||||
else refreshPreviewIfOpen();
|
||||
@@ -665,6 +769,17 @@
|
||||
if (t) t.dirty = true;
|
||||
updateStatus();
|
||||
persistTabs();
|
||||
scheduleAutosave();
|
||||
}
|
||||
|
||||
let autosaveTimer = 0;
|
||||
function scheduleAutosave() {
|
||||
if (!prefs.autosave) return;
|
||||
clearTimeout(autosaveTimer);
|
||||
autosaveTimer = setTimeout(() => {
|
||||
const t = activeTab();
|
||||
if (t && t.dirty && !t.conflictPending) save(true);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
E.editor.addEventListener('input', () => {
|
||||
@@ -832,7 +947,7 @@
|
||||
/* ------------------------------------------------------- preferências */
|
||||
|
||||
const PREFS_KEY = 'notas_prefs';
|
||||
const PREFS_DEFAULT = { gutter: true, wrap: true, colmark: true, col: 80, hl: true, tabSpaces: true, tabSize: 4, font: 'mono', size: 14, theme: 'claro' };
|
||||
const PREFS_DEFAULT = { gutter: true, wrap: true, colmark: true, col: 80, hl: true, autosave: true, tabSpaces: true, tabSize: 4, font: 'mono', size: 14, theme: 'claro' };
|
||||
const THEMES = ['claro', 'escuro', 'solar-claro', 'solar-escuro'];
|
||||
const FONT_STACKS = {
|
||||
mono: '"DejaVu Sans Mono", "Ubuntu Mono", "Cascadia Code", Consolas, monospace',
|
||||
@@ -1081,6 +1196,7 @@
|
||||
const PF = {
|
||||
overlay: $('#prefs-overlay'), form: $('#prefs-form'), gutter: $('#pf-gutter'),
|
||||
wrap: $('#pf-wrap'), colmark: $('#pf-colmark'), col: $('#pf-col'), hl: $('#pf-hl'),
|
||||
autosave: $('#pf-autosave'),
|
||||
tabmode: $('#pf-tabmode'), tabsize: $('#pf-tabsize'),
|
||||
font: $('#pf-font'), size: $('#pf-size'), theme: $('#pf-theme'),
|
||||
reset: $('#pf-reset'), cancel: $('#pf-cancel'),
|
||||
@@ -1093,6 +1209,7 @@
|
||||
PF.colmark.checked = prefs.colmark;
|
||||
PF.col.value = prefs.col;
|
||||
PF.hl.checked = prefs.hl;
|
||||
PF.autosave.checked = prefs.autosave;
|
||||
PF.tabmode.value = prefs.tabSpaces ? 'spaces' : 'tab';
|
||||
PF.tabsize.value = prefs.tabSize;
|
||||
PF.font.value = prefs.font;
|
||||
@@ -1106,6 +1223,7 @@
|
||||
prefs.colmark = PF.colmark.checked;
|
||||
prefs.col = Math.min(320, Math.max(20, parseInt(PF.col.value, 10) || 80));
|
||||
prefs.hl = PF.hl.checked;
|
||||
prefs.autosave = PF.autosave.checked;
|
||||
prefs.tabSpaces = PF.tabmode.value !== 'tab';
|
||||
prefs.tabSize = Math.min(16, Math.max(1, parseInt(PF.tabsize.value, 10) || 4));
|
||||
prefs.font = FONT_STACKS[PF.font.value] ? PF.font.value : 'mono';
|
||||
@@ -1475,25 +1593,26 @@
|
||||
|
||||
/* ------------------------------------------------------------ salvar */
|
||||
|
||||
async function save() {
|
||||
async function save(auto) {
|
||||
const t = activeTab();
|
||||
if (!t) return;
|
||||
if (!t || t.conflictPending) return;
|
||||
clearTimeout(autosaveTimer);
|
||||
const body = {
|
||||
title: currentTitle(),
|
||||
format: currentFormat(),
|
||||
categories: t.categories,
|
||||
content: t.content,
|
||||
author: E.authorInput.value.trim(),
|
||||
message: E.revMessage.value.trim(),
|
||||
message: E.revMessage.value.trim() || (auto ? 'autosave' : ''),
|
||||
base_hash: t.hash || '',
|
||||
session: SID,
|
||||
};
|
||||
E.stSaved.textContent = 'salvando…';
|
||||
E.stSaved.className = 'st-item pending';
|
||||
let data;
|
||||
try {
|
||||
if (t.id) {
|
||||
body.base_hash = t.hash;
|
||||
data = await api('PUT', '/api/notes/' + t.id, body);
|
||||
} else {
|
||||
data = await api('POST', '/api/notes', body);
|
||||
}
|
||||
if (t.id) data = await api('PUT', '/api/notes/' + t.id, body);
|
||||
else data = await api('POST', '/api/notes', body);
|
||||
} catch (err) {
|
||||
if (err.status === 409 && err.data && err.data.server_hash) {
|
||||
t.remoteHash = err.data.server_hash;
|
||||
@@ -1501,10 +1620,13 @@
|
||||
return;
|
||||
}
|
||||
toast('Falha ao salvar: ' + err.message);
|
||||
updateStatus();
|
||||
return;
|
||||
}
|
||||
const wasDraft = !t.id;
|
||||
applyNoteToTab(t, data);
|
||||
E.revMessage.value = '';
|
||||
if (wasDraft) sendPresence(); // rascunho virou nota: presença passa a reportá-la
|
||||
loadNotes().catch(() => {});
|
||||
loadAggregates().catch(() => {});
|
||||
if (!E.versionsPanel.classList.contains('hidden')) loadRevisions().catch(() => {});
|
||||
@@ -1513,6 +1635,8 @@
|
||||
|
||||
// Conflito: o servidor mudou desde a base da aba. Recarregar ou sobrescrever.
|
||||
async function resolveConflict(t, body) {
|
||||
if (t.conflictPending) return;
|
||||
t.conflictPending = true;
|
||||
if (!body) {
|
||||
body = {
|
||||
title: t.title || currentTitle(),
|
||||
@@ -1521,6 +1645,7 @@
|
||||
content: t.content,
|
||||
author: E.authorInput.value.trim(),
|
||||
message: E.revMessage.value.trim(),
|
||||
session: SID,
|
||||
};
|
||||
}
|
||||
const reload = await modal({
|
||||
@@ -1546,6 +1671,8 @@
|
||||
}
|
||||
} catch (err) {
|
||||
toast('Conflito: ' + err.message);
|
||||
} finally {
|
||||
t.conflictPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1560,6 +1687,7 @@
|
||||
toast('Nota excluída.');
|
||||
const idx = state.tabs.indexOf(t);
|
||||
state.tabs.splice(idx, 1);
|
||||
sendPresence();
|
||||
if (!state.tabs.length) addTab(newTab());
|
||||
else activateTab(state.tabs[Math.min(idx, state.tabs.length - 1)].key);
|
||||
loadNotes().catch(() => {});
|
||||
@@ -1798,8 +1926,21 @@
|
||||
await restoreTabs();
|
||||
updateStatus();
|
||||
state.booted = true;
|
||||
connectEvents();
|
||||
sendPresence();
|
||||
setInterval(sendPresence, 10000); // heartbeat de presença
|
||||
}
|
||||
|
||||
window.addEventListener('pagehide', () => {
|
||||
// remove a sessão do registro assim que a aba fecha (keepalive sobrevive ao unload)
|
||||
fetch('/api/presence', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session: SID, notes: [] }),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
if (e.reason instanceof ApiError && e.reason.status === 401) return; // login já exibido
|
||||
console.error(e.reason);
|
||||
|
||||
Reference in new issue
Block a user