From 4267f07626cd2eb16a3ac00212ed9f154c87aac8 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 27 Jul 2026 09:58:35 +0100 Subject: [PATCH] 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 --- .../Controllers/ContentDocumentController.php | 61 +++++++++++++ .../Resources/views/admin/form.blade.php | 91 ++++++++++++++++++- .../views/admin/pages/edit.blade.php | 6 -- Modules/Content/Routes/web.php | 2 + 4 files changed, 151 insertions(+), 9 deletions(-) delete mode 100644 Modules/Content/Resources/views/admin/pages/edit.blade.php diff --git a/Modules/Content/Http/Controllers/ContentDocumentController.php b/Modules/Content/Http/Controllers/ContentDocumentController.php index 2e2e7ef..936456b 100644 --- a/Modules/Content/Http/Controllers/ContentDocumentController.php +++ b/Modules/Content/Http/Controllers/ContentDocumentController.php @@ -4,9 +4,11 @@ declare(strict_types=1); namespace Modules\Content\Http\Controllers; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; +use Illuminate\Support\Facades\View as ViewFacade; use Illuminate\View\View; use Modules\Content\Http\Requests\SaveContentDocumentRequest; use Modules\Content\Models\ContentDocument; @@ -49,6 +51,65 @@ final class ContentDocumentController extends Controller return back()->with('status','Content document saved.'); } + public function autosave(Request $request, ContentDocument $document): JsonResponse + { + $validated = $request->validate([ + 'content_type_id' => ['required', 'integer', 'exists:content_types,id'], + 'title' => ['required', 'string', 'max:255'], + 'slug' => ['nullable', 'string', 'max:255'], + 'status' => ['required', 'in:draft,published,archived'], + 'data_json' => ['required', 'json'], + 'published_at' => ['nullable', 'date'], + 'expires_at' => ['nullable', 'date', 'after:published_at'], + ]); + + $document->update([ + 'content_type_id' => $validated['content_type_id'], + 'title' => $validated['title'], + 'slug' => filled($validated['slug'] ?? null) ? $validated['slug'] : str($validated['title'])->slug(), + 'status' => $validated['status'], + 'data' => json_decode($validated['data_json'], true, flags: JSON_THROW_ON_ERROR), + 'published_at' => $validated['published_at'] ?? null, + 'expires_at' => $validated['expires_at'] ?? null, + ]); + + return response()->json([ + 'saved' => true, + 'saved_at' => now()->toIso8601String(), + 'updated_at' => $document->fresh()->updated_at?->toIso8601String(), + ]); + } + + public function preview(Request $request): JsonResponse + { + $validated = $request->validate([ + 'content_type_id' => ['required', 'integer', 'exists:content_types,id'], + 'data_json' => ['required', 'json'], + ]); + + $type = ContentType::query()->findOrFail($validated['content_type_id']); + $view = 'stylist-theme-saasland::documents.'.$type->key; + + if (! ViewFacade::exists($view)) { + return response()->json([ + 'html' => '
No Saasland renderer exists for this content type yet.
', + 'view' => $view, + ]); + } + + $data = json_decode($validated['data_json'], true, flags: JSON_THROW_ON_ERROR); + + return response()->json([ + 'html' => ViewFacade::make($view, array_merge($data, [ + 'data' => $data, + 'document' => new ContentDocument(['data' => $data]), + 'placement' => null, + 'preview' => true, + ]))->render(), + 'view' => $view, + ]); + } + public function duplicate(ContentDocument $document): RedirectResponse { $copy = $document->replicate(['slug']); diff --git a/Modules/Content/Resources/views/admin/form.blade.php b/Modules/Content/Resources/views/admin/form.blade.php index 72621bd..80c8e0a 100644 --- a/Modules/Content/Resources/views/admin/form.blade.php +++ b/Modules/Content/Resources/views/admin/form.blade.php @@ -3,7 +3,7 @@ $builderSchema = $selectedType?->schema ?: []; $builderData = old('data_json', json_encode($document->data ?: [], JSON_UNESCAPED_SLASHES)); @endphp -
+
@@ -57,6 +57,7 @@
+
Saved

Canvas

{{ $selectedType?->name ?? 'Content document' }} · live structured preview

@@ -68,6 +69,8 @@
+
+
@@ -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 @@ - -
Content documents

{{ ucfirst($pageKey) }} composition

-@if(session('status'))
{{ session('status') }}
@endif -
@csrf@foreach($documents as $document)@endforeach@foreach(['header','body','footer'] as $region)@endforeach
Add placement
-@foreach(['header','body','footer'] as $region)

{{ ucfirst($region) }}

@forelse($placements->get($region,collect()) as $placement)
{{ $placement->document->title }}
{{ $placement->is_enabled ? 'Enabled' : 'Disabled' }}
@csrf @method('DELETE')Remove
@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');