Files
ABSDEV/Modules/Content/Resources/views/admin/form.blade.php
T
Mark Gregory a211b10e29 Three-panel visual editor:
* field library
    * responsive canvas
    * properties inspector
* Desktop, tablet and mobile canvas widths
* Live editing of text, textarea, URL, number and toggle fields
* Basic repeater item add/remove support
* Advanced JSON editor retained as a fallback
* Existing ContentDocument.data storage format preserved
* Existing frontend renderers remain compatible
* Saasland content types now include visual field schemas
* No migration required
2026-07-27 09:11:03 +01:00

172 lines
17 KiB
PHP

<x-layouts.app :title="$document->exists ? $document->title : __('Create content document')">
@php
$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="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">
<a href="{{ route('content.index') }}" class="hover:text-zinc-900 dark:hover:text-white">Content documents</a>
<span>/</span>
<span>{{ $document->exists ? 'Visual editor' : 'Create' }}</span>
</div>
<h1 class="mt-1 text-2xl font-semibold">{{ $document->exists ? $document->title : 'Create content document' }}</h1>
<p class="mt-1 text-sm text-zinc-500">Build and edit structured content visually. The stored document format remains fully compatible with the existing renderer.</p>
</div>
@if($document->exists)
<div class="flex flex-wrap gap-2">
<form method="POST" action="{{ route('content.documents.duplicate', $document) }}">@csrf<flux:button type="submit" size="sm">Create independent copy</flux:button></form>
<form method="POST" action="{{ route('content.documents.destroy', $document) }}" onsubmit="return confirm('Delete this document? This is only allowed when it is not used by any page.')">@csrf @method('DELETE')<flux:button type="submit" size="sm" variant="danger">Delete</flux:button></form>
</div>
@endif
</div>
@if(session('status'))<div class="rounded-xl border border-green-300 bg-green-50 p-4 text-sm text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-200">{{ session('status') }}</div>@endif
@if($errors->any())
<div class="rounded-xl border border-red-300 bg-red-50 p-4 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-200"><strong>The document could not be saved.</strong><ul class="mt-2 list-disc pl-5">@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach</ul></div>
@endif
@if($document->exists && $document->placements->count() > 1)
<div class="rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100"><strong>Shared document.</strong> This document is used in {{ $document->placements->count() }} page placements. Changes appear everywhere it is published.</div>
@endif
<form id="content-document-form" method="POST" action="{{ $document->exists ? route('content.documents.update', $document) : route('content.documents.store') }}" class="space-y-6">
@csrf
@if($document->exists) @method('PUT') @endif
<input type="hidden" name="data_json" id="data-json" value="{{ $builderData }}">
<div class="grid gap-4 rounded-xl border border-zinc-200 bg-white p-4 md:grid-cols-4 dark:border-zinc-700 dark:bg-zinc-900">
<flux:input name="title" label="Title" value="{{ old('title', $document->title) }}" required />
<flux:input name="slug" label="Slug" value="{{ old('slug', $document->slug) }}" description="Generated from title when empty." />
<flux:select id="content-type-select" name="content_type_id" label="Content type" required>
@foreach($types as $type)<option value="{{ $type->id }}" @selected((int) old('content_type_id', $selectedType?->id) === $type->id)>{{ $type->name }}</option>@endforeach
</flux:select>
<flux:select name="status" label="Status"><option value="draft" @selected(old('status', $document->status ?: 'draft') === 'draft')>Draft</option><option value="published" @selected(old('status', $document->status) === 'published')>Published</option></flux:select>
</div>
<div class="grid min-h-[42rem] overflow-hidden rounded-2xl border border-zinc-200 bg-white xl:grid-cols-[16rem_minmax(0,1fr)_20rem] dark:border-zinc-700 dark:bg-zinc-900">
<aside class="border-b border-zinc-200 p-4 xl:border-b-0 xl:border-r dark:border-zinc-700">
<div class="mb-4"><h2 class="font-semibold">Fields</h2><p class="text-xs text-zinc-500">Select a field to edit its properties.</p></div>
<div id="builder-library" class="space-y-2"></div>
<div class="mt-6 border-t border-zinc-200 pt-4 dark:border-zinc-700">
<button type="button" id="toggle-json" class="w-full rounded-lg border border-zinc-300 px-3 py-2 text-left text-sm hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800">Advanced JSON</button>
</div>
</aside>
<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><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 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 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>
<div id="json-panel" class="mt-4 hidden"><div class="mb-2 flex items-center justify-between"><label for="json-editor" class="text-sm font-medium">Document JSON</label><button type="button" id="apply-json" class="rounded-lg border border-zinc-300 px-3 py-1.5 text-sm dark:border-zinc-700">Apply JSON</button></div><textarea id="json-editor" class="min-h-72 w-full rounded-xl border border-zinc-300 bg-zinc-950 p-4 font-mono text-sm text-zinc-100" spellcheck="false"></textarea><p id="json-message" class="mt-2 text-sm"></p></div>
</main>
<aside class="border-t border-zinc-200 p-4 xl:border-l xl:border-t-0 dark:border-zinc-700">
<div class="mb-4"><h2 class="font-semibold">Inspector</h2><p id="inspector-help" class="text-xs text-zinc-500">Select a field on the canvas.</p></div>
<div id="builder-inspector"></div>
<div class="mt-6 space-y-3 border-t border-zinc-200 pt-4 dark:border-zinc-700">
<flux:input type="datetime-local" name="published_at" label="Publish at" value="{{ old('published_at', $document->published_at?->format('Y-m-d\TH:i')) }}" />
<flux:input type="datetime-local" name="expires_at" label="Expire at" value="{{ old('expires_at', $document->expires_at?->format('Y-m-d\TH:i')) }}" />
<flux:button type="submit" variant="primary" class="w-full">{{ $document->exists ? 'Save document' : 'Create document' }}</flux:button>
</div>
@if($document->exists)
<div class="mt-6 border-t border-zinc-200 pt-4 dark:border-zinc-700"><h3 class="text-sm font-semibold">Usage</h3><p class="mt-1 text-xs text-zinc-500">{{ $document->placements->count() }} placement{{ $document->placements->count() === 1 ? '' : 's' }}</p>@foreach($document->placements as $placement)<a class="mt-2 block rounded-lg border border-zinc-200 p-2 text-xs hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800" href="{{ route('content.pages.edit', $placement->page_key) }}"><strong>{{ ucfirst($placement->page_key ?: 'Page') }}</strong><span class="block uppercase text-zinc-500">{{ $placement->region }}</span></a>@endforeach</div>
@endif
</aside>
</div>
</form>
</div>
@push('scripts')
<script>
(() => {
const schema = @json($builderSchema);
let data;
try { data = JSON.parse(document.getElementById('data-json').value || '{}'); } catch (_) { data = {}; }
let selectedKey = Object.keys(schema)[0] || null;
const library = document.getElementById('builder-library');
const canvas = document.getElementById('builder-canvas');
const inspector = document.getElementById('builder-inspector');
const hidden = document.getElementById('data-json');
const jsonEditor = document.getElementById('json-editor');
const panel = document.getElementById('json-panel');
const titleFor = (key, definition) => definition.label || key.replaceAll('_', ' ').replace(/\b\w/g, char => char.toUpperCase());
const controlFor = definition => definition.control || (definition.type === 'boolean' ? 'toggle' : definition.type === 'integer' ? 'number' : 'text');
const escapeHtml = value => String(value ?? '').replace(/[&<>'"]/g, char => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#039;','"':'&quot;'}[char]));
const sync = () => { hidden.value = JSON.stringify(data); jsonEditor.value = JSON.stringify(data, null, 2); };
function renderLibrary() {
const entries = Object.entries(schema);
library.innerHTML = entries.length ? entries.map(([key, definition]) => `<button type="button" data-select="${escapeHtml(key)}" class="w-full rounded-lg border px-3 py-2 text-left text-sm ${selectedKey === key ? 'border-zinc-900 bg-zinc-900 text-white dark:border-white dark:bg-white dark:text-zinc-900' : 'border-zinc-200 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800'}"><span class="block font-medium">${escapeHtml(titleFor(key, definition))}</span><span class="block text-[11px] opacity-70">${escapeHtml(controlFor(definition))}</span></button>`).join('') : '<p class="rounded-lg border border-dashed border-zinc-300 p-3 text-xs text-zinc-500 dark:border-zinc-700">This content type has no schema yet. Use Advanced JSON to edit it.</p>';
library.querySelectorAll('[data-select]').forEach(button => button.addEventListener('click', () => select(button.dataset.select)));
}
function renderCanvas() {
const entries = Object.entries(schema);
if (!entries.length) { canvas.innerHTML = '<div class="flex min-h-[30rem] items-center justify-center text-center text-sm text-zinc-500"><div><strong class="block text-zinc-700 dark:text-zinc-200">No visual fields available</strong><span>Open Advanced JSON to edit this document.</span></div></div>'; return; }
canvas.innerHTML = entries.map(([key, definition]) => {
const value = data[key];
const selected = selectedKey === key;
const control = controlFor(definition);
let preview = '';
if (control === 'toggle') preview = `<span class="inline-flex rounded-full px-2 py-1 text-xs ${value ? 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-200' : 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300'}">${value ? 'Enabled' : 'Disabled'}</span>`;
else if (control === 'repeater') preview = `<span class="text-sm text-zinc-500">${Array.isArray(value) ? value.length : 0} item${Array.isArray(value) && value.length === 1 ? '' : 's'}</span>`;
else preview = `<p class="whitespace-pre-wrap text-sm text-zinc-700 dark:text-zinc-200">${escapeHtml(value || 'Click to add content')}</p>`;
return `<button type="button" data-canvas-key="${escapeHtml(key)}" class="block w-full rounded-xl border p-4 text-left transition ${selected ? 'border-blue-500 ring-2 ring-blue-500/20' : 'border-zinc-200 hover:border-zinc-400 dark:border-zinc-700'}"><span class="mb-2 block text-xs font-semibold uppercase tracking-wide text-zinc-500">${escapeHtml(titleFor(key, definition))}</span>${preview}</button>`;
}).join('');
canvas.querySelectorAll('[data-canvas-key]').forEach(button => button.addEventListener('click', () => select(button.dataset.canvasKey)));
}
function renderInspector() {
if (!selectedKey || !schema[selectedKey]) { inspector.innerHTML = '<p class="text-sm text-zinc-500">Nothing selected.</p>'; return; }
const definition = schema[selectedKey];
const control = controlFor(definition);
const label = titleFor(selectedKey, definition);
document.getElementById('inspector-help').textContent = label;
if (control === 'toggle') {
inspector.innerHTML = `<label class="flex items-center justify-between rounded-xl border border-zinc-200 p-3 text-sm dark:border-zinc-700"><span>${escapeHtml(label)}</span><input id="inspector-control" type="checkbox" class="h-5 w-5" ${data[selectedKey] ? 'checked' : ''}></label>`;
} else if (control === 'textarea') {
inspector.innerHTML = `<label class="block text-sm font-medium">${escapeHtml(label)}<textarea id="inspector-control" class="mt-2 min-h-40 w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm dark:border-zinc-700 dark:bg-zinc-950">${escapeHtml(data[selectedKey] || '')}</textarea></label>`;
} else if (control === 'repeater') {
const items = Array.isArray(data[selectedKey]) ? data[selectedKey] : [];
inspector.innerHTML = `<div class="space-y-3"><div class="flex items-center justify-between"><span class="text-sm font-medium">${escapeHtml(label)}</span><button type="button" id="add-repeat-item" class="rounded-lg border border-zinc-300 px-2 py-1 text-xs dark:border-zinc-700">Add item</button></div><div id="repeat-items" class="space-y-2">${items.map((item, index) => `<button type="button" data-remove-item="${index}" class="group block w-full rounded-lg border border-zinc-200 p-3 text-left text-xs dark:border-zinc-700"><strong class="block">${escapeHtml(item.title || `Item ${index + 1}`)}</strong><span class="text-zinc-500">Click to remove in this checkpoint</span></button>`).join('') || '<p class="text-xs text-zinc-500">No items.</p>'}</div></div>`;
document.getElementById('add-repeat-item')?.addEventListener('click', () => { if (!Array.isArray(data[selectedKey])) data[selectedKey] = []; const itemSchema = definition.item || {}; const item = {}; Object.keys(itemSchema).forEach(key => item[key] = ''); data[selectedKey].push(item); update(); });
inspector.querySelectorAll('[data-remove-item]').forEach(button => button.addEventListener('click', () => { data[selectedKey].splice(Number(button.dataset.removeItem), 1); update(); }));
return;
} else {
const type = control === 'number' ? 'number' : control === 'url' ? 'url' : 'text';
inspector.innerHTML = `<label class="block text-sm font-medium">${escapeHtml(label)}<input id="inspector-control" type="${type}" ${definition.min !== undefined ? `min="${definition.min}"` : ''} ${definition.max !== undefined ? `max="${definition.max}"` : ''} class="mt-2 w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm dark:border-zinc-700 dark:bg-zinc-950" value="${escapeHtml(data[selectedKey] ?? '')}"></label>`;
}
const controlElement = document.getElementById('inspector-control');
controlElement?.addEventListener(control === 'toggle' ? 'change' : 'input', event => {
data[selectedKey] = control === 'toggle' ? event.target.checked : control === 'number' ? Number(event.target.value) : event.target.value;
sync(); renderCanvas(); renderLibrary();
});
}
function select(key) { selectedKey = key; renderLibrary(); renderCanvas(); renderInspector(); }
function update() { sync(); renderLibrary(); renderCanvas(); renderInspector(); }
document.getElementById('toggle-json').addEventListener('click', () => { panel.classList.toggle('hidden'); jsonEditor.value = JSON.stringify(data, null, 2); });
document.getElementById('apply-json').addEventListener('click', () => {
const message = document.getElementById('json-message');
try { data = JSON.parse(jsonEditor.value); message.textContent = 'JSON applied to the visual editor.'; message.className = 'mt-2 text-sm text-green-700 dark:text-green-300'; update(); }
catch (error) { message.textContent = error.message; message.className = 'mt-2 text-sm text-red-700 dark:text-red-300'; }
});
document.querySelectorAll('.builder-width').forEach(button => button.addEventListener('click', () => { document.getElementById('builder-viewport').style.maxWidth = button.dataset.width; document.querySelectorAll('.builder-width').forEach(item => item.classList.remove('font-medium','bg-zinc-100','dark:bg-zinc-800')); button.classList.add('font-medium','bg-zinc-100','dark:bg-zinc-800'); }));
document.getElementById('content-document-form').addEventListener('submit', sync);
document.getElementById('content-type-select')?.addEventListener('change', event => {
if (!@json($document->exists)) window.location.href = `{{ route('content.documents.create') }}?type=${event.target.value}`;
});
update();
})();
</script>
@endpush
</x-layouts.app>