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
@@ -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']);