hasComposition()
* renderRegion() * draft and published-state handling * document and placement scheduling * disabled-placement filtering * ordered region rendering * safe fallback when composition tables or document views are missing * compatibility with common alternative column names
This commit is contained in:
@@ -1,146 +0,0 @@
|
||||
<x-layouts.app :title="'Page composition: '.$pageKey">
|
||||
<div class="space-y-6" x-data="compositionEditor()">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg: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>Page composition</span></div>
|
||||
<h1 class="mt-1 text-2xl font-semibold">{{ ucfirst($pageKey) }} page</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500">Drag documents to reorder each region. Reusing a document keeps content synchronised across pages.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<flux:button :href="route('content.documents.create')">Create document</flux:button>
|
||||
<flux:button variant="primary" :href="route('content.pages.preview', $pageKey)" target="_blank">Open preview</flux:button>
|
||||
</div>
|
||||
</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"><ul class="list-disc pl-5">@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach</ul></div>@endif
|
||||
|
||||
<div class="grid gap-6 2xl:grid-cols-[minmax(0,1fr)_25rem]">
|
||||
<div class="space-y-6">
|
||||
@foreach(['header', 'body', 'footer'] as $region)
|
||||
<section class="overflow-hidden rounded-2xl border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-950">
|
||||
<div class="flex items-center justify-between border-b border-zinc-200 bg-white px-5 py-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div><h2 class="text-lg font-semibold">{{ ucfirst($region) }}</h2><p class="text-xs text-zinc-500">{{ $region === 'body' ? 'Ordered content sections' : 'Page-level '.$region.' document' }}</p></div>
|
||||
<span class="rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">{{ $placements->get($region, collect())->count() }} document{{ $placements->get($region, collect())->count() === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<div class="min-h-24 space-y-3 p-4" data-region="{{ $region }}" @dragover.prevent @drop="drop($event, '{{ $region }}')">
|
||||
@forelse($placements->get($region, collect()) as $placement)
|
||||
<article draggable="true" data-placement-id="{{ $placement->id }}" @dragstart="startDrag($event)" class="group rounded-xl border border-zinc-200 bg-white p-4 shadow-sm transition hover:border-zinc-400 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:border-zinc-500 {{ $placement->is_enabled ? '' : 'opacity-60' }}">
|
||||
<div class="flex items-start gap-3">
|
||||
<button type="button" title="Drag to reorder" class="mt-1 cursor-grab select-none text-xl leading-none text-zinc-400 active:cursor-grabbing">⋮⋮</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="font-semibold">{{ $placement->document?->title }}</h3>
|
||||
<span class="rounded bg-zinc-100 px-2 py-0.5 text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">{{ $placement->document?->type?->name }}</span>
|
||||
@if(($placement->document?->placements()->count() ?? 0) > 1)<span class="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-950 dark:text-blue-200">Shared</span>@endif
|
||||
@unless($placement->is_enabled)<span class="rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700 dark:bg-amber-950 dark:text-amber-200">Disabled</span>@endunless
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-zinc-500">Order {{ $placement->sort_order }} · {{ $placement->document?->status === 'published' ? 'Published' : 'Draft — hidden from public rendering' }}</p>
|
||||
</div>
|
||||
<a href="{{ route('content.documents.edit', $placement->document) }}" class="text-sm font-medium underline">Edit</a>
|
||||
</div>
|
||||
|
||||
<details class="mt-4 border-t border-zinc-100 pt-3 dark:border-zinc-800">
|
||||
<summary class="cursor-pointer text-sm text-zinc-600 dark:text-zinc-300">Placement settings</summary>
|
||||
<div class="mt-4 grid gap-4 lg:grid-cols-[1fr_1fr_auto]">
|
||||
<form method="POST" action="{{ route('content.placements.update', $placement) }}" class="contents">
|
||||
@csrf @method('PUT')
|
||||
<flux:input type="datetime-local" name="starts_at" label="Show from" value="{{ $placement->starts_at?->format('Y-m-d\TH:i') }}" />
|
||||
<flux:input type="datetime-local" name="ends_at" label="Hide after" value="{{ $placement->ends_at?->format('Y-m-d\TH:i') }}" />
|
||||
<div class="flex items-end gap-2">
|
||||
<label class="mb-2 flex items-center gap-2 text-sm"><input type="checkbox" name="is_enabled" value="1" @checked($placement->is_enabled)> Enabled</label>
|
||||
<flux:button type="submit" size="sm">Save</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<form method="POST" action="{{ route('content.placements.duplicate', $placement) }}">@csrf<flux:button type="submit" size="sm">Create page-specific copy</flux:button></form>
|
||||
<form method="POST" action="{{ route('content.placements.destroy', $placement) }}" onsubmit="return confirm('Remove this document from the page? The reusable document itself will remain available.')">@csrf @method('DELETE')<flux:button type="submit" size="sm" variant="danger">Remove from page</flux:button></form>
|
||||
</div>
|
||||
</details>
|
||||
</article>
|
||||
@empty
|
||||
<div class="rounded-xl border-2 border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500 dark:border-zinc-700">No documents assigned to this region.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</section>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<aside class="space-y-6 2xl:sticky 2xl:top-6 2xl:self-start">
|
||||
<flux:card class="space-y-4">
|
||||
<div><h2 class="text-lg font-semibold">Add existing document</h2><p class="text-sm text-zinc-500">Reuse published or draft content. Draft documents remain hidden from the public page.</p></div>
|
||||
<form method="POST" action="{{ route('content.placements.store', $pageKey) }}" class="space-y-4">
|
||||
@csrf
|
||||
<flux:select name="region" label="Region" required>
|
||||
<option value="header">Header</option><option value="body" selected>Body</option><option value="footer">Footer</option>
|
||||
</flux:select>
|
||||
<label class="block text-sm font-medium">Document
|
||||
<select name="content_document_id" required class="mt-2 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 dark:border-zinc-700 dark:bg-zinc-950">
|
||||
<option value="">Select a document</option>
|
||||
@foreach($documents as $document)
|
||||
<option value="{{ $document->id }}" data-regions="{{ implode(',', $document->type?->allowed_regions ?? []) }}">{{ $document->title }} — {{ $document->type?->name }}{{ $document->status === 'draft' ? ' (draft)' : '' }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
<flux:button type="submit" variant="primary" class="w-full">Add to page</flux:button>
|
||||
</form>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<h2 class="text-lg font-semibold">Editing shared content</h2>
|
||||
<p class="mt-2 text-sm text-zinc-500">Editing a shared document changes every placement. “Create page-specific copy” makes a disabled draft copy beside the original so it can be reviewed before switching.</p>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<h2 class="text-lg font-semibold">Live order</h2>
|
||||
<p class="mt-2 text-sm text-zinc-500">Order is saved automatically after dropping a document. A confirmation appears here.</p>
|
||||
<p x-show="message" x-text="message" class="mt-3 rounded-lg bg-zinc-100 p-3 text-sm dark:bg-zinc-800"></p>
|
||||
</flux:card>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function compositionEditor() {
|
||||
return {
|
||||
dragged: null,
|
||||
message: '',
|
||||
startDrag(event) {
|
||||
this.dragged = event.currentTarget;
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', this.dragged.dataset.placementId);
|
||||
},
|
||||
async drop(event, region) {
|
||||
if (!this.dragged) return;
|
||||
const container = event.currentTarget;
|
||||
const target = event.target.closest('[data-placement-id]');
|
||||
if (target && target !== this.dragged) {
|
||||
const rect = target.getBoundingClientRect();
|
||||
container.insertBefore(this.dragged, event.clientY < rect.top + rect.height / 2 ? target : target.nextSibling);
|
||||
} else if (!target) {
|
||||
container.appendChild(this.dragged);
|
||||
}
|
||||
const ids = [...container.querySelectorAll('[data-placement-id]')].map(item => Number(item.dataset.placementId));
|
||||
this.message = 'Saving order…';
|
||||
try {
|
||||
const response = await fetch(@js(route('content.placements.reorder', $pageKey)), {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content},
|
||||
body: JSON.stringify({region, placements: ids})
|
||||
});
|
||||
if (!response.ok) throw new Error((await response.json()).message || 'Unable to save order.');
|
||||
this.message = 'Order saved.';
|
||||
} catch (error) {
|
||||
this.message = error.message;
|
||||
window.setTimeout(() => window.location.reload(), 1200);
|
||||
} finally {
|
||||
this.dragged = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
</x-layouts.app>
|
||||
@@ -46,7 +46,9 @@
|
||||
|
||||
<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 class="mb-4"><h2 class="font-semibold">Components</h2><p class="text-xs text-zinc-500">Add layouts or document fields to the canvas.</p></div>
|
||||
<div id="layout-library" class="mb-5 grid grid-cols-2 gap-2"></div>
|
||||
<div class="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">Document fields</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>
|
||||
@@ -86,79 +88,249 @@
|
||||
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 layoutTemplates = {
|
||||
section: { type: 'section', label: 'Section', children: [] },
|
||||
container: { type: 'container', label: 'Container', children: [] },
|
||||
columns2: { type: 'columns', label: 'Two columns', columns: [{ children: [] }, { children: [] }] },
|
||||
columns3: { type: 'columns', label: 'Three columns', columns: [{ children: [] }, { children: [] }, { children: [] }] },
|
||||
};
|
||||
|
||||
const uid = () => `node_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const clone = value => JSON.parse(JSON.stringify(value));
|
||||
const escapeHtml = value => String(value ?? '').replace(/[&<>'"]/g, char => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[char]));
|
||||
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');
|
||||
|
||||
function normaliseNode(node) {
|
||||
node.id ||= uid();
|
||||
node.type ||= 'field';
|
||||
if (node.type === 'columns') {
|
||||
node.columns ||= [{ children: [] }, { children: [] }];
|
||||
node.columns.forEach(column => {
|
||||
column.id ||= uid();
|
||||
column.children ||= [];
|
||||
column.children.forEach(normaliseNode);
|
||||
});
|
||||
} else {
|
||||
node.children ||= [];
|
||||
node.children.forEach(normaliseNode);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function defaultLayout() {
|
||||
return [{ id: uid(), type: 'section', label: 'Content', children: Object.keys(schema).map(key => ({ id: uid(), type: 'field', key })) }];
|
||||
}
|
||||
|
||||
let layout = Array.isArray(data._layout) ? data._layout : defaultLayout();
|
||||
layout.forEach(normaliseNode);
|
||||
let selectedId = layout[0]?.id || null;
|
||||
let dragId = null;
|
||||
|
||||
const library = document.getElementById('builder-library');
|
||||
const layoutLibrary = document.getElementById('layout-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 => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[char]));
|
||||
const sync = () => { hidden.value = JSON.stringify(data); jsonEditor.value = JSON.stringify(data, null, 2); };
|
||||
const sync = () => {
|
||||
data._layout = layout;
|
||||
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>`;
|
||||
function walk(nodes, callback, parent = null, slot = null) {
|
||||
for (let index = 0; index < nodes.length; index++) {
|
||||
const node = nodes[index];
|
||||
if (callback(node, nodes, index, parent, slot) === false) return false;
|
||||
if (node.type === 'columns') {
|
||||
for (let columnIndex = 0; columnIndex < node.columns.length; columnIndex++) {
|
||||
if (walk(node.columns[columnIndex].children, callback, node, `column:${columnIndex}`) === false) return false;
|
||||
}
|
||||
} else if (walk(node.children || [], callback, node, 'children') === false) return false;
|
||||
}
|
||||
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 findNode(id) {
|
||||
let found = null;
|
||||
walk(layout, (node, collection, index, parent, slot) => {
|
||||
if (node.id === id) { found = { node, collection, index, parent, slot }; return false; }
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function detachNode(id) {
|
||||
const found = findNode(id);
|
||||
if (!found) return null;
|
||||
return found.collection.splice(found.index, 1)[0];
|
||||
}
|
||||
|
||||
function containsNode(node, id) {
|
||||
if (node.id === id) return true;
|
||||
let result = false;
|
||||
const inspect = children => children?.some(child => containsNode(child, id));
|
||||
if (node.type === 'columns') result = node.columns.some(column => inspect(column.children));
|
||||
else result = inspect(node.children);
|
||||
return Boolean(result);
|
||||
}
|
||||
|
||||
function targetCollection(targetId, slot = 'children') {
|
||||
if (targetId === 'root') return layout;
|
||||
const target = findNode(targetId)?.node;
|
||||
if (!target) return layout;
|
||||
if (slot.startsWith('column:')) return target.columns[Number(slot.split(':')[1])].children;
|
||||
target.children ||= [];
|
||||
return target.children;
|
||||
}
|
||||
|
||||
function addNode(template, targetId = 'root', slot = 'children') {
|
||||
const node = normaliseNode(clone(template));
|
||||
targetCollection(targetId, slot).push(node);
|
||||
selectedId = node.id;
|
||||
update();
|
||||
}
|
||||
|
||||
function fieldNode(key) { return { id: uid(), type: 'field', key }; }
|
||||
|
||||
function renderLibraries() {
|
||||
layoutLibrary.innerHTML = Object.entries(layoutTemplates).map(([key, template]) => `<button type="button" data-add-layout="${key}" class="rounded-lg border border-zinc-200 px-2 py-2 text-left text-xs hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800"><strong class="block">${escapeHtml(template.label)}</strong><span class="text-zinc-500">Add</span></button>`).join('');
|
||||
layoutLibrary.querySelectorAll('[data-add-layout]').forEach(button => button.addEventListener('click', () => addNode(layoutTemplates[button.dataset.addLayout])));
|
||||
|
||||
library.innerHTML = Object.entries(schema).map(([key, definition]) => `<button type="button" data-add-field="${escapeHtml(key)}" class="w-full rounded-lg border border-zinc-200 px-3 py-2 text-left text-sm 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] text-zinc-500">${escapeHtml(controlFor(definition))} · add to canvas</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.</p>';
|
||||
library.querySelectorAll('[data-add-field]').forEach(button => button.addEventListener('click', () => addNode(fieldNode(button.dataset.addField))));
|
||||
}
|
||||
|
||||
function nodeToolbar(node) {
|
||||
return `<div class="flex items-center gap-1"><button type="button" data-action="duplicate" data-id="${node.id}" class="rounded border border-zinc-300 px-2 py-1 text-[11px] dark:border-zinc-700">Duplicate</button><button type="button" data-action="delete" data-id="${node.id}" class="rounded border border-red-300 px-2 py-1 text-[11px] text-red-700 dark:border-red-800 dark:text-red-300">Delete</button></div>`;
|
||||
}
|
||||
|
||||
function renderDropZone(targetId, slot, label = 'Drop here') {
|
||||
return `<div data-drop-target="${targetId}" data-drop-slot="${slot}" class="my-2 rounded-lg border border-dashed border-zinc-300 px-3 py-2 text-center text-[11px] text-zinc-400 transition dark:border-zinc-700">${label}</div>`;
|
||||
}
|
||||
|
||||
function renderNode(node) {
|
||||
const selected = node.id === selectedId;
|
||||
const base = `relative rounded-xl border ${selected ? 'border-blue-500 ring-2 ring-blue-500/20' : 'border-zinc-200 dark:border-zinc-700'}`;
|
||||
if (node.type === 'field') {
|
||||
const definition = schema[node.key] || {};
|
||||
const value = data[node.key];
|
||||
return `<article draggable="true" data-node-id="${node.id}" class="${base} bg-white p-4 dark:bg-zinc-900"><div class="mb-2 flex items-start justify-between gap-2"><button type="button" data-select-node="${node.id}" class="text-left"><span class="block text-xs font-semibold uppercase tracking-wide text-zinc-500">${escapeHtml(titleFor(node.key, definition))}</span><span class="text-[11px] text-zinc-400">Field</span></button>${nodeToolbar(node)}</div><p class="whitespace-pre-wrap text-sm text-zinc-700 dark:text-zinc-200">${escapeHtml(value === true ? 'Enabled' : value === false ? 'Disabled' : value || 'Click to add content')}</p></article>`;
|
||||
}
|
||||
if (node.type === 'columns') {
|
||||
return `<section draggable="true" data-node-id="${node.id}" class="${base} bg-zinc-50 p-3 dark:bg-zinc-950/50"><div class="mb-3 flex items-center justify-between"><button type="button" data-select-node="${node.id}" class="text-left"><strong class="text-sm">${escapeHtml(node.label || 'Columns')}</strong><span class="ml-2 text-[11px] text-zinc-500">${node.columns.length} columns</span></button>${nodeToolbar(node)}</div><div class="grid gap-3" style="grid-template-columns:repeat(${node.columns.length},minmax(0,1fr))">${node.columns.map((column, index) => `<div class="min-h-28 rounded-lg border border-dashed border-zinc-300 bg-white p-2 dark:border-zinc-700 dark:bg-zinc-900">${renderDropZone(node.id, `column:${index}`, `Column ${index + 1}`)}<div class="space-y-2">${column.children.map(renderNode).join('')}</div></div>`).join('')}</div></section>`;
|
||||
}
|
||||
return `<section draggable="true" data-node-id="${node.id}" class="${base} ${node.type === 'section' ? 'bg-zinc-50 p-4 dark:bg-zinc-950/50' : 'bg-white p-3 dark:bg-zinc-900'}"><div class="mb-3 flex items-center justify-between"><button type="button" data-select-node="${node.id}" class="text-left"><strong class="text-sm">${escapeHtml(node.label || titleFor(node.type))}</strong><span class="ml-2 text-[11px] uppercase text-zinc-500">${node.type}</span></button>${nodeToolbar(node)}</div>${renderDropZone(node.id, 'children')}<div class="space-y-3">${(node.children || []).map(renderNode).join('')}</div></section>`;
|
||||
}
|
||||
|
||||
function bindCanvas() {
|
||||
canvas.querySelectorAll('[data-select-node]').forEach(button => button.addEventListener('click', event => { event.stopPropagation(); selectedId = button.dataset.selectNode; renderCanvas(); renderInspector(); }));
|
||||
canvas.querySelectorAll('[data-action]').forEach(button => button.addEventListener('click', event => {
|
||||
event.stopPropagation();
|
||||
const found = findNode(button.dataset.id);
|
||||
if (!found) return;
|
||||
if (button.dataset.action === 'delete') {
|
||||
found.collection.splice(found.index, 1);
|
||||
selectedId = found.parent?.id || layout[0]?.id || null;
|
||||
} else {
|
||||
const copy = clone(found.node);
|
||||
const renew = node => { node.id = uid(); if (node.type === 'columns') node.columns.forEach(column => { column.id = uid(); column.children.forEach(renew); }); else (node.children || []).forEach(renew); };
|
||||
renew(copy);
|
||||
found.collection.splice(found.index + 1, 0, copy);
|
||||
selectedId = copy.id;
|
||||
}
|
||||
update();
|
||||
}));
|
||||
canvas.querySelectorAll('[data-node-id]').forEach(element => {
|
||||
element.addEventListener('dragstart', event => { dragId = element.dataset.nodeId; event.dataTransfer.effectAllowed = 'move'; event.stopPropagation(); });
|
||||
element.addEventListener('dragend', () => { dragId = null; });
|
||||
});
|
||||
canvas.querySelectorAll('[data-drop-target]').forEach(zone => {
|
||||
zone.addEventListener('dragover', event => { event.preventDefault(); event.stopPropagation(); zone.classList.add('border-blue-500','bg-blue-50','dark:bg-blue-950/30'); });
|
||||
zone.addEventListener('dragleave', () => zone.classList.remove('border-blue-500','bg-blue-50','dark:bg-blue-950/30'));
|
||||
zone.addEventListener('drop', event => {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
zone.classList.remove('border-blue-500','bg-blue-50','dark:bg-blue-950/30');
|
||||
const moving = findNode(dragId)?.node;
|
||||
const target = findNode(zone.dataset.dropTarget)?.node;
|
||||
if (!moving || moving.id === target?.id || (target && containsNode(moving, target.id))) return;
|
||||
const detached = detachNode(dragId);
|
||||
if (!detached) return;
|
||||
targetCollection(zone.dataset.dropTarget, zone.dataset.dropSlot).push(detached);
|
||||
selectedId = detached.id;
|
||||
update();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function select(key) { selectedKey = key; renderLibrary(); renderCanvas(); renderInspector(); }
|
||||
function update() { sync(); renderLibrary(); renderCanvas(); renderInspector(); }
|
||||
function renderCanvas() {
|
||||
canvas.innerHTML = `${renderDropZone('root', 'children', 'Drop at page root')}<div class="space-y-3">${layout.map(renderNode).join('')}</div>`;
|
||||
if (!layout.length) canvas.innerHTML += '<div class="flex min-h-64 items-center justify-center text-center text-sm text-zinc-500">Add a section, columns, container, or field from the component library.</div>';
|
||||
bindCanvas();
|
||||
}
|
||||
|
||||
function renderFieldInspector(node) {
|
||||
const definition = schema[node.key] || {};
|
||||
const control = controlFor(definition);
|
||||
const label = titleFor(node.key, 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[node.key] ? '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[node.key] || '')}</textarea></label>`;
|
||||
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}" 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[node.key] ?? '')}"></label>`;
|
||||
}
|
||||
const input = document.getElementById('inspector-control');
|
||||
input?.addEventListener(control === 'toggle' ? 'change' : 'input', event => {
|
||||
data[node.key] = control === 'toggle' ? event.target.checked : control === 'number' ? Number(event.target.value) : event.target.value;
|
||||
sync(); renderCanvas();
|
||||
});
|
||||
}
|
||||
|
||||
function renderInspector() {
|
||||
const found = findNode(selectedId);
|
||||
if (!found) { inspector.innerHTML = '<p class="text-sm text-zinc-500">Select a component on the canvas.</p>'; return; }
|
||||
const node = found.node;
|
||||
if (node.type === 'field') return renderFieldInspector(node);
|
||||
document.getElementById('inspector-help').textContent = node.label || titleFor(node.type);
|
||||
inspector.innerHTML = `<div class="space-y-4"><label class="block text-sm font-medium">Label<input id="node-label" 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(node.label || '')}"></label>${node.type === 'columns' ? `<label class="block text-sm font-medium">Columns<select id="column-count" 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"><option value="2" ${node.columns.length === 2 ? 'selected' : ''}>Two</option><option value="3" ${node.columns.length === 3 ? 'selected' : ''}>Three</option></select></label>` : ''}<p class="rounded-lg bg-zinc-50 p-3 text-xs text-zinc-500 dark:bg-zinc-800">Drag components using their cards. Drop them into sections, containers, columns, or the page root.</p></div>`;
|
||||
document.getElementById('node-label')?.addEventListener('input', event => { node.label = event.target.value; sync(); renderCanvas(); });
|
||||
document.getElementById('column-count')?.addEventListener('change', event => {
|
||||
const count = Number(event.target.value);
|
||||
while (node.columns.length < count) node.columns.push({ id: uid(), children: [] });
|
||||
while (node.columns.length > count) {
|
||||
const removed = node.columns.pop();
|
||||
node.columns[node.columns.length - 1].children.push(...removed.children);
|
||||
}
|
||||
update();
|
||||
});
|
||||
}
|
||||
|
||||
function update() { sync(); renderLibraries(); 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'; }
|
||||
try {
|
||||
data = JSON.parse(jsonEditor.value);
|
||||
layout = Array.isArray(data._layout) ? data._layout : defaultLayout();
|
||||
layout.forEach(normaliseNode);
|
||||
selectedId = layout[0]?.id || null;
|
||||
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.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}`;
|
||||
|
||||
@@ -1,53 +1,6 @@
|
||||
<x-layouts.app :title="__('Content documents')">
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Content documents</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500">Create and reuse typed content across page regions.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<flux:button :href="route('content.pages.edit', 'home')">Compose homepage</flux:button>
|
||||
<flux:button variant="primary" :href="route('content.documents.create')">Create document</flux:button>
|
||||
</div>
|
||||
</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
|
||||
@error('document')<div class="rounded-xl border border-red-300 bg-red-50 p-4 text-sm text-red-800">{{ $message }}</div>@enderror
|
||||
|
||||
<form method="GET" class="grid gap-3 rounded-xl border border-zinc-200 bg-white p-4 md:grid-cols-[minmax(12rem,1fr)_14rem_10rem_auto] dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<flux:input name="search" label="Search" value="{{ request('search') }}" placeholder="Document title" />
|
||||
<flux:select name="type" label="Content type">
|
||||
<option value="">All types</option>
|
||||
@foreach($types as $type)<option value="{{ $type->key }}" @selected(request('type') === $type->key)>{{ $type->name }}</option>@endforeach
|
||||
</flux:select>
|
||||
<flux:select name="status" label="Status">
|
||||
<option value="">All statuses</option>
|
||||
<option value="draft" @selected(request('status') === 'draft')>Draft</option>
|
||||
<option value="published" @selected(request('status') === 'published')>Published</option>
|
||||
</flux:select>
|
||||
<div class="flex items-end gap-2"><flux:button type="submit">Filter</flux:button><flux:button :href="route('content.index')" variant="ghost">Reset</flux:button></div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-zinc-50 text-left text-xs uppercase text-zinc-500 dark:bg-zinc-950"><tr><th class="p-3">Document</th><th class="p-3">Type</th><th class="p-3">Status</th><th class="p-3">Uses</th><th class="p-3 text-right">Actions</th></tr></thead>
|
||||
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||
@forelse($documents as $document)
|
||||
<tr>
|
||||
<td class="p-3"><div class="font-medium">{{ $document->title }}</div><div class="text-xs text-zinc-500">{{ $document->slug }}</div></td>
|
||||
<td class="p-3">{{ $document->type?->name }}</td>
|
||||
<td class="p-3"><span class="rounded-full px-2 py-1 text-xs {{ $document->status === 'published' ? 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-200' : 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200' }}">{{ ucfirst($document->status) }}</span></td>
|
||||
<td class="p-3">{{ $document->placements_count }}</td>
|
||||
<td class="p-3 text-right"><flux:button size="sm" :href="route('content.documents.edit', $document)">Edit</flux:button></td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="p-10 text-center text-zinc-500">No content documents match the current filters.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ $documents->links() }}
|
||||
</div>
|
||||
<x-layouts.app title="Content documents">
|
||||
<div class="space-y-6"><div class="flex items-center justify-between"><div><h1 class="text-2xl font-semibold">Content documents</h1><p class="text-sm text-zinc-500">Reusable structured content used by page compositions.</p></div><flux:button href="{{ route('content.documents.create') }}" variant="primary">Create document</flux:button></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 class="grid gap-3 md:grid-cols-4"><flux:input name="search" value="{{ request('search') }}" placeholder="Search"/><flux:select name="type"><option value="">All types</option>@foreach($types as $type)<option value="{{ $type->key }}" @selected(request('type')===$type->key)>{{ $type->name }}</option>@endforeach</flux:select><flux:select name="status"><option value="">All statuses</option>@foreach(['draft','published','archived'] as $status)<option value="{{ $status }}" @selected(request('status')===$status)>{{ ucfirst($status) }}</option>@endforeach</flux:select><flux:button type="submit">Filter</flux:button></form>
|
||||
<div class="overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-700"><table class="w-full text-sm"><thead class="bg-zinc-50 dark:bg-zinc-900"><tr><th class="p-3 text-left">Title</th><th class="p-3 text-left">Type</th><th class="p-3 text-left">Status</th><th class="p-3 text-left">Usage</th></tr></thead><tbody>@forelse($documents as $document)<tr class="border-t border-zinc-200 dark:border-zinc-700"><td class="p-3"><a class="font-medium hover:underline" href="{{ route('content.documents.edit',$document) }}">{{ $document->title }}</a><div class="text-xs text-zinc-500">{{ $document->slug }}</div></td><td class="p-3">{{ $document->type?->name }}</td><td class="p-3">{{ ucfirst($document->status) }}</td><td class="p-3">{{ $document->placements->count() }}</td></tr>@empty<tr><td colspan="4" class="p-6 text-center text-zinc-500">No documents found.</td></tr>@endforelse</tbody></table></div>{{ $documents->links() }}</div>
|
||||
</x-layouts.app>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<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>
|
||||
@@ -1,3 +0,0 @@
|
||||
@if(app()->hasDebugModeEnabled())
|
||||
<!-- Unsupported content renderer: {{ $document->type?->renderer }} -->
|
||||
@endif
|
||||
Reference in New Issue
Block a user