Authenticated autosave endpoint

* Autosave after 1.2 seconds of inactivity
* Saved, saving, unsaved and error indicators
* Last-saved time
* Dirty-state tracking
* Warning before leaving with unsaved changes
* Server-rendered Saasland theme preview
* Preview refresh while editing
* Active renderer view name
* Graceful message when a content type has no theme renderer
* Existing manual save remains available
* Existing undo, redo, responsive controls and inline editing retained
This commit is contained in:
Mark Gregory
2026-07-27 09:58:35 +01:00
parent dad92ad799
commit 4267f07626
4 changed files with 151 additions and 9 deletions
@@ -3,7 +3,7 @@
$builderSchema = $selectedType?->schema ?: [];
$builderData = old('data_json', json_encode($document->data ?: [], JSON_UNESCAPED_SLASHES));
@endphp
<div class="space-y-6" data-content-builder>
<div class="space-y-6" data-content-builder data-autosave-url="{{ $document->exists ? route('content.documents.autosave', $document) : '' }}" data-preview-url="{{ route('content.documents.preview') }}">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div class="flex items-center gap-2 text-sm text-zinc-500">
@@ -57,6 +57,7 @@
<main class="min-w-0 bg-zinc-50 p-4 dark:bg-zinc-950/60 sm:p-6">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2 text-xs"><span id="save-state" class="rounded-full bg-zinc-200 px-2.5 py-1 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">Saved</span><span id="save-time" class="text-zinc-400"></span></div>
<div><h2 class="font-semibold">Canvas</h2><p class="text-xs text-zinc-500">{{ $selectedType?->name ?? 'Content document' }} · live structured preview</p></div>
<div class="flex flex-wrap items-center gap-2">
<div class="flex rounded-lg border border-zinc-300 bg-white p-1 text-xs dark:border-zinc-700 dark:bg-zinc-900">
@@ -68,6 +69,8 @@
<div class="flex rounded-lg border border-zinc-300 bg-white p-1 text-xs dark:border-zinc-700 dark:bg-zinc-900"><button type="button" class="builder-width rounded px-3 py-1.5 font-medium" data-width="100%">Desktop</button><button type="button" class="builder-width rounded px-3 py-1.5" data-width="768px">Tablet</button><button type="button" class="builder-width rounded px-3 py-1.5" data-width="390px">Mobile</button></div>
</div>
</div>
<div class="mb-4 flex justify-end"><button type="button" id="toggle-theme-preview" class="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-900">Show theme preview</button></div>
<div id="theme-preview-panel" class="mb-4 hidden overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-700"><div class="flex items-center justify-between border-b border-zinc-200 px-3 py-2 text-xs dark:border-zinc-700"><span id="preview-view-name" class="text-zinc-500">Saasland theme renderer</span><span id="preview-state" class="text-zinc-400">Ready</span></div><iframe id="theme-preview-frame" title="Theme preview" class="h-[34rem] w-full bg-white"></iframe></div>
<div id="builder-viewport" class="mx-auto w-full transition-all duration-200">
<div id="builder-canvas" class="min-h-[34rem] space-y-3 rounded-xl border border-dashed border-zinc-300 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"></div>
</div>
@@ -200,11 +203,82 @@
const redoButton = document.getElementById('builder-redo');
const copyButton = document.getElementById('builder-copy');
const pasteButton = document.getElementById('builder-paste');
const builderRoot = document.querySelector('[data-content-builder]');
const form = document.getElementById('content-document-form');
const saveState = document.getElementById('save-state');
const saveTime = document.getElementById('save-time');
const previewPanel = document.getElementById('theme-preview-panel');
const previewFrame = document.getElementById('theme-preview-frame');
const previewState = document.getElementById('preview-state');
const previewViewName = document.getElementById('preview-view-name');
let dirty = false;
let autosaveTimer = null;
let previewTimer = null;
let saveRequest = null;
const sync = () => {
function setSaveState(state, message = '') {
saveState.textContent = state;
saveState.className = 'rounded-full px-2.5 py-1 ' + ({
Saved: 'bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300',
Saving: 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300',
Unsaved: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200',
Error: 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300'
}[state] || 'bg-zinc-200 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300');
if (message) saveTime.textContent = message;
}
function markDirty() {
dirty = true;
setSaveState('Unsaved', 'Changes pending');
window.clearTimeout(autosaveTimer);
window.clearTimeout(previewTimer);
autosaveTimer = window.setTimeout(autosave, 1200);
if (!previewPanel.classList.contains('hidden')) previewTimer = window.setTimeout(refreshThemePreview, 350);
}
function formPayload() {
sync(false);
return new FormData(form);
}
async function autosave() {
const url = builderRoot.dataset.autosaveUrl;
if (!url || !dirty) return;
saveRequest?.abort();
saveRequest = new AbortController();
setSaveState('Saving', 'Saving automatically…');
try {
const response = await fetch(url, { method: 'POST', body: formPayload(), headers: { 'X-HTTP-Method-Override': 'PUT', 'Accept': 'application/json' }, signal: saveRequest.signal });
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).message || 'Autosave failed');
dirty = false;
const result = await response.json();
const at = result.saved_at ? new Date(result.saved_at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'}) : '';
setSaveState('Saved', at ? `Saved at ${at}` : 'Saved');
} catch (error) {
if (error.name !== 'AbortError') setSaveState('Error', error.message);
}
}
async function refreshThemePreview() {
if (previewPanel.classList.contains('hidden')) return;
previewState.textContent = 'Rendering…';
try {
const response = await fetch(builderRoot.dataset.previewUrl, { method: 'POST', body: formPayload(), headers: { 'Accept': 'application/json' } });
const result = await response.json();
if (!response.ok) throw new Error(result.message || 'Preview failed');
previewFrame.srcdoc = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body style="margin:0">${result.html}</body></html>`;
previewViewName.textContent = result.view || 'Saasland theme renderer';
previewState.textContent = 'Current';
} catch (error) {
previewState.textContent = error.message;
}
}
const sync = (trackDirty = true) => {
data._layout = layout;
hidden.value = JSON.stringify(data);
jsonEditor.value = JSON.stringify(data, null, 2);
if (trackDirty) markDirty();
};
@@ -543,12 +617,23 @@
button.classList.add('font-medium','bg-zinc-100','dark:bg-zinc-800');
renderCanvas(); renderInspector();
}));
document.getElementById('content-document-form').addEventListener('submit', sync);
form.addEventListener('submit', () => { dirty = false; sync(false); });
form.querySelectorAll('input, select, textarea').forEach(control => {
if (control.id !== 'data-json' && control.id !== 'json-editor') control.addEventListener('input', markDirty);
});
document.getElementById('toggle-theme-preview')?.addEventListener('click', event => {
previewPanel.classList.toggle('hidden');
event.currentTarget.textContent = previewPanel.classList.contains('hidden') ? 'Show theme preview' : 'Hide theme preview';
if (!previewPanel.classList.contains('hidden')) refreshThemePreview();
});
window.addEventListener('beforeunload', event => { if (dirty) { event.preventDefault(); event.returnValue = ''; } });
document.getElementById('content-type-select')?.addEventListener('change', event => {
if (!@json($document->exists)) window.location.href = `{{ route('content.documents.create') }}?type=${event.target.value}`;
});
update();
dirty = false;
setSaveState('Saved', 'All changes saved');
})();
</script>
@endpush