+
Saved
Canvas
{{ $selectedType?->name ?? 'Content document' }} · live structured preview
@@ -68,6 +69,8 @@
+
+
Saasland theme rendererReady
@@ -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 = `
${result.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');
})();
@endpush
diff --git a/Modules/Content/Resources/views/admin/pages/edit.blade.php b/Modules/Content/Resources/views/admin/pages/edit.blade.php
deleted file mode 100644
index ec7003e..0000000
--- a/Modules/Content/Resources/views/admin/pages/edit.blade.php
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-@if(session('status'))
{{ session('status') }}
@endif
-
-@foreach(['header','body','footer'] as $region)
{{ ucfirst($region) }}
@forelse($placements->get($region,collect()) as $placement)
@empty
No placements.
@endforelse
@endforeach
-
diff --git a/Modules/Content/Routes/web.php b/Modules/Content/Routes/web.php
index de8c8c4..021afb5 100644
--- a/Modules/Content/Routes/web.php
+++ b/Modules/Content/Routes/web.php
@@ -10,6 +10,8 @@ Route::middleware(['web', 'auth'])->prefix('admin/content')->name('content.')->g
Route::post('/documents', [ContentDocumentController::class, 'store'])->name('documents.store');
Route::get('/documents/{document}/edit', [ContentDocumentController::class, 'edit'])->name('documents.edit');
Route::put('/documents/{document}', [ContentDocumentController::class, 'update'])->name('documents.update');
+ Route::put('/documents/{document}/autosave', [ContentDocumentController::class, 'autosave'])->name('documents.autosave');
+ Route::post('/documents/preview', [ContentDocumentController::class, 'preview'])->name('documents.preview');
Route::post('/documents/{document}/duplicate', [ContentDocumentController::class, 'duplicate'])->name('documents.duplicate');
Route::delete('/documents/{document}', [ContentDocumentController::class, 'destroy'])->name('documents.destroy');