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
This commit is contained in:
Mark Gregory
2026-07-27 09:11:03 +01:00
parent 0c26d8db8f
commit a211b10e29
2 changed files with 152 additions and 98 deletions
@@ -45,20 +45,23 @@ final class InstallSaaslandContent extends Command
private function definitions(): array
{
$type = fn(string $name,string $renderer,array $regions) => ['name'=>$name,'renderer'=>$renderer,'allowed_regions'=>$regions,'is_system'=>true,'is_active'=>true,'schema'=>[]];
$type = fn(string $name,string $renderer,array $regions,array $schema) => ['name'=>$name,'renderer'=>$renderer,'allowed_regions'=>$regions,'is_system'=>true,'is_active'=>true,'schema'=>$schema];
$text = fn(string $label, string $control = 'text', bool $required = false) => ['type'=>'string','label'=>$label,'control'=>$control,'required'=>$required];
$toggle = fn(string $label) => ['type'=>'boolean','label'=>$label,'control'=>'toggle'];
$number = fn(string $label, int $min = 1, int $max = 100) => ['type'=>'integer','label'=>$label,'control'=>'number','min'=>$min,'max'=>$max];
return [
'site-header'=>['title'=>'Main website header','type'=>$type('Site header','site-header',['header']),'data'=>['show_login'=>true,'show_registration'=>true,'sticky'=>true]],
'hero'=>['title'=>'Homepage hero','type'=>$type('Hero','hero',['body']),'data'=>['eyebrow'=>'A better way to run your business','heading'=>'Transform Your Business With Our SaaS Solution','description'=>'Streamline operations, boost productivity, and improve customer satisfaction with one connected platform built for modern teams.','primary_label'=>'Get started free','primary_url'=>'/register','secondary_label'=>'Book demo','secondary_url'=>'#contact','show_metrics'=>true]],
'feature-grid'=>['title'=>'Core features','type'=>$type('Feature grid','feature-grid',['body']),'data'=>['eyebrow'=>'Powerful features','heading'=>'Everything your team needs to grow','description'=>'Planning, collaboration, reporting, publishing, commerce and customer insight in one extensible platform.','items'=>[['title'=>'Smart automation','description'=>'Remove repetitive work and delays.'],['title'=>'Real-time analytics','description'=>'Turn activity into useful business insight.'],['title'=>'Content operations','description'=>'Create, review and publish confidently.'],['title'=>'Secure by design','description'=>'Protect users and data with dependable controls.'],['title'=>'Flexible integrations','description'=>'Connect the tools your organisation already uses.'],['title'=>'Modular architecture','description'=>'Activate only the capabilities each site needs.']]]],
'product-feed'=>['title'=>'Pricing plans','type'=>$type('Product feed','product-feed',['body']),'data'=>['eyebrow'=>'Simple pricing','heading'=>'Choose a plan that fits','description'=>'Plans are managed from Products & Pricing in the CMS.','limit'=>6]],
'testimonial-feed'=>['title'=>'Customer stories','type'=>$type('Testimonial feed','testimonial-feed',['body']),'data'=>['eyebrow'=>'Customer stories','heading'=>'Trusted by ambitious teams','limit'=>6]],
'team-feed'=>['title'=>'Team','type'=>$type('Team feed','team-feed',['body']),'data'=>['eyebrow'=>'Our team','heading'=>'People behind the platform','limit'=>8]],
'blog-feed'=>['title'=>'Latest blog posts','type'=>$type('Blog feed','blog-feed',['body']),'data'=>['eyebrow'=>'Insights','heading'=>'Latest from the blog','limit'=>3]],
'news-feed'=>['title'=>'Latest news','type'=>$type('News feed','news-feed',['body']),'data'=>['eyebrow'=>'Newsroom','heading'=>'Latest news','limit'=>3]],
'faq-feed'=>['title'=>'Frequently asked questions','type'=>$type('FAQ feed','faq-feed',['body']),'data'=>['eyebrow'=>'FAQ','heading'=>'Answers before you begin','limit'=>12]],
'newsletter-form'=>['title'=>'Newsletter signup','type'=>$type('Newsletter form','newsletter-form',['body']),'data'=>['eyebrow'=>'Newsletter','heading'=>'Useful updates, no noise','description'=>'Receive product news, publishing guidance, and practical platform updates.','button_label'=>'Subscribe']],
'call-to-action'=>['title'=>'Homepage final call to action','type'=>$type('Call to action','call-to-action',['body']),'data'=>['heading'=>'Ready to transform your business?','description'=>'Start building a faster, more connected operation today.','button_label'=>'Get started free','button_url'=>'/register']],
'site-footer'=>['title'=>'Main website footer','type'=>$type('Site footer','site-footer',['footer']),'data'=>['description'=>'One connected platform for modern teams.','show_social_links'=>true,'show_back_to_top'=>true]],
'site-header'=>['title'=>'Main website header','type'=>$type('Site header','site-header',['header'],['show_login'=>$toggle('Show login link'),'show_registration'=>$toggle('Show registration link'),'sticky'=>$toggle('Sticky header')]),'data'=>['show_login'=>true,'show_registration'=>true,'sticky'=>true]],
'hero'=>['title'=>'Homepage hero','type'=>$type('Hero','hero',['body'],['eyebrow'=>$text('Eyebrow'),'heading'=>$text('Heading','text',true),'description'=>$text('Description','textarea'),'primary_label'=>$text('Primary button label'),'primary_url'=>$text('Primary button URL','url'),'secondary_label'=>$text('Secondary button label'),'secondary_url'=>$text('Secondary button URL','url'),'show_metrics'=>$toggle('Show metrics')]),'data'=>['eyebrow'=>'A better way to run your business','heading'=>'Transform Your Business With Our SaaS Solution','description'=>'Streamline operations, boost productivity, and improve customer satisfaction with one connected platform built for modern teams.','primary_label'=>'Get started free','primary_url'=>'/register','secondary_label'=>'Book demo','secondary_url'=>'#contact','show_metrics'=>true]],
'feature-grid'=>['title'=>'Core features','type'=>$type('Feature grid','feature-grid',['body'],['eyebrow'=>$text('Eyebrow'),'heading'=>$text('Heading','text',true),'description'=>$text('Description','textarea'),'items'=>['type'=>'array','label'=>'Features','control'=>'repeater','item'=>['title'=>$text('Title','text',true),'description'=>$text('Description','textarea')]]]),'data'=>['eyebrow'=>'Powerful features','heading'=>'Everything your team needs to grow','description'=>'Planning, collaboration, reporting, publishing, commerce and customer insight in one extensible platform.','items'=>[['title'=>'Smart automation','description'=>'Remove repetitive work and delays.'],['title'=>'Real-time analytics','description'=>'Turn activity into useful business insight.'],['title'=>'Content operations','description'=>'Create, review and publish confidently.'],['title'=>'Secure by design','description'=>'Protect users and data with dependable controls.'],['title'=>'Flexible integrations','description'=>'Connect the tools your organisation already uses.'],['title'=>'Modular architecture','description'=>'Activate only the capabilities each site needs.']]]],
'product-feed'=>['title'=>'Pricing plans','type'=>$type('Product feed','product-feed',['body'],['eyebrow'=>$text('Eyebrow'),'heading'=>$text('Heading'),'description'=>$text('Description','textarea'),'limit'=>$number('Maximum products',1,24)]),'data'=>['eyebrow'=>'Simple pricing','heading'=>'Choose a plan that fits','description'=>'Plans are managed from Products & Pricing in the CMS.','limit'=>6]],
'testimonial-feed'=>['title'=>'Customer stories','type'=>$type('Testimonial feed','testimonial-feed',['body'],['eyebrow'=>$text('Eyebrow'),'heading'=>$text('Heading'),'limit'=>$number('Maximum testimonials',1,24)]),'data'=>['eyebrow'=>'Customer stories','heading'=>'Trusted by ambitious teams','limit'=>6]],
'team-feed'=>['title'=>'Team','type'=>$type('Team feed','team-feed',['body'],['eyebrow'=>$text('Eyebrow'),'heading'=>$text('Heading'),'limit'=>$number('Maximum team members',1,24)]),'data'=>['eyebrow'=>'Our team','heading'=>'People behind the platform','limit'=>8]],
'blog-feed'=>['title'=>'Latest blog posts','type'=>$type('Blog feed','blog-feed',['body'],['eyebrow'=>$text('Eyebrow'),'heading'=>$text('Heading'),'limit'=>$number('Maximum posts',1,24)]),'data'=>['eyebrow'=>'Insights','heading'=>'Latest from the blog','limit'=>3]],
'news-feed'=>['title'=>'Latest news','type'=>$type('News feed','news-feed',['body'],['eyebrow'=>$text('Eyebrow'),'heading'=>$text('Heading'),'limit'=>$number('Maximum stories',1,24)]),'data'=>['eyebrow'=>'Newsroom','heading'=>'Latest news','limit'=>3]],
'faq-feed'=>['title'=>'Frequently asked questions','type'=>$type('FAQ feed','faq-feed',['body'],['eyebrow'=>$text('Eyebrow'),'heading'=>$text('Heading'),'limit'=>$number('Maximum questions',1,30)]),'data'=>['eyebrow'=>'FAQ','heading'=>'Answers before you begin','limit'=>12]],
'newsletter-form'=>['title'=>'Newsletter signup','type'=>$type('Newsletter form','newsletter-form',['body'],['eyebrow'=>$text('Eyebrow'),'heading'=>$text('Heading'),'description'=>$text('Description','textarea'),'button_label'=>$text('Button label')]),'data'=>['eyebrow'=>'Newsletter','heading'=>'Useful updates, no noise','description'=>'Receive product news, publishing guidance, and practical platform updates.','button_label'=>'Subscribe']],
'call-to-action'=>['title'=>'Homepage final call to action','type'=>$type('Call to action','call-to-action',['body'],['heading'=>$text('Heading','text',true),'description'=>$text('Description','textarea'),'button_label'=>$text('Button label'),'button_url'=>$text('Button URL','url')]),'data'=>['heading'=>'Ready to transform your business?','description'=>'Start building a faster, more connected operation today.','button_label'=>'Get started free','button_url'=>'/register']],
'site-footer'=>['title'=>'Main website footer','type'=>$type('Site footer','site-footer',['footer'],['description'=>$text('Description','textarea'),'show_social_links'=>$toggle('Show social links'),'show_back_to_top'=>$toggle('Show back to top')]),'data'=>['description'=>'One connected platform for modern teams.','show_social_links'=>true,'show_back_to_top'=>true]],
];
}
}
@@ -1,120 +1,171 @@
<x-layouts.app :title="$document->exists ? $document->title : __('Create content document')">
<div class="mx-auto max-w-6xl space-y-6">
@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 ? 'Edit' : 'Create' }}</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">Structured, reusable content that can be placed into one or more page regions.</p>
<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>
<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(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>
<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 will appear everywhere it is published. Use “Create independent copy” for page-specific content.
</div>
<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 method="POST" action="{{ $document->exists ? route('content.documents.update', $document) : route('content.documents.store') }}" class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]">
<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="space-y-6">
<flux:card class="space-y-5">
<h2 class="text-lg font-semibold">Document</h2>
<flux:input name="title" label="Title" value="{{ old('title', $document->title) }}" required />
<flux:input name="slug" label="Slug" value="{{ old('slug', $document->slug) }}" description="Leave empty to generate it from the title." />
<flux: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:card>
<flux:card class="space-y-4">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold">Structured data</h2>
<p class="text-sm text-zinc-500">This phase retains JSON editing while adding validation, copies, placement controls and preview. Schema-generated field controls follow in the next checkpoint.</p>
</div>
<button type="button" id="format-json" class="rounded-lg border border-zinc-300 px-3 py-2 text-sm hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800">Format JSON</button>
</div>
<textarea id="data-json" class="min-h-[32rem] w-full rounded-xl border border-zinc-300 bg-white p-4 font-mono text-sm text-zinc-900 shadow-sm focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" name="data_json" spellcheck="false">{{ old('data_json', json_encode($document->data ?: [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) }}</textarea>
<p id="json-message" class="hidden text-sm"></p>
</flux:card>
<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>
<aside class="space-y-6">
<flux:card class="space-y-4">
<h2 class="text-lg font-semibold">Publishing</h2>
<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>
<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>
</flux:card>
<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>
@if($document->exists)
<flux:card>
<h2 class="text-lg font-semibold">Usage</h2>
<p class="mt-1 text-sm text-zinc-500">{{ $document->placements->count() }} placement{{ $document->placements->count() === 1 ? '' : 's' }}</p>
<div class="mt-4 space-y-2">
@forelse($document->placements as $placement)
<a class="block rounded-lg border border-zinc-200 p-3 text-sm 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 text-xs uppercase text-zinc-500">{{ $placement->region }}</span>
</a>
@empty
<p class="text-sm text-zinc-500">Not currently assigned to a page.</p>
@endforelse
</div>
</flux:card>
@endif
</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>
document.getElementById('format-json')?.addEventListener('click', () => {
const field = document.getElementById('data-json');
const message = document.getElementById('json-message');
try {
field.value = JSON.stringify(JSON.parse(field.value), null, 2);
message.textContent = 'Valid JSON.';
message.className = 'text-sm text-green-700 dark:text-green-300';
} catch (error) {
message.textContent = error.message;
message.className = 'text-sm text-red-700 dark:text-red-300';
(() => {
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>