Raiju/js/revision-client.js
2026-10-25 06:05:42 +03:00

66 lines
4.2 KiB
JavaScript

/** Shared optimistic concurrency for dashboard and inline content forms. */
(() => {
'use strict';
if (window.RaijuRevisions) return;
const revisions = new Map();
const originalFetch = window.fetch.bind(window);
const tr = key => (window.RJ_LANG || {})['conflict.' + key] || key;
let dialog;
async function showConflict(url, action, page, pending) {
if (dialog?.open) return;
const latestUrl = new URL(url);
latestUrl.search = new URLSearchParams({ action: action === 'save' ? 'load' : 'load-settings', ...(page ? { page } : {}) });
let latest;
try { latest = (await (await originalFetch(latestUrl)).json()).data; }
catch (_) { latest = tr('unavailable'); }
const previousFocus = document.activeElement;
dialog = document.createElement('dialog');
dialog.className = 'raiju-conflict';
const heading = document.createElement('h2');
heading.id = 'raijuConflictTitle'; heading.textContent = tr('changed');
dialog.setAttribute('aria-labelledby', heading.id);
const hint = document.createElement('p'); hint.textContent = tr('hint');
const columns = document.createElement('div'); columns.className = 'raiju-conflict__columns';
for (const [label, value] of [[tr('yours'), pending], [tr('current'), latest]]) {
const wrapper = document.createElement('label'); wrapper.textContent = label;
const content = document.createElement('textarea'); content.readOnly = true;
content.value = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
wrapper.append(content); columns.append(wrapper);
}
const actions = document.createElement('div'); actions.className = 'raiju-conflict__actions';
function button(label, callback) { const btn = document.createElement('button'); btn.type = 'button'; btn.textContent = label; btn.onclick = callback; actions.append(btn); }
button(tr('keep'), () => dialog.close());
button(tr('download'), () => {
const link = document.createElement('a');
link.href = URL.createObjectURL(new Blob([typeof pending === 'string' ? pending : JSON.stringify(pending, null, 2)], { type: 'application/json' }));
link.download = (page || 'settings') + '-unsaved.json'; link.click(); URL.revokeObjectURL(link.href);
});
button(tr('reload'), () => window.location.reload());
dialog.append(heading, hint, columns, actions); document.body.append(dialog);
dialog.addEventListener('close', () => { dialog.remove(); previousFocus?.focus(); });
dialog.showModal();
}
window.fetch = async function(input, init) {
const url = new URL(input instanceof Request ? input.url : input, location.href);
const apiUrl = new URL(window.RJ_ADMIN_API_URL || 'api.php', location.href);
if (url.origin !== apiUrl.origin || url.pathname !== apiUrl.pathname) return originalFetch(input, init);
const body = init?.body;
const fields = body instanceof FormData || body instanceof URLSearchParams ? body : url.searchParams;
const action = fields.get('action') || url.searchParams.get('action');
const page = fields.get('page') || url.searchParams.get('page') || '';
const key = ['load-settings', 'save-settings'].includes(action) ? 'settings' : 'page:' + page;
const write = ['save', 'save-settings'].includes(action);
if (write && fields !== url.searchParams && !fields.has('revision') && revisions.has(key)) fields.set('revision', revisions.get(key));
const response = await originalFetch(input, init);
if (['load', 'load-settings', 'save', 'save-settings'].includes(action)) {
try {
const payload = await response.clone().json();
if (payload.success && payload.revision) revisions.set(key, payload.revision);
if ([409, 428].includes(response.status)) void showConflict(url, action, page, fields.get(action === 'save' ? 'content' : 'settings'));
} catch (_) { /* The caller owns transport/error handling. */ }
}
return response;
};
window.RaijuRevisions = { get: key => revisions.get(key) };
})();