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:
@@ -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' => '<div style="padding:2rem;font-family:system-ui;color:#71717a">No Saasland renderer exists for this content type yet.</div>',
|
||||
'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']);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<x-layouts.app :title="'Compose '.ucfirst($pageKey)">
|
||||
<div class="space-y-6"><div><a class="text-sm text-zinc-500 hover:underline" href="{{ route('content.index') }}">Content documents</a><h1 class="text-2xl font-semibold">{{ ucfirst($pageKey) }} composition</h1></div>
|
||||
@if(session('status'))<div class="rounded-xl border border-green-300 bg-green-50 p-4 text-sm text-green-800">{{ session('status') }}</div>@endif
|
||||
<form method="POST" action="{{ route('content.pages.placements.store',$pageKey) }}" class="grid gap-3 rounded-xl border border-zinc-200 p-4 md:grid-cols-3">@csrf<flux:select name="content_document_id" label="Document">@foreach($documents as $document)<option value="{{ $document->id }}">{{ $document->title }} ({{ $document->type?->name }})</option>@endforeach</flux:select><flux:select name="region" label="Region">@foreach(['header','body','footer'] as $region)<option value="{{ $region }}">{{ ucfirst($region) }}</option>@endforeach</flux:select><div class="self-end"><flux:button type="submit" variant="primary">Add placement</flux:button></div></form>
|
||||
@foreach(['header','body','footer'] as $region)<section class="rounded-xl border border-zinc-200 p-4 dark:border-zinc-700"><h2 class="mb-3 text-lg font-semibold">{{ ucfirst($region) }}</h2><div class="space-y-2">@forelse($placements->get($region,collect()) as $placement)<div class="flex items-center justify-between rounded-lg border border-zinc-200 p-3 dark:border-zinc-700"><div><a class="font-medium hover:underline" href="{{ route('content.documents.edit',$placement->document) }}">{{ $placement->document->title }}</a><div class="text-xs text-zinc-500">{{ $placement->is_enabled ? 'Enabled' : 'Disabled' }}</div></div><form method="POST" action="{{ route('content.pages.placements.destroy',[$pageKey,$placement]) }}">@csrf @method('DELETE')<flux:button type="submit" size="sm" variant="danger">Remove</flux:button></form></div>@empty<p class="text-sm text-zinc-500">No placements.</p>@endforelse</div></section>@endforeach</div>
|
||||
</x-layouts.app>
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user